From b784fd7ac89f311c8bb405bbeb55cf892e892c10 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 22 Mar 2026 11:21:20 +0100 Subject: [PATCH 01/11] add translations --- locale/fr/LC_MESSAGES/django.po | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 7791ae3f..5d9c46bc 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -258,8 +258,7 @@ msgstr "Rôle de la présidence" msgid "" "If the role is inactive, people joining the club won't be able to get it." msgstr "" -"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " -"rejoignent le club." +"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui rejoignent le club." #: club/models.py msgid "club role" @@ -274,20 +273,12 @@ msgstr "rôles de club" msgid "" "Role %(name)s was declared as a presidency role without being a board role" msgstr "" -"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du " -"bureau." +"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du bureau." #: club/models.py #, python-format -msgid "Role %(role)s cannot be placed below a member role" -msgstr "" -"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre." - -#: club/models.py -#, python-format -msgid "Role %(role)s cannot be placed below a non-presidency role" -msgstr "" -"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre." +msgid "Board role %(role)s cannot be placed below a member role" +msgstr "Le rôle du bureau %(role)s ne peut pas être placé en-dessous d'un rôle de membre." #: club/models.py core/models.py counter/models.py eboutic/models.py #: election/models.py pedagogy/models.py sas/models.py trombi/models.py From bfd147fd3c40d0113aae902e09378802f0fd0031 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 10 Apr 2026 18:53:29 +0200 Subject: [PATCH 02/11] add forgotten check --- locale/fr/LC_MESSAGES/django.po | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5d9c46bc..75cfcea1 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -258,7 +258,8 @@ msgstr "Rôle de la présidence" msgid "" "If the role is inactive, people joining the club won't be able to get it." msgstr "" -"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui rejoignent le club." +"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " +"rejoignent le club." #: club/models.py msgid "club role" @@ -273,12 +274,22 @@ msgstr "rôles de club" msgid "" "Role %(name)s was declared as a presidency role without being a board role" msgstr "" -"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du bureau." +"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du " +"bureau." #: club/models.py #, python-format -msgid "Board role %(role)s cannot be placed below a member role" -msgstr "Le rôle du bureau %(role)s ne peut pas être placé en-dessous d'un rôle de membre." +msgid "Role %(role)s cannot be placed below a member role" +msgstr "" +"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de " +"membre." + +#: club/models.py +#, python-format +msgid "Role %(role)s cannot be placed below a non-presidency role" +msgstr "" +"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de " +"membre." #: club/models.py core/models.py counter/models.py eboutic/models.py #: election/models.py pedagogy/models.py sas/models.py trombi/models.py From 07b08180d53e90542d3847ed8a520f435cab280f Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 28 Sep 2025 11:52:08 +0200 Subject: [PATCH 03/11] adapt tests to new club roles framework --- club/tests/test_club_controller.py | 2 +- club/tests/test_membership.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py index b6248e01..e084109c 100644 --- a/club/tests/test_club_controller.py +++ b/club/tests/test_club_controller.py @@ -9,7 +9,7 @@ from model_bakery import baker from model_bakery.recipe import Recipe from pytest_django.asserts import assertNumQueries -from club.models import Club, ClubRole, Membership +from club.models import Club, Membership, ClubRole from core.baker_recipes import subscriber_user from core.models import Group, Page, User diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index e1bafef7..285b50f4 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -317,6 +317,51 @@ class TestMembership(TestClub): self.club.refresh_from_db() assert self.club.members.count() == nb_memberships + def test_president_add_members(self): + """Test that the president of the club can add members.""" + president = self.club.members.get(role=self.president_role).user + nb_club_membership = self.club.members.count() + nb_subscriber_memberships = self.subscriber.memberships.count() + self.client.force_login(president) + response = self.client.post( + self.new_members_url, + {"user": self.subscriber.id, "role": self.president_role.id}, + ) + assert response.status_code == 200 + assert response.headers.get("HX-Redirect", "") == reverse( + "club:club_members", kwargs={"club_id": self.club.id} + ) + self.club.refresh_from_db() + self.subscriber.refresh_from_db() + assert self.club.members.count() == nb_club_membership + 1 + assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1 + self.assert_membership_started_today(self.subscriber, role=self.president_role) + + def test_add_member_greater_role(self): + """Test that a member of the club member cannot create + a membership with a greater role than its own. + """ + user_role = self.simple_board_member.memberships.first().role + other_role = baker.make(ClubRole, club=user_role.club, is_board=True) + other_role.above(user_role) + form = ClubAddMemberForm( + data={"user": self.subscriber.id, "role": other_role.id}, + request_user=self.simple_board_member, + club=self.club, + ) + nb_memberships = self.club.members.count() + + assert not form.is_valid() + assert form.errors == { + "role": [ + "Sélectionnez un choix valide. " + "Ce choix ne fait pas partie de ceux disponibles." + ] + } + self.club.refresh_from_db() + assert nb_memberships == self.club.members.count() + assert not self.subscriber.memberships.filter(club=self.club).exists() + def test_add_member_without_role(self): """Test that trying to add members without specifying their role fails.""" form = ClubAddMemberForm( From 3d8a832acaafea10c7d3e2fc118940a0b16926a4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 10 Apr 2026 19:02:06 +0200 Subject: [PATCH 04/11] feat: page to edit and reorder club role --- club/forms.py | 39 ++++++++++ club/static/bundled/club/role-list-index.ts | 37 ++++++++++ club/static/club/roles.scss | 7 ++ club/templates/club/club_roles.jinja | 82 +++++++++++++++++++++ club/urls.py | 2 + club/views.py | 29 +++++++- core/static/core/accordion.scss | 7 +- 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 club/static/bundled/club/role-list-index.ts create mode 100644 club/static/club/roles.scss create mode 100644 club/templates/club/club_roles.jinja diff --git a/club/forms.py b/club/forms.py index 76687103..284c5024 100644 --- a/club/forms.py +++ b/club/forms.py @@ -330,3 +330,42 @@ class ClubSearchForm(forms.ModelForm): # so we enforce it. self.fields["club_status"].value = True self.fields["name"].required = False + + +class ClubRoleForm(forms.ModelForm): + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = ClubRole + fields = ["name", "description", "is_presidency", "is_board", "is_active"] + widgets = { + "is_presidency": forms.HiddenInput(), + "is_board": forms.HiddenInput(), + "is_active": forms.CheckboxInput(attrs={"class": "switch"}), + } + + def __init__(self, *args, label_suffix="", **kwargs): + super().__init__(*args, label_suffix=label_suffix, **kwargs) + + def clean(self): + cleaned_data = super().clean() + if "ORDER" in cleaned_data: + self.instance.order = cleaned_data["ORDER"] + return cleaned_data + + +class ClubRoleBaseFormSet(forms.BaseInlineFormSet): + ordering_widget = forms.HiddenInput() + + +ClubRoleFormSet = forms.inlineformset_factory( + Club, + ClubRole, + ClubRoleForm, + ClubRoleBaseFormSet, + can_delete=False, + can_order=True, + edit_only=True, + extra=0, +) diff --git a/club/static/bundled/club/role-list-index.ts b/club/static/bundled/club/role-list-index.ts new file mode 100644 index 00000000..a750501a --- /dev/null +++ b/club/static/bundled/club/role-list-index.ts @@ -0,0 +1,37 @@ +import type { AlpineComponent } from "alpinejs"; + +interface RoleGroupData { + isBoard: boolean; + isPresidency: boolean; +} + +document.addEventListener("alpine:init", () => { + Alpine.data("clubRoleList", () => ({ + /** + * Edit relevant item data after it has been moved by x-sort + */ + reorder(item: AlpineComponent, conf: RoleGroupData) { + item.isBoard = conf.isBoard; + item.isPresidency = conf.isPresidency; + this.resetOrder(); + }, + /** + * Reset the value of the ORDER input of all items in the list. + * This is to be called after any reordering operation, in order to make sure + * that the order that will be saved is coherent with what is displayed. + */ + resetOrder() { + // When moving items with x-sort, the only information we truly have is + // the end position in the target group, not the previous position nor + // the position in the global list. + // To overcome this, we loop through an enumeration of all inputs + // that are in the form `roles-X-ORDER` and sequentially set the value of the field. + const inputs = document.querySelectorAll( + "input[name^='roles'][name$='ORDER']", + ); + for (const [i, elem] of inputs.entries()) { + elem.value = (i + 1).toString(); + } + }, + })); +}); diff --git a/club/static/club/roles.scss b/club/static/club/roles.scss new file mode 100644 index 00000000..ea10e72e --- /dev/null +++ b/club/static/club/roles.scss @@ -0,0 +1,7 @@ +.fa-grip-vertical { + display: flex; + flex-direction: column; + justify-content: center; + cursor: pointer; + margin-right: .5em; +} \ No newline at end of file diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja new file mode 100644 index 00000000..ab9310ba --- /dev/null +++ b/club/templates/club/club_roles.jinja @@ -0,0 +1,82 @@ +{% extends "core/base.jinja" %} + +{% block additional_js %} + +{% endblock %} + +{% block additional_css %} + +{% endblock %} + +{% macro display_subform(subform) %} +
+ {# hidden fields #} + {{ subform.ORDER }} + {{ subform.id }} + {{ subform.club }} + {{ subform.is_presidency|add_attr("x-model=isPresidency") }} + {{ subform.is_board|add_attr("x-model=isBoard") }} + +
+ + {{ subform.name.value() }} + {% if not subform.instance.is_active -%} + ({% trans %}inactive{% endtrans %}) + {%- endif %} + +
+ {{ subform.non_field_errors() }} +
+ {{ subform.name.as_field_group() }} +
+
+ {{ subform.description.as_field_group() }} +
+
+ {{ subform.is_active }} + {{ subform.is_active.label_tag() }} +
+
+
+
+{% endmacro %} + +{% block content %} +
+ {% csrf_token %} + {{ form.management_form }} + {{ form.non_form_errors() }} +

{% trans %}Presidency{% endtrans %}

+
+ {% for subform in form %} + {% if subform.is_presidency.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+

{% trans %}Board{% endtrans %}

+
+ {% for subform in form %} + {% if subform.is_board.value() and not subform.is_presidency.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+

{% trans %}Members{% endtrans %}

+
+ {% for subform in form %} + {% if not subform.is_board.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+

+
+{% endblock content %} diff --git a/club/urls.py b/club/urls.py index e33c2a26..7a9bc109 100644 --- a/club/urls.py +++ b/club/urls.py @@ -35,6 +35,7 @@ from club.views import ( ClubPageEditView, ClubPageHistView, ClubRevView, + ClubRoleUpdateView, ClubSellingCSVView, ClubSellingView, ClubToolsView, @@ -71,6 +72,7 @@ urlpatterns = [ ClubOldMembersView.as_view(), name="club_old_members", ), + path("/role/", ClubRoleUpdateView.as_view(), name="club_roles"), path("/sellings/", ClubSellingView.as_view(), name="club_sellings"), path( "/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" diff --git a/club/views.py b/club/views.py index 2df81a0e..7df78875 100644 --- a/club/views.py +++ b/club/views.py @@ -28,7 +28,11 @@ import csv import itertools from typing import TYPE_CHECKING, Any -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import ( + LoginRequiredMixin, + PermissionRequiredMixin, + UserPassesTestMixin, +) from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.paginator import InvalidPage, Paginator @@ -55,6 +59,7 @@ from club.forms import ( ClubAdminEditForm, ClubEditForm, ClubOldMemberForm, + ClubRoleFormSet, ClubSearchForm, JoinClubForm, MailingForm, @@ -414,6 +419,28 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView): } +class ClubRoleUpdateView( + ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView +): + form_class = ClubRoleFormSet + model = Club + template_name = "club/club_roles.jinja" + pk_url_kwarg = "club_id" + current_tab = "members" + success_message = _("Club roles updated") + + def test_func(self): + if self.request.user.has_perm("club.change_clubrole"): + return True + club: Club = self.get_object() + return club.members.filter( + user=self.request.user, role__is_presidency=True + ).exists() + + def get_success_url(self): + return self.request.path + + class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): """Sales of a club.""" diff --git a/core/static/core/accordion.scss b/core/static/core/accordion.scss index 4f40ba71..28a7f75b 100644 --- a/core/static/core/accordion.scss +++ b/core/static/core/accordion.scss @@ -53,7 +53,7 @@ details.accordion>.accordion-content { opacity: 0; @supports (max-height: calc-size(max-content, size)) { - max-height: 0px; + max-height: 0; } } @@ -71,11 +71,12 @@ details.accordion>.accordion-content { } } -// ::details-content isn't available on firefox yet +// ::details-content is available on firefox only since september 2025 +// (and wasn't available when this code was initially written) // we use .accordion-content as a workaround // But we need to use ::details-content for chrome because it's // not working correctly otherwise -// it only happen in chrome, not safari or firefox +// it only happens in chrome, not safari or firefox // Note: `selector` is not supported by scss so we comment it out to // avoid compiling it and sending it straight to the css // This is a trick that comes from here : From 31f36506a456053f7dfefac4db8e274a1066e1b7 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 15 Apr 2026 22:47:28 +0200 Subject: [PATCH 05/11] feat: page to create club roles --- club/forms.py | 28 +++++++++- club/templates/club/club_roles.jinja | 27 ++++++++- club/urls.py | 18 ++++++ club/views.py | 84 +++++++++++++++++++++++++++- core/templates/core/create.jinja | 11 +++- 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/club/forms.py b/club/forms.py index 284c5024..5aa3415d 100644 --- a/club/forms.py +++ b/club/forms.py @@ -345,9 +345,6 @@ class ClubRoleForm(forms.ModelForm): "is_active": forms.CheckboxInput(attrs={"class": "switch"}), } - def __init__(self, *args, label_suffix="", **kwargs): - super().__init__(*args, label_suffix=label_suffix, **kwargs) - def clean(self): cleaned_data = super().clean() if "ORDER" in cleaned_data: @@ -355,6 +352,31 @@ class ClubRoleForm(forms.ModelForm): return cleaned_data +class ClubRoleCreateForm(forms.ModelForm): + """Form to create a club role. + + Notes: + For UX purposes, users are not meant to fill `is_presidency` + and `is_board`, so those values are required by the form constructor + in order to initialize the instance properly. + """ + + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = ClubRole + fields = ["name", "description"] + + def __init__( + self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs + ): + super().__init__(*args, **kwargs) + self.instance.club = club + self.instance.is_presidency = is_presidency + self.instance.is_board = is_board + + class ClubRoleBaseFormSet(forms.BaseInlineFormSet): ordering_widget = forms.HiddenInput() diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja index ab9310ba..b008419a 100644 --- a/club/templates/club/club_roles.jinja +++ b/club/templates/club/club_roles.jinja @@ -54,7 +54,14 @@ {{ form.management_form }} {{ form.non_form_errors() }}

{% trans %}Presidency{% endtrans %}

-
+ + {% trans %}add role{% endtrans %} + +
{% for subform in form %} {% if subform.is_presidency.value() %} {{ display_subform(subform) }} @@ -62,7 +69,14 @@ {% endfor %}

{% trans %}Board{% endtrans %}

-
+ + {% trans %}add role{% endtrans %} + +
{% for subform in form %} {% if subform.is_board.value() and not subform.is_presidency.value() %} {{ display_subform(subform) }} @@ -70,7 +84,14 @@ {% endfor %}

{% trans %}Members{% endtrans %}

-
+ + {% trans %}add role{% endtrans %} + +
{% for subform in form %} {% if not subform.is_board.value() %} {{ display_subform(subform) }} diff --git a/club/urls.py b/club/urls.py index 7a9bc109..6a08bbe5 100644 --- a/club/urls.py +++ b/club/urls.py @@ -35,6 +35,9 @@ from club.views import ( ClubPageEditView, ClubPageHistView, ClubRevView, + ClubRoleBoardCreateView, + ClubRoleMemberCreateView, + ClubRolePresidencyCreateView, ClubRoleUpdateView, ClubSellingCSVView, ClubSellingView, @@ -73,6 +76,21 @@ urlpatterns = [ name="club_old_members", ), path("/role/", ClubRoleUpdateView.as_view(), name="club_roles"), + path( + "/role/new/president/", + ClubRolePresidencyCreateView.as_view(), + name="new_role_president", + ), + path( + "/role/new/board/", + ClubRoleBoardCreateView.as_view(), + name="new_role_board", + ), + path( + "/role/new/member/", + ClubRoleMemberCreateView.as_view(), + name="new_role_member", + ), path("/sellings/", ClubSellingView.as_view(), name="club_sellings"), path( "/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" diff --git a/club/views.py b/club/views.py index 7df78875..97536909 100644 --- a/club/views.py +++ b/club/views.py @@ -59,13 +59,14 @@ from club.forms import ( ClubAdminEditForm, ClubEditForm, ClubOldMemberForm, + ClubRoleCreateForm, ClubRoleFormSet, ClubSearchForm, JoinClubForm, MailingForm, SellingsForm, ) -from club.models import Club, Mailing, MailingSubscription, Membership +from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership from com.models import Poster from com.views import ( PosterCreateBaseView, @@ -437,10 +438,91 @@ class ClubRoleUpdateView( user=self.request.user, role__is_presidency=True ).exists() + def get_form_kwargs(self): + return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}} + def get_success_url(self): return self.request.path +class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView): + """View to create a new Club Role, using [][club.forms.ClubRoleCreateForm]. + + This view isn't meant to be called directly, but rather subclassed for each + type of role that can exist : + + - `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]` + to create a presidency role + - `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]` + to create a board role + - `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]` + to create a member role + + Each subclass have to override the following variables : + + - `is_presidency` and `is_board`, indicating what type of role + the view creates. + - `role_description`, which is the title of the page, indication + the user what kind of role is being created. + + This way, we are making sure the correct type of role will + be created, without bothering the user with the implementation details. + """ + + form_class = ClubRoleCreateForm + model = ClubRole + template_name = "core/create.jinja" + success_message = _("Role %(name)s created") + role_description = "" + is_presidency: bool + is_board: bool + + @cached_property + def club(self): + return get_object_or_404(Club, id=self.kwargs["club_id"]) + + def test_func(self): + return self.request.user.is_authenticated and ( + self.request.user.has_perm("club.add_clubrole") + or self.club.members.filter( + user=self.request.user, role__is_presidency=True + ).exists() + ) + + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "club": self.club, + "is_presidency": self.is_presidency, + "is_board": self.is_board, + } + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": self.role_description + } + + def get_success_url(self): + return reverse("club:club_roles", kwargs={"club_id": self.club.id}) + + +class ClubRolePresidencyCreateView(ClubRoleBaseCreateView): + is_presidency = True + is_board = True + role_description = _("club role \u2013 presidency") + + +class ClubRoleBoardCreateView(ClubRoleBaseCreateView): + is_presidency = False + is_board = True + role_description = _("club role \u2013 board") + + +class ClubRoleMemberCreateView(ClubRoleBaseCreateView): + is_presidency = False + is_board = False + role_description = _("club role \u2013 member") + + class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): """Sales of a club.""" diff --git a/core/templates/core/create.jinja b/core/templates/core/create.jinja index 332bb94b..724d2bc3 100644 --- a/core/templates/core/create.jinja +++ b/core/templates/core/create.jinja @@ -1,11 +1,18 @@ {% extends "core/base.jinja" %} +{# if the template context has the `object_name` variable, + then this one will be used in the page title, + instead of the result of `str(object)` #} +{% if not object_name %} + {% set object_name=form.instance.__class__._meta.verbose_name %} +{% endif %} + {% block title %} - {% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %} + {% trans name=object_name %}Create {{ name }}{% endtrans %} {% endblock %} {% block content %} -

{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}

+

{% trans name=object_name %}Create {{ name }}{% endtrans %}

{% csrf_token %} {{ form.as_p() }} From c7160727ce972b9756a64e108ea21c08407840cb Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 15 Apr 2026 23:31:40 +0200 Subject: [PATCH 06/11] add help texts in the club roles edition page --- club/templates/club/club_roles.jinja | 70 ++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja index b008419a..4c1d2111 100644 --- a/club/templates/club/club_roles.jinja +++ b/club/templates/club/club_roles.jinja @@ -40,8 +40,13 @@ {{ subform.description.as_field_group() }}
- {{ subform.is_active }} - {{ subform.is_active.label_tag() }} +
+ {{ subform.is_active }} + {{ subform.is_active.label_tag() }} +
+ + {{ subform.is_active.help_text }} +
@@ -49,14 +54,42 @@ {% endmacro %} {% block content %} +

+ {% trans trimmed %} + Roles give rights on the club. + Higher roles grant more rights, and the members having them are displayed higher + in the club members list. + {% endtrans %} +

+

+ {% trans trimmed %} + On this page, you can edit their name and description, as well as their order. + You can also drag roles from a category to another + (e.g. a board role can be made into a presidency role). + {% endtrans %} +

{% csrf_token %} {{ form.management_form }} {{ form.non_form_errors() }}

{% trans %}Presidency{% endtrans %}

- + {% trans %}add role{% endtrans %} +
+ {% trans %}Help{% endtrans %} + {# The style we use for markdown rendering is quite nice for what we want to display, + so we are just gonna reuse it. #} +
+

{% trans %}Users with a presidency role can :{% endtrans %}

+
    +
  • {% trans %}create new club roles and edit existing ones{% endtrans %}
  • +
  • {% trans %}manage the club counters{% endtrans %}
  • +
  • {% trans %}add new members with any active role and end any membership{% endtrans %}
  • +
+

{% trans %}They also have all the rights of the club board.{% endtrans %}

+
+

{% trans %}Board{% endtrans %}

- + {% trans %}add role{% endtrans %} +
+ {% trans %}Help{% endtrans %} +
+

+ {% trans trimmed %} + Board members can do most administrative actions in the club, including : + {% endtrans %} +

+
    +
  • {% trans %}manage the club posters{% endtrans %}
  • +
  • {% trans %}create news for the club{% endtrans %}
  • +
  • {% trans %}click users on the club's counters{% endtrans %}
  • +
  • + {% trans trimmed %} + add new members and end active memberships + for roles that are lower than their own. + {% endtrans %} +
  • +
+
+

{% trans %}Members{% endtrans %}

- + {% trans %}add role{% endtrans %} +
+ {% trans %}Help{% endtrans %} +
+

{% trans %}Simple members cannot perform administrative actions.{% endtrans %}

+
+
Date: Thu, 16 Apr 2026 00:05:58 +0200 Subject: [PATCH 07/11] add links to club edit page --- club/models.py | 7 +++++++ club/templates/club/club_members.jinja | 9 +++++++++ club/templates/club/club_tools.jinja | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/club/models.py b/club/models.py index f8698d6b..408090b2 100644 --- a/club/models.py +++ b/club/models.py @@ -238,6 +238,13 @@ class Club(models.Model): """Method to see if that object can be edited by the given user.""" return self.has_rights_in_club(user) + def can_roles_be_edited_by(self, user: User) -> bool: + """Return True if the given user can edit the roles of this club""" + return ( + user.has_perm("club.change_clubrole") + or self.members.ongoing().filter(user=user, role__is_presidency=True).exists() + ) + @cached_property def current_members(self) -> list[Membership]: return list( diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja index 3e134442..684326e4 100644 --- a/club/templates/club/club_members.jinja +++ b/club/templates/club/club_members.jinja @@ -12,6 +12,15 @@

{% trans %}Club members{% endtrans %}

+ {% if club.can_roles_be_edited_by(user) %} + + {% trans %}Manage roles{% endtrans %} + + {% endif %} + {% if add_member_fragment %}
{{ add_member_fragment }} diff --git a/club/templates/club/club_tools.jinja b/club/templates/club/club_tools.jinja index 651cc9bb..a69d5c26 100644 --- a/club/templates/club/club_tools.jinja +++ b/club/templates/club/club_tools.jinja @@ -5,8 +5,19 @@

{% trans %}Communication:{% endtrans %}

    -
  • {% trans %}Create a news{% endtrans %}
  • -
  • {% trans %}Post in the Weekmail{% endtrans %}
  • +
  • + + {% trans %}Create a news{% endtrans %} + +
  • +
  • + + {% trans %}Post in the Weekmail{% endtrans %} + +
  • + {% if object.can_roles_be_edited_by(user) %} +
  • + {% endif %} {% if object.trombi %}
  • {% trans %}Edit Trombi{% endtrans %}
  • {% else %} From 66f826625e6d3a674dc795ffc37e1c42ddedaa3a Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 17 Apr 2026 22:49:45 +0200 Subject: [PATCH 08/11] ask for user confirmation if its role was moved out of presidency --- club/models.py | 2 +- club/static/bundled/club/role-list-index.ts | 26 ++- club/templates/club/club_roles.jinja | 10 +- club/tests/test_clubrole.py | 203 +++++++++++++++++++- club/views.py | 29 ++- 5 files changed, 256 insertions(+), 14 deletions(-) diff --git a/club/models.py b/club/models.py index 408090b2..2c5b5ff0 100644 --- a/club/models.py +++ b/club/models.py @@ -240,7 +240,7 @@ class Club(models.Model): def can_roles_be_edited_by(self, user: User) -> bool: """Return True if the given user can edit the roles of this club""" - return ( + return user.is_authenticated and ( user.has_perm("club.change_clubrole") or self.members.ongoing().filter(user=user, role__is_presidency=True).exists() ) diff --git a/club/static/bundled/club/role-list-index.ts b/club/static/bundled/club/role-list-index.ts index a750501a..2020bba4 100644 --- a/club/static/bundled/club/role-list-index.ts +++ b/club/static/bundled/club/role-list-index.ts @@ -3,16 +3,22 @@ import type { AlpineComponent } from "alpinejs"; interface RoleGroupData { isBoard: boolean; isPresidency: boolean; + roleId: number; } document.addEventListener("alpine:init", () => { - Alpine.data("clubRoleList", () => ({ + Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({ + confirmOnSubmit: false, + /** * Edit relevant item data after it has been moved by x-sort */ reorder(item: AlpineComponent, conf: RoleGroupData) { item.isBoard = conf.isBoard; item.isPresidency = conf.isPresidency; + // if the user has moved its own role outside the presidency, + // submitting the form will require a confirmation + this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency; this.resetOrder(); }, /** @@ -33,5 +39,23 @@ document.addEventListener("alpine:init", () => { elem.value = (i + 1).toString(); } }, + + /** + * If the user moved its role out of the presidency, ask a confirmation + * before submitting the form + */ + confirmSubmission(event: SubmitEvent) { + if ( + this.confirmOnSubmit && + !confirm( + gettext( + "You're going to remove your own role from the presidency. " + + "You may lock yourself out of this page. Do you want to continue ? ", + ), + ) + ) { + event.preventDefault(); + } + }, })); }); diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja index 4c1d2111..eaa693f0 100644 --- a/club/templates/club/club_roles.jinja +++ b/club/templates/club/club_roles.jinja @@ -14,6 +14,7 @@ x-data="{ isPresidency: {{ subform.is_presidency.value()|lower }}, isBoard: {{ subform.is_board.value()|lower }}, + roleId: {{ subform.id.value() }}, }" x-sort:item="$data" > @@ -68,7 +69,11 @@ (e.g. a board role can be made into a presidency role). {% endtrans %}

    - + {% csrf_token %} {{ form.management_form }} {{ form.non_form_errors() }} @@ -93,7 +98,6 @@
    {% for subform in form %} {% if subform.is_presidency.value() %} @@ -129,7 +133,6 @@
    {% for subform in form %} {% if subform.is_board.value() and not subform.is_presidency.value() %} @@ -150,7 +153,6 @@
    {% for subform in form %} {% if not subform.is_board.value() %} diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index 6956ac8c..e090f7e6 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -2,7 +2,10 @@ import pytest from model_bakery import baker, seq from model_bakery.recipe import Recipe -from club.models import Club, ClubRole +from club.forms import ClubRoleFormSet +from club.models import Club, ClubRole, Membership +from core.baker_recipes import subscriber_user +from core.models import AnonymousUser, User @pytest.mark.django_db @@ -31,3 +34,201 @@ def test_order_auto(): roles[2], role_c, ] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "is_allowed"), + [ + ( + lambda club: baker.make( + User, + user_permissions=[Permission.objects.get(codename="change_clubrole")], + ), + True, + ), + ( # user with presidency roles can edit the club roles + lambda club: subscriber_user.make( + memberships=[ + baker.make( + Membership, + club=club, + role=club.roles.filter(is_presidency=True).first(), + ) + ] + ), + True, + ), + ( # user in the board but not in the presidency cannot edit roles + lambda club: subscriber_user.make( + memberships=[ + baker.make( + Membership, + club=club, + role=club.roles.filter( + is_presidency=False, is_board=True + ).first(), + ) + ] + ), + False, + ), + (lambda _: AnonymousUser(), False), + ], +) +def test_can_roles_be_edited_by( + club: Club, user_factory: Callable[[Club], User], is_allowed +): + """Test that `Club.can_roles_be_edited_by` return the right value""" + user = user_factory(club) + assert club.can_roles_be_edited_by(user) == is_allowed + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ["route", "is_presidency", "is_board"], + [ + ("club:new_role_president", True, True), + ("club:new_role_board", False, True), + ("club:new_role_member", False, False), + ], +) +def test_create_role_view(client: Client, route: str, is_presidency, is_board): + """Test that the role creation views work.""" + club = baker.make(Club) + role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True) + user = subscriber_user.make() + baker.make(Membership, club=club, role=role, user=user, end_date=None) + url = reverse(route, kwargs={"club_id": club.id}) + client.force_login(user) + + res = client.get(url) + assert res.status_code == 200 + + res = client.post(url, data={"name": "foo"}) + assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id})) + new_role = club.roles.last() + assert new_role.name == "foo" + assert new_role.is_presidency == is_presidency + assert new_role.is_board == is_board + + +class TestClubRoleUpdate(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = make_club() + cls.roles = list(cls.club.roles.all()) + cls.user = subscriber_user.make() + baker.make( + Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None + ) + cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id}) + + def setUp(self): + self.payload = { + "roles-TOTAL_FORMS": 3, + "roles-INITIAL_FORMS": 3, + "roles-MIN_NUM_FORMS": 0, + "roles-MAX_NUM_FORMS": 1000, + "roles-0-ORDER": self.roles[0].order, + "roles-0-id": self.roles[0].id, + "roles-0-club": self.club.id, + "roles-0-is_presidency": True, + "roles-0-is_board": True, + "roles-0-name": self.roles[0].name, + "roles-0-description": self.roles[0].description, + "roles-0-is_active": True, + "roles-1-ORDER": self.roles[1].order, + "roles-1-id": self.roles[1].id, + "roles-1-club": self.club.id, + "roles-1-is_presidency": False, + "roles-1-is_board": True, + "roles-1-name": self.roles[1].name, + "roles-1-description": self.roles[1].description, + "roles-1-is_active": True, + "roles-2-ORDER": self.roles[2].order, + "roles-2-id": self.roles[2].id, + "roles-2-club": self.club.id, + "roles-2-is_presidency": False, + "roles-2-is_board": False, + "roles-2-name": self.roles[2].name, + "roles-2-description": self.roles[2].description, + "roles-2-is_active": True, + } + + def test_view_ok(self): + """Basic test to check that the view works.""" + self.client.force_login(self.user) + res = self.client.get(self.url) + assert res.status_code == 200 + self.payload["roles-2-name"] = "foo" + res = self.client.post(self.url, data=self.payload) + assertRedirects(res, self.url) + self.roles[2].refresh_from_db() + assert self.roles[2].name == "foo" + + def test_incoherent_order(self): + """Test that placing a member role over a board role fails.""" + self.payload["roles-0-ORDER"] = 4 + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert not formset.is_valid() + assert formset.errors == [ + { + "__all__": [ + f"Le rôle {self.roles[0].name} ne peut pas " + "être placé en-dessous d'un rôle de membre.", + f"Le rôle {self.roles[0].name} ne peut pas être placé " + "en-dessous d'un rôle qui n'est pas de la présidence.", + ] + }, + {}, + {}, + ] + + def test_change_order_ok(self): + """Test that changing order the intended way works""" + self.payload["roles-1-ORDER"] = 3 + self.payload["roles-1-is_board"] = False + self.payload["roles-2-ORDER"] = 2 + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + assert list(self.club.roles.order_by("order")) == [ + self.roles[0], + self.roles[2], + self.roles[1], + ] + self.roles[1].refresh_from_db() + assert not self.roles[1].is_board + + def test_non_board_presidency_is_forbidden(self): + """Test that a role cannot be in the presidency without being in the board.""" + self.payload["roles-0-is_board"] = False + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert not formset.is_valid() + assert formset.errors == [ + { + "__all__": [ + "Un rôle ne peut pas appartenir à la présidence sans être dans le bureau", + ] + }, + {}, + {}, + ] + + def test_president_moves_itself_out_of_the_presidency(self): + """Test that if the user moves its own role out of the presidency, + then it's redirected to another page and loses access to the update page.""" + self.payload["roles-0-is_presidency"] = False + self.client.force_login(self.user) + res = self.client.post(self.url, data=self.payload) + assertRedirects( + res, reverse("club:club_members", kwargs={"club_id": self.club.id}) + ) + # When the user clicked that button, it still had the right to update roles, + # so the modification should be applied + self.roles[0].refresh_from_db() + assert self.roles[0].is_presidency is False + + res = self.client.get(self.url) + assert res.status_code == 403 diff --git a/club/views.py b/club/views.py index 97536909..1392f0e3 100644 --- a/club/views.py +++ b/club/views.py @@ -430,19 +430,34 @@ class ClubRoleUpdateView( current_tab = "members" success_message = _("Club roles updated") + @cached_property + def club(self) -> Club: + return self.get_object() + def test_func(self): - if self.request.user.has_perm("club.change_clubrole"): - return True - club: Club = self.get_object() - return club.members.filter( - user=self.request.user, role__is_presidency=True - ).exists() + return self.club.can_roles_be_edited_by(self.request.user) def get_form_kwargs(self): return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}} def get_success_url(self): - return self.request.path + # if the user lost the right to view the role update page + # (because it moved its own role out of the presidency), + # redirect to the club member page, else stay on the same page. + if self.club.can_roles_be_edited_by(self.request.user): + return self.request.path + return reverse("club:club_members", kwargs={"club_id": self.club.id}) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "user_role": ClubRole.objects.filter( + club=self.object, + members__user=self.request.user, + members__end_date=None, + ) + .values_list("id", flat=True) + .first() + } class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView): From b6522971b29744f18cc50005c60d9f52f5d76e89 Mon Sep 17 00:00:00 2001 From: imperosol Date: Thu, 16 Apr 2026 18:08:06 +0200 Subject: [PATCH 09/11] add translations --- locale/fr/LC_MESSAGES/django.po | 188 +++++++++++++++++++++++------- locale/fr/LC_MESSAGES/djangojs.po | 13 ++- 2 files changed, 158 insertions(+), 43 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 75cfcea1..5b56449a 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: 2026-05-12 09:48+0200\n" +"POT-Creation-Date: 2026-05-12 09:52+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -270,26 +270,22 @@ msgid "club roles" msgstr "rôles de club" #: club/models.py -#, python-format -msgid "" -"Role %(name)s was declared as a presidency role without being a board role" +msgid "A role cannot be in the presidency while not being in the board" msgstr "" -"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du " -"bureau." +"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau" #: club/models.py #, python-format msgid "Role %(role)s cannot be placed below a member role" msgstr "" -"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de " -"membre." +"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre." #: club/models.py #, python-format msgid "Role %(role)s cannot be placed below a non-presidency role" msgstr "" -"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de " -"membre." +"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle qui n'est pas " +"de la présidence." #: club/models.py core/models.py counter/models.py eboutic/models.py #: election/models.py pedagogy/models.py sas/models.py trombi/models.py @@ -387,7 +383,7 @@ msgstr "Recherche" msgid "New club" msgstr "Nouveau club" -#: club/templates/club/club_list.jinja +#: club/templates/club/club_list.jinja club/templates/club/club_roles.jinja msgid "inactive" msgstr "inactif" @@ -395,6 +391,10 @@ msgstr "inactif" msgid "Club members" msgstr "Membres du club" +#: club/templates/club/club_members.jinja +msgid "Manage roles" +msgstr "Gérer les rôles" + #: club/templates/club/club_members.jinja #: club/templates/club/club_old_members.jinja #: core/templates/core/user_clubs.jinja @@ -433,6 +433,121 @@ msgstr "Du" msgid "To" msgstr "Au" +#: club/templates/club/club_roles.jinja +msgid "" +"Roles give rights on the club. Higher roles grant more rights, and the " +"members having them are displayed higher in the club members list." +msgstr "" +"Les rôles donnent des droits sur le club. Les rôles plus élevés donnent plus " +"de droit, et les membres qui les possèdent sont affichés plus haut dans la " +"liste des membres." + +#: club/templates/club/club_roles.jinja +msgid "" +"On this page, you can edit their name and description, as well as their " +"order. You can also drag roles from a category to another (e.g. a board role " +"can be made into a presidency role)." +msgstr "" +"Sur cette page, vous pouvez éditer leur nom et leur description, ainsi que " +"leur ordre. Vous pouvez également déplacer des rôles d'une catégorie à " +"l'autre (par exemple, un rôle du bureau peut devenir un rôle de présidence)." + +#: club/templates/club/club_roles.jinja +msgid "Presidency" +msgstr "Présidence" + +#: club/templates/club/club_roles.jinja +msgid "add role" +msgstr "ajouter un rôle" + +#: club/templates/club/club_roles.jinja core/templates/core/base/navbar.jinja +msgid "Help" +msgstr "Aide" + +#: club/templates/club/club_roles.jinja +msgid "Users with a presidency role can :" +msgstr "Les utilisateurs avec un rôle de présidence peuvent :" + +#: club/templates/club/club_roles.jinja +msgid "create new club roles and edit existing ones" +msgstr "créer de nouveaux rôles et modifier ceux qui existent" + +#: club/templates/club/club_roles.jinja +msgid "manage the club counters" +msgstr "gérer les comptoirs du club" + +#: club/templates/club/club_roles.jinja +msgid "add new members with any active role and end any membership" +msgstr "" +"ajouter de nouveaux membres avec n'importe quel rôle et mettre fin à " +"n'importe quelle adhésion au club." + +#: club/templates/club/club_roles.jinja +msgid "They also have all the rights of the club board." +msgstr "Ils possèdent également tous les droits du bureau." + +#: club/templates/club/club_roles.jinja +msgid "Board" +msgstr "Bureau" + +#: club/templates/club/club_roles.jinja +msgid "" +"Board members can do most administrative actions in the club, including :" +msgstr "" +"Les membres du bureau peuvent effectuer la plupart des actions " +"administratives dans le club, incluant :" + +#: club/templates/club/club_roles.jinja +msgid "manage the club posters" +msgstr "gérer les affiches du club" + +#: club/templates/club/club_roles.jinja +msgid "create news for the club" +msgstr "créer des nouvelles pour le club" + +#: club/templates/club/club_roles.jinja +msgid "click users on the club's counters" +msgstr "cliquer des utilisateurs sur les comptoirs du club" + +#: club/templates/club/club_roles.jinja +msgid "" +"add new members and end active memberships for roles that are lower than " +"their own." +msgstr "" +"ajouter de nouveaux membres et mettre fin à des adhésions en cours, pour des " +"rôles plus bas que le leur." + +#: club/templates/club/club_roles.jinja club/views.py +msgid "Members" +msgstr "Membres" + +#: club/templates/club/club_roles.jinja +msgid "Simple members cannot perform administrative actions." +msgstr "" +"Les simples membres ne peuvent pas effectuer d'actions administratives." + +#: club/templates/club/club_roles.jinja club/templates/club/edit_club.jinja +#: club/templates/club/pagerev_edit.jinja com/templates/com/news_edit.jinja +#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja +#: com/templates/com/weekmail.jinja core/templates/core/create.jinja +#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja +#: core/templates/core/fragment/user_visibility.jinja +#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja +#: core/templates/core/user_godfathers.jinja +#: core/templates/core/user_godfathers_tree.jinja +#: core/templates/core/user_preferences.jinja +#: counter/templates/counter/cash_register_summary.jinja +#: counter/templates/counter/invoices_call.jinja +#: counter/templates/counter/product_form.jinja +#: forum/templates/forum/reply.jinja +#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja +#: subscription/templates/subscription/fragments/creation_form_new_user.jinja +#: trombi/templates/trombi/comment.jinja +#: trombi/templates/trombi/edit_profile.jinja +#: trombi/templates/trombi/user_tools.jinja +msgid "Save" +msgstr "Sauver" + #: club/templates/club/club_sellings.jinja msgid "Previous" msgstr "Précédent" @@ -617,28 +732,6 @@ msgstr "" "Les champs de formulaire suivants sont liées à la description basique d'un " "club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci." -#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja -#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja -#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja -#: core/templates/core/create.jinja core/templates/core/edit.jinja -#: core/templates/core/file_edit.jinja -#: core/templates/core/fragment/user_visibility.jinja -#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja -#: core/templates/core/user_godfathers.jinja -#: core/templates/core/user_godfathers_tree.jinja -#: core/templates/core/user_preferences.jinja -#: counter/templates/counter/cash_register_summary.jinja -#: counter/templates/counter/invoices_call.jinja -#: counter/templates/counter/product_form.jinja -#: forum/templates/forum/reply.jinja -#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja -#: subscription/templates/subscription/fragments/creation_form_new_user.jinja -#: trombi/templates/trombi/comment.jinja -#: trombi/templates/trombi/edit_profile.jinja -#: trombi/templates/trombi/user_tools.jinja -msgid "Save" -msgstr "Sauver" - #: club/templates/club/fragments/add_member.jinja msgid "Add a new member" msgstr "Ajouter un nouveau membre" @@ -719,10 +812,6 @@ msgstr "Éditer la page" msgid "Infos" msgstr "Infos" -#: club/views.py -msgid "Members" -msgstr "Membres" - #: club/views.py msgid "Old members" msgstr "Anciens membres" @@ -772,6 +861,27 @@ msgstr "Vous êtes maintenant membre de ce club." msgid "%(user)s has been added to club." msgstr "%(user)s a été ajouté au club." +#: club/views.py +msgid "Club roles updated" +msgstr "Rôles de club mis à jour" + +#: club/views.py +#, python-format +msgid "Role %(name)s created" +msgstr "Rôle %(name)s créé" + +#: club/views.py +msgid "club role – presidency" +msgstr "rôle de club – présidence" + +#: club/views.py +msgid "club role – board" +msgstr "rôle de club – bureau" + +#: club/views.py +msgid "club role – member" +msgstr "rôle de club – membre" + #: club/views.py msgid "Benefit" msgstr "Bénéfice" @@ -1999,10 +2109,6 @@ msgstr "Partenaires" msgid "Subscriber benefits" msgstr "Les avantages cotisants" -#: core/templates/core/base/navbar.jinja -msgid "Help" -msgstr "Aide" - #: core/templates/core/base/navbar.jinja msgid "FAQ" msgstr "FAQ" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 9b598aee..78e15202 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-26 15:45+0100\n" +"POT-Creation-Date: 2026-04-17 22:42+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -17,6 +17,14 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: club/static/bundled/club/role-list-index.ts +msgid "" +"You're going to remove your own role from the presidency. You may lock " +"yourself out of this page. Do you want to continue ? " +msgstr "" +"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez " +"de perdre l'accès à cette page. Voulez-vous continuer ?" + #: com/static/bundled/com/components/ics-calendar-index.ts msgid "More info" msgstr "Plus d'informations" @@ -271,4 +279,5 @@ msgstr "Il n'a pas été possible de supprimer l'image" msgid "" "Wrong timetable format. Make sure you copied if from your student folder." msgstr "" -"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants." +"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis " +"votre dossier étudiant." From 307f1fb1fe417640cca202f2d1d5acd4ddbe0912 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 17 Apr 2026 18:13:13 +0200 Subject: [PATCH 10/11] add tests --- .../0015_clubrole_alter_membership_role.py | 3 ++ club/models.py | 20 ++++-------- club/tests/test_club_controller.py | 2 +- club/tests/test_clubrole.py | 32 +++++++++++++++---- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/club/migrations/0015_clubrole_alter_membership_role.py b/club/migrations/0015_clubrole_alter_membership_role.py index 6a1b58ee..9c3e5a8e 100644 --- a/club/migrations/0015_clubrole_alter_membership_role.py +++ b/club/migrations/0015_clubrole_alter_membership_role.py @@ -148,6 +148,9 @@ class Migration(migrations.Migration): ("is_presidency", False), ("is_board", True), _connector="OR" ), name="clubrole_presidency_implies_board", + violation_error_message=( + "A role cannot be in the presidency while not being in the board" + ), ), ), migrations.RunPython(migrate_roles, migrations.RunPython.noop), diff --git a/club/models.py b/club/models.py index 2c5b5ff0..aedc6993 100644 --- a/club/models.py +++ b/club/models.py @@ -242,7 +242,9 @@ class Club(models.Model): """Return True if the given user can edit the roles of this club""" return user.is_authenticated and ( user.has_perm("club.change_clubrole") - or self.members.ongoing().filter(user=user, role__is_presidency=True).exists() + or self.members.ongoing() + .filter(user=user, role__is_presidency=True) + .exists() ) @cached_property @@ -292,6 +294,9 @@ class ClubRole(OrderedModel): models.CheckConstraint( condition=Q(is_presidency=False) | Q(is_board=True), name="clubrole_presidency_implies_board", + violation_error_message=_( + "A role cannot be in the presidency while not being in the board" + ), ) ] @@ -301,21 +306,8 @@ class ClubRole(OrderedModel): def get_display_name(self): return f"{self.name} - {self.club.name}" - def get_absolute_url(self): - return reverse("club:club_roles", kwargs={"club_id": self.club_id}) - def clean(self): errors = [] - if self.is_presidency and not self.is_board: - errors.append( - ValidationError( - _( - "Role %(name)s was declared as a presidency role " - "without being a board role" - ) - % {"name": self.name} - ) - ) roles = list(self.club.roles.all()) if ( self.is_board diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py index e084109c..b6248e01 100644 --- a/club/tests/test_club_controller.py +++ b/club/tests/test_club_controller.py @@ -9,7 +9,7 @@ from model_bakery import baker from model_bakery.recipe import Recipe from pytest_django.asserts import assertNumQueries -from club.models import Club, Membership, ClubRole +from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import Group, Page, User diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index e090f7e6..4628c292 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -1,6 +1,12 @@ +from collections.abc import Callable + import pytest +from django.contrib.auth.models import Permission +from django.test import Client, TestCase +from django.urls import reverse from model_bakery import baker, seq from model_bakery.recipe import Recipe +from pytest_django.asserts import assertRedirects from club.forms import ClubRoleFormSet from club.models import Club, ClubRole, Membership @@ -8,21 +14,35 @@ from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User -@pytest.mark.django_db -def test_order_auto(): - """Test that newly created roles are put in the right place.""" +def make_club(): + # unittest-style tests cannot use fixture, so we create a function + # that will be callable either by a pytest fixture or inside + # a TestCase.setUpTestData method. club = baker.make(Club) recipe = Recipe(ClubRole, club=club, name=seq("role ")) - # bulk create initial roles (1 presidency, 1 board, 1 member) - roles = recipe.make( + recipe.make( is_board=iter([True, True, False]), is_presidency=iter([True, False, False]), order=iter([1, 2, 3]), _quantity=3, _bulk_create=True, ) - # then create the remaining roles one by one (like they will be in prod) + return club + + +@pytest.fixture +def club(db): + """A club with a presidency role, a board role and a member role""" + return make_club() + + +@pytest.mark.django_db +def test_order_auto(club): + """Test that newly created roles are put in the right place.""" + roles = list(club.roles.all()) + # create new roles one by one (like they will be in prod) # each new role should be placed at the end of its category + recipe = Recipe(ClubRole, club=club, name=seq("new role ")) role_a = recipe.make(is_board=True, is_presidency=True, order=None) role_b = recipe.make(is_board=True, is_presidency=False, order=None) role_c = recipe.make(is_board=False, is_presidency=False, order=None) From ffa4f82c7de088a6065126680dc00b2e723afad1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 21 Apr 2026 14:12:27 +0200 Subject: [PATCH 11/11] UI/UX tweaks --- club/forms.py | 2 +- club/templates/club/club_members.jinja | 2 +- club/templates/club/club_roles.jinja | 17 ++++++++++++----- club/tests/test_clubrole.py | 15 +++++++-------- club/views.py | 7 +------ 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/club/forms.py b/club/forms.py index 5aa3415d..a6268822 100644 --- a/club/forms.py +++ b/club/forms.py @@ -348,7 +348,7 @@ class ClubRoleForm(forms.ModelForm): def clean(self): cleaned_data = super().clean() if "ORDER" in cleaned_data: - self.instance.order = cleaned_data["ORDER"] + self.instance.order = cleaned_data["ORDER"] - 1 return cleaned_data diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja index 684326e4..fcd4a6d8 100644 --- a/club/templates/club/club_members.jinja +++ b/club/templates/club/club_members.jinja @@ -17,7 +17,7 @@ href="{{ url("club:club_roles", club_id=object.id) }}" class="btn btn-blue margin-bottom" > - {% trans %}Manage roles{% endtrans %} + {% trans %}Manage roles{% endtrans %} {% endif %} diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja index eaa693f0..ddc54b0c 100644 --- a/club/templates/club/club_roles.jinja +++ b/club/templates/club/club_roles.jinja @@ -1,7 +1,7 @@ {% extends "core/base.jinja" %} {% block additional_js %} - + {% endblock %} {% block additional_css %} @@ -78,7 +78,7 @@ {{ form.management_form }} {{ form.non_form_errors() }}

    {% trans %}Presidency{% endtrans %}

    - + {% trans %}add role{% endtrans %}
    @@ -105,8 +105,9 @@ {% endif %} {% endfor %}
    +

    {% trans %}Board{% endtrans %}

    - + {% trans %}add role{% endtrans %}
    @@ -140,8 +141,9 @@ {% endif %} {% endfor %}
    +

    {% trans %}Members{% endtrans %}

    - + {% trans %}add role{% endtrans %}
    @@ -160,6 +162,11 @@ {% endif %} {% endfor %}
    -

    +
    +

    + +

    {% endblock content %} diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index 4628c292..0173c4d6 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -23,7 +23,7 @@ def make_club(): recipe.make( is_board=iter([True, True, False]), is_presidency=iter([True, False, False]), - order=iter([1, 2, 3]), + order=iter([0, 1, 2]), _quantity=3, _bulk_create=True, ) @@ -143,6 +143,7 @@ class TestClubRoleUpdate(TestCase): Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None ) cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id}) + cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) def setUp(self): self.payload = { @@ -150,7 +151,7 @@ class TestClubRoleUpdate(TestCase): "roles-INITIAL_FORMS": 3, "roles-MIN_NUM_FORMS": 0, "roles-MAX_NUM_FORMS": 1000, - "roles-0-ORDER": self.roles[0].order, + "roles-0-ORDER": 1, "roles-0-id": self.roles[0].id, "roles-0-club": self.club.id, "roles-0-is_presidency": True, @@ -158,7 +159,7 @@ class TestClubRoleUpdate(TestCase): "roles-0-name": self.roles[0].name, "roles-0-description": self.roles[0].description, "roles-0-is_active": True, - "roles-1-ORDER": self.roles[1].order, + "roles-1-ORDER": 2, "roles-1-id": self.roles[1].id, "roles-1-club": self.club.id, "roles-1-is_presidency": False, @@ -166,7 +167,7 @@ class TestClubRoleUpdate(TestCase): "roles-1-name": self.roles[1].name, "roles-1-description": self.roles[1].description, "roles-1-is_active": True, - "roles-2-ORDER": self.roles[2].order, + "roles-2-ORDER": 3, "roles-2-id": self.roles[2].id, "roles-2-club": self.club.id, "roles-2-is_presidency": False, @@ -183,7 +184,7 @@ class TestClubRoleUpdate(TestCase): assert res.status_code == 200 self.payload["roles-2-name"] = "foo" res = self.client.post(self.url, data=self.payload) - assertRedirects(res, self.url) + assertRedirects(res, self.redirect_url) self.roles[2].refresh_from_db() assert self.roles[2].name == "foo" @@ -242,9 +243,7 @@ class TestClubRoleUpdate(TestCase): self.payload["roles-0-is_presidency"] = False self.client.force_login(self.user) res = self.client.post(self.url, data=self.payload) - assertRedirects( - res, reverse("club:club_members", kwargs={"club_id": self.club.id}) - ) + assertRedirects(res, self.redirect_url) # When the user clicked that button, it still had the right to update roles, # so the modification should be applied self.roles[0].refresh_from_db() diff --git a/club/views.py b/club/views.py index 1392f0e3..3e6ba5c1 100644 --- a/club/views.py +++ b/club/views.py @@ -441,17 +441,12 @@ class ClubRoleUpdateView( return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}} def get_success_url(self): - # if the user lost the right to view the role update page - # (because it moved its own role out of the presidency), - # redirect to the club member page, else stay on the same page. - if self.club.can_roles_be_edited_by(self.request.user): - return self.request.path return reverse("club:club_members", kwargs={"club_id": self.club.id}) def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) | { "user_role": ClubRole.objects.filter( - club=self.object, + club=self.club, members__user=self.request.user, members__end_date=None, )