From 8153d7a1056deb8d655388319d345543eda4daa5 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 1 Sep 2026 17:49:24 +0200 Subject: [PATCH] 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 6d6d8bb0..12cd7d16 100644 --- a/club/admin.py +++ b/club/admin.py @@ -46,8 +46,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 285b50f4..8113470f 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 class TestMembershipQuerySet(TestClub): @@ -500,6 +500,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(