From 83c96884d83773f9925cc046c5fc1780ff0f8044 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 1 Sep 2025 17:22:20 +0200 Subject: [PATCH 01/16] add missing meta description tags --- club/templates/club/club_detail.jinja | 8 ++++ club/templates/club/club_list.jinja | 14 ++++--- club/views.py | 4 ++ com/templates/com/news_list.jinja | 4 -- core/templates/core/base.jinja | 8 +++- eboutic/templates/eboutic/eboutic_main.jinja | 8 +++- forum/templates/forum/main.jinja | 8 +++- locale/fr/LC_MESSAGES/django.po | 39 +++++++++++++++++--- pedagogy/templates/pedagogy/guide.jinja | 6 ++- sas/templates/sas/main.jinja | 4 ++ 10 files changed, 83 insertions(+), 20 deletions(-) diff --git a/club/templates/club/club_detail.jinja b/club/templates/club/club_detail.jinja index 2d2c5719..33760695 100644 --- a/club/templates/club/club_detail.jinja +++ b/club/templates/club/club_detail.jinja @@ -1,6 +1,14 @@ {% extends "core/base.jinja" %} {% from 'core/macros.jinja' import user_profile_link %} +{% block title -%} + {{ club.name }} +{%- endblock %} + +{% block description -%} + {{ club.short_description }} +{%- endblock %} + {% block content %}
{% if club.logo %} diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja index f914803e..da0a54be 100644 --- a/club/templates/club/club_list.jinja +++ b/club/templates/club/club_list.jinja @@ -1,8 +1,12 @@ {% extends "core/base.jinja" %} -{% block title %} +{% block title -%} {% trans %}Club list{% endtrans %} -{% endblock %} +{%- endblock %} + +{% block description -%} + {% trans %}The list of all clubs existing at UTBM.{% endtrans %} +{%- endblock %} {% macro display_club(club) -%} @@ -21,7 +25,7 @@ {%- if club.children.all()|length != 0 %} @@ -36,8 +40,8 @@ {% if club_list %}

{% trans %}Club list{% endtrans %}

