From c4cbcc35607e5bf638e6529d1aef6c2c4837919f Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 1 Sep 2026 17:49:24 +0200 Subject: [PATCH 1/3] Allow club role-group m2m links --- club/admin.py | 4 +- .../migrations/0018_clubrole_linked_groups.py | 26 +++++++++++ club/models.py | 45 ++++++++++++++----- club/tests/test_membership.py | 25 ++++++++++- 4 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 club/migrations/0018_clubrole_linked_groups.py diff --git a/club/admin.py b/club/admin.py index a9550d92..522ffe7d 100644 --- a/club/admin.py +++ b/club/admin.py @@ -50,8 +50,8 @@ class ClubAdmin(admin.ModelAdmin): @admin.register(ClubRole) class ClubRoleAdmin(admin.ModelAdmin): list_display = ("name", "club", "is_board", "is_presidency") - search_fields = ("name",) - autocomplete_fields = ("club",) + search_fields = ("name", "club__name") + autocomplete_fields = ("club", "linked_groups") list_select_related = ("club",) list_filter = ( "is_board", diff --git a/club/migrations/0018_clubrole_linked_groups.py b/club/migrations/0018_clubrole_linked_groups.py new file mode 100644 index 00000000..b22b1f86 --- /dev/null +++ b/club/migrations/0018_clubrole_linked_groups.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.17 on 2026-09-01 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0017_linktype_clublink"), + ("core", "0050_alter_sithfile_moderator"), + ] + + operations = [ + migrations.AddField( + model_name="clubrole", + name="linked_groups", + field=models.ManyToManyField( + help_text=( + "Groups that are automatically given or removed " + "to user receiving or losing this club role" + ), + related_name="club_roles", + to="core.group", + verbose_name="Linked groups", + ), + ), + ] diff --git a/club/models.py b/club/models.py index a226cbcd..6c2baec8 100644 --- a/club/models.py +++ b/club/models.py @@ -23,6 +23,8 @@ # from __future__ import annotations +import operator +from functools import reduce from typing import Iterable, Self from django.conf import settings @@ -282,6 +284,15 @@ class ClubRole(OrderedModel): "If the role is inactive, people joining the club won't be able to get it." ), ) + linked_groups = models.ManyToManyField( + Group, + verbose_name=_("Linked groups"), + help_text=_( + "Groups that are automatically given or removed " + "to user receiving or losing this club role" + ), + related_name="linked_roles", + ) order_with_respect_to = "club" @@ -534,7 +545,7 @@ class Membership(models.Model): def _remove_club_groups( memberships: Iterable[Membership], ) -> tuple[int, dict[str, int]]: - """Remove users of those memberships from the club groups. + """Remove users of those memberships from the club and club role groups. For example, if a user is in the Troll club board, he is in the board group and the members group of the Troll. @@ -553,15 +564,19 @@ class Membership(models.Model): clubs = {m.club_id for m in memberships} users = {m.user_id for m in memberships} groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs)) + role_groups = [ + Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships + ] return User.groups.through.objects.filter( - Q(group__in=groups) & Q(user__in=users) + (Q(group__in=groups) & Q(user__in=users)) + | reduce(operator.or_, role_groups) ).delete() @staticmethod def _add_club_groups( memberships: Iterable[Membership], ) -> list[User.groups.through]: - """Add users of those memberships to the club groups. + """Add users of those memberships to the club and club role groups. For example, if a user just joined the Troll club board, he will be added in both the members group and the board group @@ -582,34 +597,40 @@ class Membership(models.Model): memberships = [m for m in memberships if m.end_date is None] if not memberships: return [] - - if sum(1 for m in memberships if not hasattr(m, "club")) > 1: + nb_prefetched = sum( + 1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role") + ) + if nb_prefetched > 1: # if more than one membership hasn't its `club` attribute set # it's less expensive to reload the whole query with # a select_related than perform a distinct query # to fetch each club. ids = {m.id for m in memberships} memberships = list( - Membership.objects.filter(id__in=ids).select_related("club") + Membership.objects.filter(id__in=ids) + .select_related("club", "role") + .prefetch_related("role__linked_groups") ) - club_groups = [] + groups = [] for membership in memberships: - club_groups.append( + groups.append( User.groups.through( user_id=membership.user_id, group_id=membership.club.members_group_id, ) ) if membership.role.is_board: - club_groups.append( + groups.append( User.groups.through( user_id=membership.user_id, group_id=membership.club.board_group_id, ) ) - return User.groups.through.objects.bulk_create( - club_groups, ignore_conflicts=True - ) + groups.extend( + User.groups.through(user_id=membership.user_id, group_id=g.id) + for g in membership.role.linked_groups.all() + ) + return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True) class Mailing(models.Model): diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index a17569cc..dacfab35 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -17,7 +17,7 @@ from club.forms import ClubAddMemberForm, JoinClubForm from club.models import Club, ClubRole, Membership from club.tests.base import TestClub from core.baker_recipes import subscriber_user -from core.models import AnonymousUser, User +from core.models import AnonymousUser, Group, User if TYPE_CHECKING: from collections.abc import Callable @@ -503,6 +503,29 @@ class TestMembership(TestClub): assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.board_group) + def test_add_to_club_role_group(self): + groups = baker.make(Group, _quantity=4) + self.subscriber.groups.set(groups[:1]) + self.board_role.linked_groups.set(groups[1:3]) + baker.make( + Membership, club=self.club, user=self.subscriber, role=self.board_role + ) + assert set(self.subscriber.groups.all()) == { + *groups[:3], + self.club.board_group, + self.club.members_group, + } + + def test_remove_from_club_role_group(self): + groups = baker.make(Group, _quantity=3) + baker.make( + Membership, club=self.club, user=self.subscriber, role=self.board_role + ) + self.subscriber.groups.set(groups[:1]) + self.board_role.linked_groups.set(groups[1:]) + self.subscriber.memberships.update(end_date=localdate()) + assert set(self.subscriber.groups.all()) == {groups[0]} + def test_change_position_in_club(self): """Test that when moving from board to members, club group change""" membership = baker.make( From 43eedb274af32e28b962cedc5783cd489edf1393 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 1 Sep 2026 18:30:58 +0200 Subject: [PATCH 2/3] Show linked groups on club role form page --- club/forms.py | 7 +++++++ club/templates/club/club_roles.jinja | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/club/forms.py b/club/forms.py index 2f9db670..80d4c058 100644 --- a/club/forms.py +++ b/club/forms.py @@ -479,6 +479,13 @@ class ClubRoleCreateForm(forms.ModelForm): class ClubRoleBaseFormSet(forms.BaseInlineFormSet): ordering_widget = forms.HiddenInput() + def __init__(self, *args, queryset=None, **kwargs): + if queryset is None: + queryset = self.model._default_manager + super().__init__( + *args, queryset=queryset.prefetch_related("linked_groups"), **kwargs + ) + ClubRoleFormSet = forms.inlineformset_factory( Club, diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja index ddc54b0c..bb860f10 100644 --- a/club/templates/club/club_roles.jinja +++ b/club/templates/club/club_roles.jinja @@ -49,6 +49,20 @@ {{ subform.is_active.help_text }} + {% set groups = subform.instance.linked_groups.all()|list %} + {% if groups %} +
+

+ {% trans %}Linked groups : {% endtrans %} + {{ groups|map(attribute="name")|join(", ") }} +

+

+ {% trans trimmed %} + Users receiving this role will also be assigned to those groups + {% endtrans %} +

+
+ {% endif %} From d4432a49632001f0c19225b69f299f4fb00be60e Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 1 Sep 2026 18:31:06 +0200 Subject: [PATCH 3/3] add translations --- locale/fr/LC_MESSAGES/django.po | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 4fa87b77..4bc80079 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -260,6 +260,18 @@ msgstr "" "Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " "rejoignent le club." +#: club/models.py +msgid "Linked groups" +msgstr "Groupes liés" + +#: club/models.py +msgid "" +"Groups that are automatically given or removed to user receiving or losing " +"this club role" +msgstr "" +"Les groupes qui sont automatiquement donnés ou retirés quand l'utilisateur " +"reçoit ou perd ce rôle de club" + #: club/models.py election/models.py msgid "club role" msgstr "rôle de club" @@ -485,6 +497,14 @@ msgstr "Du" msgid "To" msgstr "Au" +#: club/templates/club/club_roles.jinja +msgid "Linked groups : " +msgstr "Groupes liés : " + +#: club/templates/club/club_roles.jinja +msgid "Users receiving this role will also be assigned to those groups" +msgstr "Les utilisateurs recevant ce rôle seront aussi assignés à ces groupes" + #: club/templates/club/club_roles.jinja msgid "" "Roles give rights on the club. Higher roles grant more rights, and the "