feat: make poster views available to club board members

This commit is contained in:
imperosol
2025-09-19 16:21:15 +02:00
parent c6baab068a
commit 08b16d6e74
10 changed files with 242 additions and 219 deletions

View File

@@ -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"]

27
club/tests/test_club.py Normal file
View File

@@ -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}

View File

@@ -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

View File

@@ -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})

View File

@@ -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()

View File

@@ -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

View File

@@ -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):

View File

@@ -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"

View File

@@ -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/<int:club_id>/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
)

View File

@@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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"