{% else %} diff --git a/club/views.py b/club/views.py index 184e64fd..735e8291 100644 --- a/club/views.py +++ b/club/views.py @@ -171,6 +171,10 @@ class ClubListView(ListView): model = Club template_name = "club/club_list.jinja" + queryset = ( + Club.objects.filter(parent=None).order_by("name").prefetch_related("children") + ) + context_object_name = "club_list" class ClubView(ClubTabsMixin, DetailView): diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 92e4dd71..1becd1ba 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -1,10 +1,6 @@ {% extends "core/base.jinja" %} {% from "com/macros.jinja" import news_moderation_alert %} -{% block title %} - {% trans %}News{% endtrans %} -{% endblock %} - {% block additional_css %} diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 225abcfd..b3cf07bb 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -2,8 +2,14 @@ {% block head %} - {% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM + {% block title %}Association des Étudiants de l'UTBM{% endblock %} + diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 18ece465..1809d0b7 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -1,8 +1,12 @@ {% extends "core/base.jinja" %} -{% block title %} +{% block title -%} {% trans %}Eboutic{% endtrans %} -{% endblock %} +{%- endblock %} + +{% block description -%} + {% trans %}The online shop of the association.{% endtrans %} +{%- endblock %} {% block additional_js %} {# This script contains the code to perform requests to manipulate the diff --git a/forum/templates/forum/main.jinja b/forum/templates/forum/main.jinja index 137617ae..a2a280b3 100644 --- a/forum/templates/forum/main.jinja +++ b/forum/templates/forum/main.jinja @@ -2,9 +2,13 @@ {% from 'core/macros.jinja' import user_profile_link %} {% from 'forum/macros.jinja' import display_forum, display_search_bar %} -{% block title %} +{% block title -%} {% trans %}Forum{% endtrans %} -{% endblock %} +{%- endblock %} + +{% block description -%} + {% trans %}A forum dedicated to the UTBM students.{% endtrans %} +{%- endblock %} {% block additional_css %} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index f1ad16da..59b0205f 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-08-23 15:30+0200\n" +"POT-Creation-Date: 2025-09-01 18:18+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -306,6 +306,10 @@ msgstr "Utilisateur non enregistré" msgid "Club list" msgstr "Liste des clubs" +#: club/templates/club/club_list.jinja +msgid "The list of all clubs existing at UTBM." +msgstr "La liste de tous les clubs existants à l'UTBM" + #: club/templates/club/club_list.jinja msgid "inactive" msgstr "inactif" @@ -901,7 +905,7 @@ msgid "News admin" msgstr "Administration des nouvelles" #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja -#: com/templates/com/news_list.jinja com/views.py +#: com/views.py msgid "News" msgstr "Nouvelles" @@ -1035,7 +1039,7 @@ msgstr "Liens" msgid "Our services" msgstr "Nos services" -#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja +#: com/templates/com/news_list.jinja msgid "UV Guide" msgstr "Guide des UVs" @@ -1705,8 +1709,12 @@ msgid "500, Server Error" msgstr "500, Erreur Serveur" #: core/templates/core/base.jinja -msgid "Welcome!" -msgstr "Bienvenue !" +msgid "" +"AE UTBM is a voluntary organisation run by UTBM students. It organises " +"student life at UTBM and manages its student facilities." +msgstr "" +"L'AE UTBM est une association bénévole gérée par les étudiants de " +"l'UTBM.Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja msgid "Contacts" @@ -3819,6 +3827,10 @@ msgstr "" msgid "Pay with Sith account" msgstr "Payer avec un compte AE" +#: eboutic/templates/eboutic/eboutic_main.jinja +msgid "The online shop of the association." +msgstr "La boutique en ligne de l'association." + #: eboutic/templates/eboutic/eboutic_main.jinja msgid "Clear" msgstr "Vider" @@ -4148,6 +4160,10 @@ msgstr "Message supprimé ou non-visible." msgid "Order by date" msgstr "Trier par date" +#: forum/templates/forum/main.jinja +msgid "A forum dedicated to the UTBM students." +msgstr "un forum dédié aux étudiants de l'UTBM." + #: forum/templates/forum/main.jinja msgid "View last unread messages" msgstr "Voir les derniers messages non lus" @@ -4374,6 +4390,14 @@ msgstr "signaler" msgid "reporter" msgstr "signalant" +#: pedagogy/templates/pedagogy/guide.jinja +msgid "UE Guide" +msgstr "Guide des UEs" + +#: pedagogy/templates/pedagogy/guide.jinja +msgid "A guide of courses available at UTBM." +msgstr "Un guide de tous les cours disponibles à l'UTBM." + #: pedagogy/templates/pedagogy/guide.jinja #, python-format msgid "%(display_name)s" @@ -4666,6 +4690,11 @@ msgstr "Demande de retrait d'image" msgid "Request removal" msgstr "Demander le retrait" +#: sas/templates/sas/main.jinja +msgid "See all the photos taken during events organised by the AE." +msgstr "" +"Retrouvez toutes les photos prises lors des événements organisés par l'AE." + #: sas/templates/sas/main.jinja msgid "You must be logged in to see the SAS." msgstr "Vous devez être connecté pour voir les photos." diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja index 460fdcc5..85f8dce5 100644 --- a/pedagogy/templates/pedagogy/guide.jinja +++ b/pedagogy/templates/pedagogy/guide.jinja @@ -2,9 +2,13 @@ {% from 'core/macros.jinja' import paginate_alpine %} {% block title %} - {% trans %}UV Guide{% endtrans %} + {% trans %}UE Guide{% endtrans %} {% endblock %} +{% block description -%} + {% trans %}A guide of courses available at UTBM.{% endtrans %} +{%- endblock %} + {% block additional_css %} {% endblock %} diff --git a/sas/templates/sas/main.jinja b/sas/templates/sas/main.jinja index bd66fd64..52824af7 100644 --- a/sas/templates/sas/main.jinja +++ b/sas/templates/sas/main.jinja @@ -8,6 +8,10 @@ {% trans %}SAS{% endtrans %} {% endblock %} +{% block description -%} + {% trans %}See all the photos taken during events organised by the AE.{% endtrans %} +{%- endblock %} + {% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} {% from "sas/macros.jinja" import display_album %} From 03759fd83ede189da6fd31d725404fe7dee86d52 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 1 Sep 2025 18:21:55 +0200 Subject: [PATCH 02/16] fix translations --- locale/fr/LC_MESSAGES/django.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 59b0205f..a638339e 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -1714,7 +1714,7 @@ msgid "" "student life at UTBM and manages its student facilities." msgstr "" "L'AE UTBM est une association bénévole gérée par les étudiants de " -"l'UTBM.Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." +"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja msgid "Contacts" @@ -4162,7 +4162,7 @@ msgstr "Trier par date" #: forum/templates/forum/main.jinja msgid "A forum dedicated to the UTBM students." -msgstr "un forum dédié aux étudiants de l'UTBM." +msgstr "Un forum dédié aux étudiants de l'UTBM." #: forum/templates/forum/main.jinja msgid "View last unread messages" From 5646f22968411d876a5ede1db218721d29ba8a21 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 2 Sep 2025 12:51:45 +0200 Subject: [PATCH 03/16] feat: add sitemap --- sith/settings.py | 3 ++- sith/sitemap.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ sith/urls.py | 6 +++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 sith/sitemap.py diff --git a/sith/settings.py b/sith/settings.py index b095a114..0544658d 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -99,9 +99,10 @@ INSTALLED_APPS = ( "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.sitemaps", + "django.contrib.sites", "django.contrib.messages", "staticfiles", - "django.contrib.sites", "honeypot", "django_jinja", "ninja_extra", diff --git a/sith/sitemap.py b/sith/sitemap.py new file mode 100644 index 00000000..5b0dec55 --- /dev/null +++ b/sith/sitemap.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.contrib.sitemaps import Sitemap +from django.db.models import OuterRef, Subquery +from django.urls import reverse + +from club.models import Club +from core.models import Page, PageRev + + +class SithSitemap(Sitemap): + def items(self): + return [ + "core:index", + "eboutic:main", + "sas:main", + "forum:main", + "club:club_list", + "election:list", + ] + + def location(self, item): + return reverse(item) + + +class PagesSitemap(Sitemap): + def items(self): + return ( + Page.objects.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID) + .exclude(revisions=None, _full_name__startswith="club") + .annotate( + lastmod=Subquery( + PageRev.objects.filter(page=OuterRef("pk")) + .values("date") + .order_by("-date")[:1] + ) + ) + .all() + ) + + def lastmod(self, item: Page): + return item.lastmod + + +class ClubSitemap(Sitemap): + def items(self): + return Club.objects.filter(is_active=True) diff --git a/sith/urls.py b/sith/urls.py index dd560626..561de616 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -15,20 +15,24 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin +from django.contrib.sitemaps.views import sitemap from django.http import Http404 from django.urls import include, path +from django.views.decorators.cache import cache_page from django.views.i18n import JavaScriptCatalog from api.urls import api +from sith.sitemap import ClubSitemap, PagesSitemap, SithSitemap js_info_dict = {"packages": ("sith",)} handler403 = "core.views.forbidden" handler404 = "core.views.not_found" handler500 = "core.views.internal_servor_error" - +sitemaps = {"sith": SithSitemap, "pages": PagesSitemap, "clubs": ClubSitemap} urlpatterns = [ path("", include(("core.urls", "core"), namespace="core")), + path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}), path("api/", api.urls), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path( From 17c50934bbbf364310b11d154d9245957879832f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 3 Sep 2025 13:55:07 +0200 Subject: [PATCH 04/16] fix: news notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Résout trois problèmes : - la création des notifications faisait un N+1 queries - le décompte du nombre de nouvelles à modérer était mauvais - modérer une nouvelle ne modifiait pas les notifications des autres admins --- com/models.py | 59 ++++++++++++++++++++------------- com/tests/test_notifications.py | 32 ++++++++++++++++-- sith/settings.py | 4 ++- 3 files changed, 69 insertions(+), 26 deletions(-) diff --git a/com/models.py b/com/models.py index ebc458e7..849c6b12 100644 --- a/com/models.py +++ b/com/models.py @@ -27,7 +27,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives from django.db import models, transaction -from django.db.models import F, Q +from django.db.models import Exists, F, OuterRef, Q from django.shortcuts import render from django.templatetags.static import static from django.urls import reverse @@ -55,9 +55,17 @@ class Sith(models.Model): class NewsQuerySet(models.QuerySet): - def moderated(self) -> Self: + def published(self) -> Self: return self.filter(is_published=True) + def waiting_moderation(self) -> Self: + """Filter all non-finished non-published news""" + # Because of the way News and NewsDates are created, + # there may be some cases where this method is called before + # the NewsDates linked to a Date are actually persisted in db. + # Thus, it's important to filter by "not past date" rather than by "future date" + return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False) + def viewable_by(self, user: User) -> Self: """Filter news that the given user can view. @@ -127,20 +135,28 @@ class News(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) - if self.is_published: - return - for user in User.objects.filter( - groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] - ): - Notification.objects.create( - user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION" + if not self.is_published: + admins_without_notif = User.objects.filter( + ~Exists( + Notification.objects.filter( + user=OuterRef("pk"), type="NEWS_MODERATION" + ) + ), + groups__id=settings.SITH_GROUP_COM_ADMIN_ID, ) + notif_url = reverse("com:news_admin_list") + new_notifs = [ + Notification(user=user, url=notif_url, type="NEWS_MODERATION") + for user in admins_without_notif + ] + Notification.objects.bulk_create(new_notifs) + self.update_moderation_notifs() def get_absolute_url(self): return reverse("com:news_detail", kwargs={"news_id": self.id}) def get_full_url(self): - return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url()) + return f"https://{settings.SITH_URL}{self.get_absolute_url()}" def is_owned_by(self, user): if user.is_anonymous: @@ -159,19 +175,16 @@ class News(models.Model): or (user.is_authenticated and self.author_id == user.id) ) - -def news_notification_callback(notif: Notification): - # the NewsDate linked to the News - # which creation triggered this callback may not exist yet, - # so it's important to filter by "not past date" rather than by "future date" - count = News.objects.filter( - ~Q(dates__start_date__gt=timezone.now()), is_published=False - ).count() - if count: - notif.viewed = False - notif.param = str(count) - else: - notif.viewed = True + @staticmethod + def update_moderation_notifs(): + count = News.objects.waiting_moderation().count() + notifs_qs = Notification.objects.filter( + type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID + ) + if count: + notifs_qs.update(viewed=False, param=str(count)) + else: + notifs_qs.update(viewed=True) class NewsDateQuerySet(models.QuerySet): diff --git a/com/tests/test_notifications.py b/com/tests/test_notifications.py index fa541efb..8ddbfcb3 100644 --- a/com/tests/test_notifications.py +++ b/com/tests/test_notifications.py @@ -1,13 +1,22 @@ +from datetime import timedelta + import pytest from django.conf import settings +from django.utils.timezone import now from model_bakery import baker -from com.models import News +from com.models import News, NewsDate +from core.baker_recipes import subscriber_user from core.models import Group, Notification, User @pytest.mark.django_db def test_notification_created(): + # this news is unpublished, but is set in the past + # it shouldn't be taken into account when counting the number + # of news that are to be moderated + past_news = baker.make(News, is_published=False) + baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1)) com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) com_admin_group.users.all().delete() Notification.objects.all().delete() @@ -15,9 +24,28 @@ def test_notification_created(): for i in range(2): # news notifications are permanent, so the notification created # during the first iteration should be reused during the second one. - baker.make(News) + baker.make(News, is_published=False) notifications = list(Notification.objects.all()) assert len(notifications) == 1 assert notifications[0].user == com_admin assert notifications[0].type == "NEWS_MODERATION" assert notifications[0].param == str(i + 1) + + +@pytest.mark.django_db +def test_notification_edited_when_moderating_news(): + com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) + com_admins = subscriber_user.make(_quantity=3) + com_admin_group.users.set(com_admins) + Notification.objects.all().delete() + news = baker.make(News, is_published=False) + assert Notification.objects.count() == 3 + assert Notification.objects.filter(viewed=False).count() == 3 + + news.is_published = True + news.moderator = com_admins[0] + news.save() + # when the news is moderated, the notification should be marked as read + # for all admins + assert Notification.objects.count() == 3 + assert Notification.objects.filter(viewed=False).count() == 0 diff --git a/sith/settings.py b/sith/settings.py index b095a114..d2f152c5 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -686,8 +686,10 @@ SITH_NOTIFICATIONS = [ # The keys are the notification names as found in SITH_NOTIFICATIONS, and the # values are the callback function to update the notifs. # The callback must take the notif object as first and single argument. +# If a notification is permanent but requires no post-action, set the +# callback import string as None SITH_PERMANENT_NOTIFICATIONS = { - "NEWS_MODERATION": "com.models.news_notification_callback", + "NEWS_MODERATION": None, "SAS_MODERATION": "sas.models.sas_notification_callback", } From cb454935ad2d07ee10c573fb4ae6998704c0a443 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 3 Sep 2025 14:00:09 +0200 Subject: [PATCH 05/16] fix: N+1 queries on ICS generation --- com/ics_calendar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com/ics_calendar.py b/com/ics_calendar.py index 6cb10d2d..b0a2da5b 100644 --- a/com/ics_calendar.py +++ b/com/ics_calendar.py @@ -68,7 +68,7 @@ class IcsCalendar: start=news_date.start_date, end=news_date.end_date, url=as_absolute_url( - reverse("com:news_detail", kwargs={"news_id": news_date.news.id}) + reverse("com:news_detail", kwargs={"news_id": news_date.news_id}) ), ) calendar.events.append(event) From 84e2f1b45a8b14e7435e380a6cd6f10e1e59d3b7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 2 Sep 2025 15:10:44 +0200 Subject: [PATCH 06/16] fix: subscription form alignment --- subscription/static/subscription/css/subscription.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/subscription/static/subscription/css/subscription.scss b/subscription/static/subscription/css/subscription.scss index fd388574..d23d00b2 100644 --- a/subscription/static/subscription/css/subscription.scss +++ b/subscription/static/subscription/css/subscription.scss @@ -1,4 +1,14 @@ #subscription-form form { + margin-top: 0; + + .form-content { + margin-top: 0; + } + + fieldset p:first-of-type, & > p:first-of-type { + margin-top: 0; + } + .form-content.existing-user { max-height: 100%; display: flex; From 171a3f4d921e0113b93c6d325a2070f384a70ec2 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 2 Sep 2025 15:31:54 +0200 Subject: [PATCH 07/16] make some users not having birthday in populate_more.py --- core/management/commands/populate_more.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index f9456712..38803dc8 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -94,7 +94,11 @@ class Command(BaseCommand): username=self.faker.user_name(), first_name=self.faker.first_name(), last_name=self.faker.last_name(), - date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25), + date_of_birth=( + None + if random.random() < 0.2 + else self.faker.date_of_birth(minimum_age=15, maximum_age=25) + ), email=self.faker.email(), phone=self.faker.phone_number(), address=self.faker.address(), From 3709b5c221b687724488dba28154c8886c2057b9 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 2 Sep 2025 15:58:48 +0200 Subject: [PATCH 08/16] require birthday when creating subscriptions for users that didn't give it previously --- core/api.py | 11 +++- core/schemas.py | 16 ++++++ locale/fr/LC_MESSAGES/django.po | 8 ++- subscription/forms.py | 51 ++++++++++++++++++- .../creation-form-existing-user-index.ts | 17 ++++++- .../static/subscription/css/subscription.scss | 5 ++ subscription/tests/test_new_subscription.py | 37 +++++++++++++- 7 files changed, 135 insertions(+), 10 deletions(-) diff --git a/core/api.py b/core/api.py index 06b32989..af4daff5 100644 --- a/core/api.py +++ b/core/api.py @@ -25,6 +25,7 @@ from core.schemas import ( UserFamilySchema, UserFilterSchema, UserProfileSchema, + UserSchema, ) from core.templatetags.renderer import markdown @@ -69,16 +70,22 @@ class MailingListController(ControllerBase): return data -@api_controller("/user", permissions=[CanAccessLookup]) +@api_controller("/user") class UserController(ControllerBase): - @route.get("", response=list[UserProfileSchema]) + @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) def fetch_profiles(self, pks: Query[set[int]]): return User.objects.filter(pk__in=pks) + @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) + def fetch_user(self, user_id: int): + """Fetch a single user""" + return self.get_object_or_exception(User, id=user_id) + @route.get( "/search", response=PaginatedResponseSchema[UserProfileSchema], url_name="search_users", + permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=20) def search_users(self, filters: Query[UserFilterSchema]): diff --git a/core/schemas.py b/core/schemas.py index b5f5991f..5231d859 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -34,6 +34,22 @@ class SimpleUserSchema(ModelSchema): fields = ["id", "nick_name", "first_name", "last_name"] +class UserSchema(ModelSchema): + class Meta: + model = User + fields = [ + "id", + "nick_name", + "first_name", + "last_name", + "date_of_birth", + "email", + "role", + "quote", + "promo", + ] + + class UserProfileSchema(ModelSchema): """The necessary information to show a user profile""" diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index a638339e..dcf19d5e 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-01 18:18+0200\n" +"POT-Creation-Date: 2025-09-02 15:56+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -5135,6 +5135,10 @@ msgstr "Tee-shirt AE" msgid "A user with that email address already exists" msgstr "Un utilisateur avec cette adresse email existe déjà" +#: subscription/forms.py +msgid "This user didn't fill its birthdate yet." +msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance" + #: subscription/models.py msgid "Bad subscription type" msgstr "Mauvais type de cotisation" @@ -5174,7 +5178,7 @@ msgid "" "%(user)s received its new %(type)s subscription. It will be active until " "%(end)s included." msgstr "" -"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au " +"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au " "%(end)s inclu." #: subscription/templates/subscription/fragments/creation_success.jinja diff --git a/subscription/forms.py b/subscription/forms.py index babc613c..41d5ddbc 100644 --- a/subscription/forms.py +++ b/subscription/forms.py @@ -23,8 +23,8 @@ class SelectionDateForm(forms.Form): class SubscriptionForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - initial = kwargs.pop("initial", {}) + def __init__(self, *args, initial=None, **kwargs): + initial = initial or {} if "subscription_type" not in initial: initial["subscription_type"] = "deux-semestres" if "payment_method" not in initial: @@ -131,8 +131,55 @@ class SubscriptionExistingUserForm(SubscriptionForm): """Form to add a subscription to an existing user.""" template_name = "subscription/forms/create_existing_user.html" + required_css_class = "required" + + birthdate = forms.fields_for_model( + User, + ["date_of_birth"], + widgets={"date_of_birth": SelectDate(attrs={"hidden": True})}, + help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")}, + )["date_of_birth"] class Meta: model = Subscription fields = ["member", "subscription_type", "payment_method", "location"] widgets = {"member": AutoCompleteSelectUser} + + field_order = [ + "member", + "birthdate", + "subscription_type", + "payment_method", + "location", + ] + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + self.fields["birthdate"].required = True + if not initial: + return + member = initial.get("member") + if member: + member = User.objects.filter(id=member).first() + if member and member.date_of_birth: + # if there is an initial member with a birthdate, + # there is no need to ask this to the user + self.fields["birthdate"].initial = member.date_of_birth + elif member: + # if there is an initial member without a birthdate, + # then the field must be displayed + self.fields["birthdate"].widget.attrs.update({"hidden": False}) + # if there is no initial member, it means that it will be + # dynamically selected using the AutoCompleteSelectUser widget. + # JS will take care of un-hiding the field if necessary + + def save(self, *args, **kwargs): + if self.errors: + return super().save(*args, **kwargs) + if ( + self.cleaned_data["birthdate"] is not None + and self.instance.member.date_of_birth != self.cleaned_data["birthdate"] + ): + self.instance.member.date_of_birth = self.cleaned_data["birthdate"] + self.instance.member.save() + return super().save(*args, **kwargs) diff --git a/subscription/static/bundled/subscription/creation-form-existing-user-index.ts b/subscription/static/bundled/subscription/creation-form-existing-user-index.ts index b997ad7b..3d258c90 100644 --- a/subscription/static/bundled/subscription/creation-form-existing-user-index.ts +++ b/subscription/static/bundled/subscription/creation-form-existing-user-index.ts @@ -1,3 +1,5 @@ +import { userFetchUser } from "#openapi"; + document.addEventListener("alpine:init", () => { Alpine.data("existing_user_subscription_form", () => ({ loading: false, @@ -12,13 +14,24 @@ document.addEventListener("alpine:init", () => { }, async loadProfile(userId: number) { + const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement; if (!Number.isInteger(userId)) { this.profileFragment = ""; + birthdayInput.hidden = true; return; } this.loading = true; - const response = await fetch(`/user/${userId}/mini/`); - this.profileFragment = await response.text(); + const [miniProfile, userInfos] = await Promise.all([ + fetch(`/user/${userId}/mini/`), + // biome-ignore lint/style/useNamingConvention: api is snake_case + userFetchUser({ path: { user_id: userId } }), + ]); + this.profileFragment = await miniProfile.text(); + // If the user has no birthdate yet, show the form input + // to fill this info. + // Else keep the input hidden and change its value to the user birthdate + birthdayInput.value = userInfos.data.date_of_birth; + birthdayInput.hidden = userInfos.data.date_of_birth !== null; this.loading = false; }, })); diff --git a/subscription/static/subscription/css/subscription.scss b/subscription/static/subscription/css/subscription.scss index d23d00b2..850abc76 100644 --- a/subscription/static/subscription/css/subscription.scss +++ b/subscription/static/subscription/css/subscription.scss @@ -23,6 +23,11 @@ * then display the user profile right in the middle of the remaining space. */ fieldset { flex: 0 1 auto; + + p:has(input[hidden]) { + // when the input is hidden, hide the whole label+input+help text group + display: none; + } } #subscription-form-user-mini-profile { diff --git a/subscription/tests/test_new_subscription.py b/subscription/tests/test_new_subscription.py index 1c4eaa6b..5933fe57 100644 --- a/subscription/tests/test_new_subscription.py +++ b/subscription/tests/test_new_subscription.py @@ -1,6 +1,6 @@ """Tests focused on testing subscription creation""" -from datetime import timedelta +from datetime import date, timedelta from typing import Callable import pytest @@ -31,6 +31,26 @@ def test_form_existing_user_valid( ): """Test `SubscriptionExistingUserForm`""" user = user_factory() + user.date_of_birth = date(year=1967, month=3, day=14) + user.save() + data = { + "member": user, + "birthdate": user.date_of_birth, + "subscription_type": "deux-semestres", + "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], + "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], + } + form = SubscriptionExistingUserForm(data) + assert form.is_valid() + form.save() + user.refresh_from_db() + assert user.is_subscribed + + +@pytest.mark.django_db +def test_form_existing_user_with_birthdate(settings: SettingsWrapper): + """Test `SubscriptionExistingUserForm`""" + user = baker.make(User, date_of_birth=None) data = { "member": user, "subscription_type": "deux-semestres", @@ -38,11 +58,15 @@ def test_form_existing_user_valid( "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], } form = SubscriptionExistingUserForm(data) + assert not form.is_valid() + data |= {"birthdate": date(year=1967, month=3, day=14)} + form = SubscriptionExistingUserForm(data) assert form.is_valid() form.save() user.refresh_from_db() assert user.is_subscribed + assert user.date_of_birth == date(year=1967, month=3, day=14) @pytest.mark.django_db @@ -132,6 +156,14 @@ def test_page_access( assert res.status_code == status_code +@pytest.mark.django_db +def test_page_access_with_get_data(client: Client): + user = old_subscriber_user.make() + client.force_login(baker.make(User, is_superuser=True)) + res = client.get(reverse("subscription:subscription", query={"member": user.id})) + assert res.status_code == 200 + + @pytest.mark.django_db def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): client.force_login( @@ -140,11 +172,12 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): user_permissions=Permission.objects.filter(codename="add_subscription"), ) ) - user = old_subscriber_user.make() + user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14)) response = client.post( reverse("subscription:fragment-existing-user"), { "member": user.id, + "birthdate": user.date_of_birth, "subscription_type": "deux-semestres", "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0], From 3ad40b73833d3646703d02608d81b0ac3855ac86 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 3 Sep 2025 11:50:01 +0200 Subject: [PATCH 09/16] change birthdate only if user didn't have it previously --- subscription/forms.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/subscription/forms.py b/subscription/forms.py index 41d5ddbc..1ac6964d 100644 --- a/subscription/forms.py +++ b/subscription/forms.py @@ -158,9 +158,11 @@ class SubscriptionExistingUserForm(SubscriptionForm): self.fields["birthdate"].required = True if not initial: return - member = initial.get("member") - if member: - member = User.objects.filter(id=member).first() + member: str | None = initial.get("member") + if member and member.isdigit(): + member: User | None = User.objects.filter(id=int(member)).first() + else: + member = None if member and member.date_of_birth: # if there is an initial member with a birthdate, # there is no need to ask this to the user @@ -178,7 +180,7 @@ class SubscriptionExistingUserForm(SubscriptionForm): return super().save(*args, **kwargs) if ( self.cleaned_data["birthdate"] is not None - and self.instance.member.date_of_birth != self.cleaned_data["birthdate"] + and self.instance.member.date_of_birth is None ): self.instance.member.date_of_birth = self.cleaned_data["birthdate"] self.instance.member.save() From b97a1a2e56da73659c63ed7ecf86e0d3ed6bace8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 4 Sep 2025 17:38:58 +0200 Subject: [PATCH 10/16] improve User.can_view and User.can_edit --- core/models.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/core/models.py b/core/models.py index 64b4b778..f2a799c3 100644 --- a/core/models.py +++ b/core/models.py @@ -560,7 +560,7 @@ class User(AbstractUser): """Determine if the object is owned by the user.""" if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): return True - if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id): + if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id): return True return self.is_root @@ -569,9 +569,15 @@ class User(AbstractUser): if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): return True if hasattr(obj, "edit_groups"): - for pk in obj.edit_groups.values_list("pk", flat=True): - if self.is_in_group(pk=pk): - return True + if ( + hasattr(obj, "_prefetched_objects_cache") + and "edit_groups" in obj._prefetched_objects_cache + ): + pks = [g.id for g in obj.edit_groups.all()] + else: + pks = list(obj.edit_groups.values_list("id", flat=True)) + if any(self.is_in_group(pk=pk) for pk in pks): + return True if isinstance(obj, User) and obj == self: return True return self.is_owner(obj) @@ -581,9 +587,18 @@ class User(AbstractUser): if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): return True if hasattr(obj, "view_groups"): - for pk in obj.view_groups.values_list("pk", flat=True): - if self.is_in_group(pk=pk): - return True + # if "view_groups" has already been prefetched, use + # the prefetch cache, else fetch only the ids, to make + # the query lighter. + if ( + hasattr(obj, "_prefetched_objects_cache") + and "view_groups" in obj._prefetched_objects_cache + ): + pks = [g.id for g in obj.view_groups.all()] + else: + pks = list(obj.view_groups.values_list("id", flat=True)) + if any(self.is_in_group(pk=pk) for pk in pks): + return True return self.can_edit(obj) def can_be_edited_by(self, user): @@ -1384,9 +1399,9 @@ class Page(models.Model): @cached_property def is_club_page(self): - club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() - return club_root_page is not None and ( - self == club_root_page or club_root_page in self.get_parent_list() + return ( + self.name == settings.SITH_CLUB_ROOT_PAGE + or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()] ) @cached_property From 37961e437b803faf8fd64d431b786d99f25515c8 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 4 Sep 2025 17:39:17 +0200 Subject: [PATCH 11/16] fix: N+1 queries on PageListView --- core/templates/core/page_list.jinja | 16 ++++++---------- core/views/page.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/core/templates/core/page_list.jinja b/core/templates/core/page_list.jinja index dfb8ea48..7a64035b 100644 --- a/core/templates/core/page_list.jinja +++ b/core/templates/core/page_list.jinja @@ -5,16 +5,12 @@ {% endblock %} {% block content %} - {% if page_list %} -

{% trans %}Page list{% endtrans %}

- - {% else %} - {% trans %}There is no page in this website.{% endtrans %} - {% endif %} +

{% trans %}Page list{% endtrans %}

+ {% endblock %} diff --git a/core/views/page.py b/core/views/page.py index 23898217..db366891 100644 --- a/core/views/page.py +++ b/core/views/page.py @@ -12,7 +12,10 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # + from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import F, OuterRef, Subquery +from django.db.models.functions import Coalesce # This file contains all the views that concern the page model from django.forms.models import modelform_factory @@ -43,6 +46,20 @@ class CanEditPagePropMixin(CanEditPropMixin): class PageListView(CanViewMixin, ListView): model = Page template_name = "core/page_list.jinja" + queryset = ( + Page.objects.annotate( + display_name=Coalesce( + Subquery( + PageRev.objects.filter(page=OuterRef("id")) + .order_by("-date") + .values("title")[:1] + ), + F("name"), + ) + ) + .prefetch_related("view_groups") + .select_related("parent") + ) class PageView(CanViewMixin, DetailView): From 25cd8771601048fa5f60c65cfd189e52271343c4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 13 Sep 2025 11:39:53 +0200 Subject: [PATCH 12/16] fix: `Counter.edit_groups` --- .../migrations/0012_club_board_group_club_members_group.py | 6 ++---- counter/models.py | 7 ------- sith/settings.py | 3 --- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/club/migrations/0012_club_board_group_club_members_group.py b/club/migrations/0012_club_board_group_club_members_group.py index f3d3a1e9..e436bcd4 100644 --- a/club/migrations/0012_club_board_group_club_members_group.py +++ b/club/migrations/0012_club_board_group_club_members_group.py @@ -34,12 +34,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor): clubs = list(Club.objects.all()) for club in clubs: club.board_group = meta_groups.get_or_create( - name=club.unix_name + settings.SITH_BOARD_SUFFIX, - defaults={"is_meta": True}, + name=f"{club.unix_name}-bureau", defaults={"is_meta": True} )[0] club.members_group = meta_groups.get_or_create( - name=club.unix_name + settings.SITH_MEMBER_SUFFIX, - defaults={"is_meta": True}, + name=f"{club.unix_name}-membres", defaults={"is_meta": True} )[0] club.save() club.refresh_from_db() diff --git a/counter/models.py b/counter/models.py index d2b9a927..ae9b2e49 100644 --- a/counter/models.py +++ b/counter/models.py @@ -535,13 +535,6 @@ class Counter(models.Model): def __str__(self): return self.name - def __getattribute__(self, name: str): - if name == "edit_groups": - return Group.objects.filter( - name=self.club.unix_name + settings.SITH_BOARD_SUFFIX - ).all() - return object.__getattribute__(self, name) - def get_absolute_url(self) -> str: if self.type == "EBOUTIC": return reverse("eboutic:main") diff --git a/sith/settings.py b/sith/settings.py index e2057928..52c93420 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -405,9 +405,6 @@ SITH_FORUM_PAGE_LENGTH = 30 SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_IMAGES_PER_PAGE = 60 -SITH_BOARD_SUFFIX = "-bureau" -SITH_MEMBER_SUFFIX = "-membres" - SITH_PROFILE_DEPARTMENTS = [ ("TC", _("TC")), ("IMSI", _("IMSI")), From b58da0ea3024f501dfb12a5afef05785047dc77a Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 15 Sep 2025 12:04:18 +0200 Subject: [PATCH 13/16] fix: dependabot.yml --- .github/auto_assign.yml | 2 +- .github/dependabot.yml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml index d577edab..0ea9c853 100644 --- a/.github/auto_assign.yml +++ b/.github/auto_assign.yml @@ -6,7 +6,7 @@ addAssignees: author # A list of team reviewers to be added to pull requests (GitHub team slug) reviewers: - - ae-utbm/sith-3-developers + - ae-utbm/developpeurs # Number of reviewers has no impact on GitHub teams # Set 0 to add all the reviewers (default: 0) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d9af29db..8fd9c718 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,7 +16,16 @@ multi-ecosystem-groups: updates: - package-ecosystem: "uv" + patterns: ["*"] multi-ecosystem-group: "common" - package-ecosystem: "npm" + patterns: ["*"] multi-ecosystem-group: "common" + groups: + # npm supports production and development groups, but not uv + # cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups + main-deps: + dependency-type: "production" + dev-deps: + dependency-type: "development" From c7fe8961abc6f5a651b8aea50aab8930b2f47b4e Mon Sep 17 00:00:00 2001 From: Kenneth SOARES Date: Tue, 16 Sep 2025 12:43:03 +0200 Subject: [PATCH 14/16] fixed display of archived products --- counter/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/counter/models.py b/counter/models.py index d2b9a927..ca43184a 100644 --- a/counter/models.py +++ b/counter/models.py @@ -690,8 +690,10 @@ class Counter(models.Model): Prices will be annotated """ - products = self.products.select_related("product_type").prefetch_related( - "buying_groups" + products = ( + self.products.filter(archived=False) + .select_related("product_type") + .prefetch_related("buying_groups") ) # Only include age appropriate products From 262281adda1faf03096e6622c4ed8bf3b23a2e96 Mon Sep 17 00:00:00 2001 From: Noa Fouich Date: Thu, 18 Sep 2025 14:40:20 +0200 Subject: [PATCH 15/16] Add test case --- counter/tests/test_counter.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index d90f9510..453727a1 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -583,6 +583,16 @@ class TestCounterClick(TestFullClickBase): - self.beer.selling_price ) + def test_no_fetch_archived_product(self): + counter = baker.make(Counter) + customer = baker.make(Customer) + product_recipe.make(archived=True, counters=[counter]) + unarchived_products = product_recipe.make( + archived=False, counters=[counter], _quantity=3 + ) + customer_products = counter.get_products_for(customer) + assert unarchived_products == customer_products + class TestCounterStats(TestCase): @classmethod From 08b16d6e74e2cfdb514759f83fbbe63c61b01b9b Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 19 Sep 2025 16:21:15 +0200 Subject: [PATCH 16/16] feat: make poster views available to club board members --- club/models.py | 9 ++ club/tests/test_club.py | 27 +++++ club/tests/test_posters.py | 35 ++++++ club/views.py | 58 +++++----- com/forms.py | 26 ++--- com/models.py | 12 --- com/tests/test_views.py | 30 +----- com/views.py | 184 +++++++++++--------------------- core/auth/mixins.py | 53 +++++++++ locale/fr/LC_MESSAGES/django.po | 27 ++--- 10 files changed, 242 insertions(+), 219 deletions(-) create mode 100644 club/tests/test_club.py create mode 100644 club/tests/test_posters.py diff --git a/club/models.py b/club/models.py index fc5aa64b..800f67c2 100644 --- a/club/models.py +++ b/club/models.py @@ -42,6 +42,13 @@ from core.fields import ResizedImageField from core.models import Group, Notification, Page, SithFile, User +class ClubQuerySet(models.QuerySet): + def having_board_member(self, user: User) -> Self: + """Filter all club in which the given user is a board member.""" + active_memberships = user.memberships.board().ongoing() + return self.filter(Exists(active_memberships.filter(club=OuterRef("pk")))) + + class Club(models.Model): """The Club class, made as a tree to allow nice tidy organization.""" @@ -91,6 +98,8 @@ class Club(models.Model): Group, related_name="club_board", on_delete=models.PROTECT ) + objects = ClubQuerySet.as_manager() + class Meta: ordering = ["name"] diff --git a/club/tests/test_club.py b/club/tests/test_club.py new file mode 100644 index 00000000..2a232b19 --- /dev/null +++ b/club/tests/test_club.py @@ -0,0 +1,27 @@ +from datetime import timedelta + +import pytest +from django.utils.timezone import localdate +from model_bakery import baker +from model_bakery.recipe import Recipe + +from club.models import Club, Membership +from core.baker_recipes import subscriber_user + + +@pytest.mark.django_db +def test_club_queryset_having_board_member(): + clubs = baker.make(Club, _quantity=5) + user = subscriber_user.make() + membership_recipe = Recipe( + Membership, user=user, start_date=localdate() - timedelta(days=3) + ) + membership_recipe.make(club=clubs[0], role=1) + membership_recipe.make(club=clubs[1], role=3) + membership_recipe.make(club=clubs[2], role=7) + membership_recipe.make( + club=clubs[3], role=3, end_date=localdate() - timedelta(days=1) + ) + + club_ids = Club.objects.having_board_member(user).values_list("id", flat=True) + assert set(club_ids) == {clubs[1].id, clubs[2].id} diff --git a/club/tests/test_posters.py b/club/tests/test_posters.py new file mode 100644 index 00000000..8f9941e5 --- /dev/null +++ b/club/tests/test_posters.py @@ -0,0 +1,35 @@ +import pytest +from django.test import Client +from django.urls import reverse +from model_bakery import baker + +from club.models import Club +from com.models import Poster +from core.baker_recipes import subscriber_user + + +@pytest.mark.django_db +@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"]) +def test_access(client: Client, route_url): + club = baker.make(Club) + user = subscriber_user.make() + url = reverse(route_url, kwargs={"club_id": club.id}) + + client.force_login(user) + assert client.get(url).status_code == 403 + club.board_group.users.add(user) + assert client.get(url).status_code == 200 + + +@pytest.mark.django_db +@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"]) +def test_access_specific_poster(client: Client, route_url): + club = baker.make(Club) + user = subscriber_user.make() + poster = baker.make(Poster) + url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id}) + + client.force_login(user) + assert client.get(url).status_code == 403 + club.board_group.users.add(user) + assert client.get(url).status_code == 200 diff --git a/club/views.py b/club/views.py index 735e8291..4ed40a9c 100644 --- a/club/views.py +++ b/club/views.py @@ -51,13 +51,17 @@ from club.forms import ( SellingsForm, ) from club.models import Club, Mailing, MailingSubscription, Membership +from com.models import Poster from com.views import ( PosterCreateBaseView, PosterDeleteBaseView, PosterEditBaseView, PosterListBaseView, ) -from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin +from core.auth.mixins import ( + CanEditMixin, + CanViewMixin, +) from core.models import PageRev from core.views import DetailFormView, PageEditViewBase from core.views.mixins import TabedViewMixin @@ -66,9 +70,12 @@ from counter.models import Selling class ClubTabsMixin(TabedViewMixin): def get_tabs_title(self): - obj = self.get_object() - if isinstance(obj, PageRev): - self.object = obj.page.club + if not hasattr(self, "object") or not self.object: + self.object = self.get_object() + if isinstance(self.object, PageRev): + self.object = self.object.page.club + elif isinstance(self.object, Poster): + self.object = self.object.club return self.object.get_display_name() def get_list_of_tabs(self): @@ -159,7 +166,7 @@ class ClubTabsMixin(TabedViewMixin): "club:poster_list", kwargs={"club_id": self.object.id} ), "slug": "posters", - "name": _("Posters list"), + "name": _("Posters"), }, ] ) @@ -686,48 +693,45 @@ class MailingAutoGenerationView(View): return redirect("club:mailing", club_id=club.id) -class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin): +class PosterListView(ClubTabsMixin, PosterListBaseView): """List communication posters.""" + current_tab = "posters" + extra_context = {"app": "club"} + + def get_queryset(self): + return super().get_queryset().filter(club=self.club.id) + def get_object(self): return self.club - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["app"] = "club" - kwargs["club"] = self.club - return kwargs - -class PosterCreateView(PosterCreateBaseView, CanCreateMixin): +class PosterCreateView(ClubTabsMixin, PosterCreateBaseView): """Create communication poster.""" - pk_url_kwarg = "club_id" - - def get_object(self): - obj = super().get_object() - if not obj: - return self.club - return obj + current_tab = "posters" def get_success_url(self, **kwargs): return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) + def get_object(self, *args, **kwargs): + return self.club -class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin): + +class PosterEditView(ClubTabsMixin, PosterEditBaseView): """Edit communication poster.""" + current_tab = "posters" + extra_context = {"app": "club"} + def get_success_url(self): return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["app"] = "club" - return kwargs - -class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin): +class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView): """Delete communication poster.""" + current_tab = "posters" + def get_success_url(self): return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) diff --git a/com/forms.py b/com/forms.py index e94d697e..4d5d0e09 100644 --- a/com/forms.py +++ b/com/forms.py @@ -2,7 +2,6 @@ from datetime import date from dateutil.relativedelta import relativedelta from django import forms -from django.db.models import Exists, OuterRef from django.forms import CheckboxInput from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -35,20 +34,18 @@ class PosterForm(forms.ModelForm): label=_("Start date"), widget=SelectDateTime, required=True, - initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"), + initial=timezone.now(), ) date_end = forms.DateTimeField( label=_("End date"), widget=SelectDateTime, required=False ) - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user", None) + def __init__(self, *args, user: User, **kwargs): super().__init__(*args, **kwargs) - if self.user and not self.user.is_com_admin: - self.fields["club"].queryset = Club.objects.filter( - id__in=self.user.clubs_with_rights - ) - self.fields.pop("display_time") + if user.is_root or user.is_com_admin: + self.fields["club"].widget = AutoCompleteSelectClub() + else: + self.fields["club"].queryset = Club.objects.having_board_member(user) class NewsDateForm(forms.ModelForm): @@ -161,16 +158,9 @@ class NewsForm(forms.ModelForm): # if the author is an admin, he/she can choose any club, # otherwise, only clubs for which he/she is a board member can be selected if author.is_root or author.is_com_admin: - self.fields["club"] = forms.ModelChoiceField( - queryset=Club.objects.all(), widget=AutoCompleteSelectClub - ) + self.fields["club"].widget = AutoCompleteSelectClub() else: - active_memberships = author.memberships.board().ongoing() - self.fields["club"] = forms.ModelChoiceField( - queryset=Club.objects.filter( - Exists(active_memberships.filter(club=OuterRef("pk"))) - ) - ) + self.fields["club"].queryset = Club.objects.having_board_member(author) def is_valid(self): return super().is_valid() and self.date_form.is_valid() diff --git a/com/models.py b/com/models.py index 849c6b12..6485d73b 100644 --- a/com/models.py +++ b/com/models.py @@ -412,17 +412,5 @@ class Poster(models.Model): if self.date_end and self.date_begin > self.date_end: raise ValidationError(_("Begin date should be before end date")) - def is_owned_by(self, user): - if user.is_anonymous: - return False - return user.is_com_admin or len(user.clubs_with_rights) > 0 - - def can_be_moderated_by(self, user): - return user.is_com_admin - def get_display_name(self): return self.club.get_display_name() - - @property - def page(self): - return self.club.page diff --git a/com/tests/test_views.py b/com/tests/test_views.py index 607d4b3f..02081597 100644 --- a/com/tests/test_views.py +++ b/com/tests/test_views.py @@ -18,17 +18,16 @@ from unittest.mock import patch import pytest from django.conf import settings from django.contrib.sites.models import Site -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import Client, TestCase from django.urls import reverse from django.utils import html -from django.utils.timezone import localtime, now +from django.utils.timezone import now from django.utils.translation import gettext as _ from model_bakery import baker from pytest_django.asserts import assertNumQueries, assertRedirects from club.models import Club, Membership -from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle +from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle from core.baker_recipes import subscriber_user from core.models import AnonymousUser, Group, User @@ -207,31 +206,6 @@ class TestWeekmailArticle(TestCase): assert not self.article.is_owned_by(self.sli) -class TestPoster(TestCase): - @classmethod - def setUpTestData(cls): - cls.com_admin = User.objects.get(username="comunity") - cls.poster = Poster.objects.create( - name="dummy", - file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"), - club=Club.objects.first(), - date_begin=localtime(now()), - ) - cls.sli = User.objects.get(username="sli") - cls.sli.memberships.all().delete() - Membership(user=cls.sli, club=Club.objects.first(), role=5).save() - cls.susbcriber = User.objects.get(username="subscriber") - cls.anonymous = AnonymousUser() - - def test_poster_owner(self): - """Test that poster are owned by com admins and board members in clubs.""" - assert self.poster.is_owned_by(self.com_admin) - assert not self.poster.is_owned_by(self.anonymous) - - assert not self.poster.is_owned_by(self.susbcriber) - assert self.poster.is_owned_by(self.sli) - - class TestNewsCreation(TestCase): @classmethod def setUpTestData(cls): diff --git a/com/views.py b/com/views.py index 024cb781..1b58feac 100644 --- a/com/views.py +++ b/com/views.py @@ -28,7 +28,9 @@ from typing import Any from dateutil.relativedelta import relativedelta from django.conf import settings -from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin, +) from django.contrib.syndication.views import Feed from django.core.exceptions import PermissionDenied, ValidationError from django.db.models import Max @@ -50,6 +52,7 @@ from core.auth.mixins import ( CanEditPropMixin, CanViewMixin, PermissionOrAuthorRequiredMixin, + PermissionOrClubBoardRequiredMixin, ) from core.models import User from core.views.mixins import QuickNotifMixin, TabedViewMixin @@ -99,13 +102,6 @@ class ComTabsMixin(TabedViewMixin): ] -class IsComAdminMixin(AccessMixin): - def dispatch(self, request, *args, **kwargs): - if not request.user.is_com_admin: - raise PermissionDenied - return super().dispatch(request, *args, **kwargs) - - class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView): model = Sith template_name = "core/edit.jinja" @@ -558,161 +554,109 @@ class MailingModerateView(View): raise PermissionDenied -class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin): - current_tab = "posters" - - -class PosterListBaseView(PosterAdminViewMixin, ListView): +class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView): """List communication posters.""" - current_tab = "posters" model = Poster template_name = "com/poster_list.jinja" - - def dispatch(self, request, *args, **kwargs): - club_id = kwargs.pop("club_id", None) - self.club = None - if club_id: - self.club = get_object_or_404(Club, pk=club_id) - return super().dispatch(request, *args, **kwargs) - - def get_queryset(self): - if self.request.user.is_com_admin: - return Poster.objects.all().order_by("-date_begin") - else: - return Poster.objects.filter(club=self.club.id) + permission_required = "com.view_poster" + ordering = ["-date_begin"] def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - if not self.request.user.is_com_admin: - kwargs["club"] = self.club - return kwargs + return super().get_context_data(**kwargs) | {"club": self.club} -class PosterCreateBaseView(PosterAdminViewMixin, CreateView): +class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView): """Create communication poster.""" - current_tab = "posters" form_class = PosterForm template_name = "core/create.jinja" + permission_required = "com.add_poster" def get_queryset(self): return Poster.objects.all() - def dispatch(self, request, *args, **kwargs): - if "club_id" in kwargs: - self.club = get_object_or_404(Club, pk=kwargs["club_id"]) - return super().dispatch(request, *args, **kwargs) - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs + return super().get_form_kwargs() | {"user": self.request.user} + + def get_initial(self): + return {"club": self.club} def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - if not self.request.user.is_com_admin: - kwargs["club"] = self.club - return kwargs + return super().get_context_data(**kwargs) | {"club": self.club} def form_valid(self, form): - if self.request.user.is_com_admin: + if self.request.user.has_perm("com.moderate_poster"): form.instance.is_moderated = True return super().form_valid(form) -class PosterEditBaseView(PosterAdminViewMixin, UpdateView): +class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView): """Edit communication poster.""" pk_url_kwarg = "poster_id" - current_tab = "posters" form_class = PosterForm template_name = "com/poster_edit.jinja" - - def get_initial(self): - return { - "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S") - if self.object.date_begin - else None, - "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S") - if self.object.date_end - else None, - } - - def dispatch(self, request, *args, **kwargs): - if kwargs.get("club_id"): - try: - self.club = Club.objects.get(pk=kwargs["club_id"]) - except Club.DoesNotExist as e: - raise PermissionDenied from e - return super().dispatch(request, *args, **kwargs) + permission_required = "com.change_poster" def get_queryset(self): return Poster.objects.all() def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs + return super().get_form_kwargs() | {"user": self.request.user} def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - if hasattr(self, "club"): - kwargs["club"] = self.club - return kwargs + return super().get_context_data(**kwargs) | {"club": self.club} def form_valid(self, form): - if self.request.user.is_com_admin: + if not self.request.user.has_perm("com.moderate_poster"): form.instance.is_moderated = False return super().form_valid(form) -class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView): +class PosterDeleteBaseView( + PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView +): """Edit communication poster.""" pk_url_kwarg = "poster_id" current_tab = "posters" model = Poster template_name = "core/delete_confirm.jinja" - - def dispatch(self, request, *args, **kwargs): - if kwargs.get("club_id"): - try: - self.club = Club.objects.get(pk=kwargs["club_id"]) - except Club.DoesNotExist as e: - raise PermissionDenied from e - return super().dispatch(request, *args, **kwargs) + permission_required = "com.delete_poster" -class PosterListView(PosterListBaseView): +class PosterListView(ComTabsMixin, PosterListBaseView): """List communication posters.""" + current_tab = "posters" + + def get_queryset(self): + qs = super().get_queryset() + if self.request.user.has_perm("com.view_poster"): + return qs + return qs.filter(club=self.club.id) + def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) kwargs["app"] = "com" return kwargs -class PosterCreateView(PosterCreateBaseView): +class PosterCreateView(ComTabsMixin, PosterCreateBaseView): """Create communication poster.""" + current_tab = "posters" success_url = reverse_lazy("com:poster_list") - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["app"] = "com" - return kwargs + extra_context = {"app": "com"} -class PosterEditView(PosterEditBaseView): +class PosterEditView(ComTabsMixin, PosterEditBaseView): """Edit communication poster.""" + current_tab = "posters" success_url = reverse_lazy("com:poster_list") - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["app"] = "com" - return kwargs + extra_context = {"app": "com"} class PosterDeleteView(PosterDeleteBaseView): @@ -721,44 +665,39 @@ class PosterDeleteView(PosterDeleteBaseView): success_url = reverse_lazy("com:poster_list") -class PosterModerateListView(PosterAdminViewMixin, ListView): +class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView): """Moderate list communication poster.""" current_tab = "posters" model = Poster template_name = "com/poster_moderate.jinja" queryset = Poster.objects.filter(is_moderated=False).all() - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["app"] = "com" - return kwargs + permission_required = "com.moderate_poster" + extra_context = {"app": "com"} -class PosterModerateView(PosterAdminViewMixin, View): +class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View): """Moderate communication poster.""" + current_tab = "posters" + permission_required = "com.moderate_poster" + extra_context = {"app": "com"} + def get(self, request, *args, **kwargs): obj = get_object_or_404(Poster, pk=kwargs["object_id"]) - if obj.can_be_moderated_by(request.user): - obj.is_moderated = True - obj.moderator = request.user - obj.save() - return redirect("com:poster_moderate_list") - raise PermissionDenied - - def get_context_data(self, **kwargs): - kwargs = super(PosterModerateListView, self).get_context_data(**kwargs) - kwargs["app"] = "com" - return kwargs + obj.is_moderated = True + obj.moderator = request.user + obj.save() + return redirect("com:poster_moderate_list") -class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView): +class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView): """List communication screens.""" current_tab = "screens" model = Screen template_name = "com/screen_list.jinja" + permission_required = "com.view_screen" class ScreenSlideshowView(DetailView): @@ -769,12 +708,12 @@ class ScreenSlideshowView(DetailView): template_name = "com/screen_slideshow.jinja" def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - kwargs["posters"] = self.object.active_posters() - return kwargs + return super().get_context_data(**kwargs) | { + "posters": self.object.active_posters() + } -class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView): +class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView): """Create communication screen.""" current_tab = "screens" @@ -782,9 +721,10 @@ class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView): fields = ["name"] template_name = "core/create.jinja" success_url = reverse_lazy("com:screen_list") + permission_required = "com.add_screen" -class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView): +class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView): """Edit communication screen.""" pk_url_kwarg = "screen_id" @@ -793,9 +733,10 @@ class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView): fields = ["name"] template_name = "com/screen_edit.jinja" success_url = reverse_lazy("com:screen_list") + permission_required = "com.change_screen" -class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView): +class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView): """Delete communication screen.""" pk_url_kwarg = "screen_id" @@ -803,3 +744,4 @@ class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView): model = Screen template_name = "core/delete_confirm.jinja" success_url = reverse_lazy("com:screen_list") + permission_required = "com.delete_screen" diff --git a/core/auth/mixins.py b/core/auth/mixins.py index dfba3f11..c4e603a9 100644 --- a/core/auth/mixins.py +++ b/core/auth/mixins.py @@ -29,8 +29,14 @@ from typing import TYPE_CHECKING, Any, LiteralString from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property +from django.utils.translation import gettext as _ from django.views.generic.base import View +from club.models import Club + if TYPE_CHECKING: from django.db.models import Model @@ -297,3 +303,50 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin): self.author_field += "_id" author_id = getattr(obj, self.author_field, None) return author_id == self.request.user.id + + +class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin): + """Require that the user has the required perm or is the board of the club. + + This mixin can be used in any view that is called from a url + having a `club_id` kwarg. + + Example: + + In `urls.py` : + ```python + urlpatterns = [ + path("foo//bar/", FooView.as_view()) + ] + ``` + + In `views.py` : + + ```python + # this view is available to users that either have the + # "foo.view_foo" permission or are in the board of the club + # which id was given in the url + class FooView(PermissionOrClubBoardRequiredMixin, View): + permission_required = "foo.view_foo" + ``` + """ + + club_pk_url_kwarg = "club_id" + + @cached_property + def club(self): + club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None) + if club_id is None: + return None + if isinstance(club_id, int) or club_id.isdigit(): + return get_object_or_404(Club, pk=club_id) + raise Http404(_("No club found with id %(id)s") % {"id": club_id}) + + def has_permission(self): + if self.request.user.is_anonymous: + return False + if super().has_permission(): + return True + return self.club is not None and any( + g.id == self.club.board_group_id for g in self.request.user.cached_groups + ) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index dcf19d5e..9b43dadf 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-02 15:56+0200\n" +"POT-Creation-Date: 2025-09-19 17:22+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -514,8 +514,8 @@ msgstr "Éditer le Trombi" msgid "New Trombi" msgstr "Nouveau Trombi" -#: club/templates/club/club_tools.jinja com/templates/com/poster_list.jinja -#: core/templates/core/user_tools.jinja +#: club/templates/club/club_tools.jinja club/views.py +#: com/templates/com/poster_list.jinja core/templates/core/user_tools.jinja msgid "Posters" msgstr "Affiches" @@ -675,10 +675,6 @@ msgstr "Vente" msgid "Mailing list" msgstr "Listes de diffusion" -#: club/views.py com/views.py -msgid "Posters list" -msgstr "Liste d'affiches" - #: com/forms.py msgid "Format: 16:9 | Resolution: 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080" @@ -1249,6 +1245,10 @@ msgstr "Message d'info" msgid "Alert message" msgstr "Message d'alerte" +#: com/views.py +msgid "Posters list" +msgstr "Liste d'affiches" + #: com/views.py msgid "Screens list" msgstr "Liste d'écrans" @@ -1272,6 +1272,11 @@ msgstr "" "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Weekmail." +#: core/auth/mixins.py +#, python-format +msgid "No club found with id %(id)s" +msgstr "Pas de club avec l'id %(id)s trouvé" + #: core/models.py msgid "Is manually manageable" msgstr "Est gérable manuellement" @@ -1713,8 +1718,8 @@ msgid "" "AE UTBM is a voluntary organisation run by UTBM students. It organises " "student life at UTBM and manages its student facilities." msgstr "" -"L'AE UTBM est une association bénévole gérée par les étudiants de " -"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." +"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. " +"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja msgid "Contacts" @@ -2157,10 +2162,6 @@ msgstr "" msgid "Page history" msgstr "Historique de la page" -#: core/templates/core/page_list.jinja -msgid "There is no page in this website." -msgstr "Il n'y a pas de page sur ce site web." - #: core/templates/core/page_prop.jinja msgid "Page properties" msgstr "Propriétés de la page"