From 7db66bb8f6de8ca41b1a86c77d938cb2d518ea6a Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 13 Sep 2025 13:07:20 +0200 Subject: [PATCH] feat: `MembershipQuerySet.editable_by` method --- club/models.py | 42 ++++++++++++++++++++++++++++++----- club/tests/test_membership.py | 36 +++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/club/models.py b/club/models.py index 800f67c2..682ee0af 100644 --- a/club/models.py +++ b/club/models.py @@ -30,7 +30,8 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import RegexValidator, validate_email from django.db import models, transaction -from django.db.models import Exists, F, OuterRef, Q +from django.db.models import Exists, F, OuterRef, Q, Value +from django.db.models.functions import Greatest from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property @@ -252,6 +253,41 @@ class MembershipQuerySet(models.QuerySet): """ return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) + def editable_by(self, user: User) -> Self: + """Filter Memberships that this user can edit. + + Users with the `club.change_membership` permission can edit all Membership. + The other users can end : + - their own membership + - if they are board members, memberships with a role lower than their own + + For example, let's suppose the following users : + - A : board member + - B : board member + - C : simple member + - D : curious + + A will be able to end the memberships of A, C and D ; + C and D will be able to end only their own membership. + """ + if user.has_perm("club.change_membership"): + return self.all() + return self.filter( + Exists( + Membership.objects.filter( + Q( + role__gt=Greatest( + OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE) + ) + ) + | Q(pk=OuterRef("pk")), + user=user, + end_date=None, + club=OuterRef("club"), + ) + ) + ) + def update(self, **kwargs) -> int: """Refresh the cache and edit group ownership. @@ -328,16 +364,12 @@ class Membership(models.Model): User, verbose_name=_("user"), related_name="memberships", - null=False, - blank=False, on_delete=models.CASCADE, ) club = models.ForeignKey( Club, verbose_name=_("club"), related_name="members", - null=False, - blank=False, on_delete=models.CASCADE, ) start_date = models.DateField(_("start date"), default=timezone.now) diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index ff668498..64d00a50 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -1,13 +1,15 @@ from bs4 import BeautifulSoup from django.conf import settings +from django.contrib.auth.models import Permission from django.core.cache import cache from django.db.models import Max +from django.test import TestCase from django.urls import reverse from django.utils.timezone import localdate, localtime, now from model_bakery import baker from club.forms import ClubMemberForm -from club.models import Membership +from club.models import Club, Membership from club.tests.base import TestClub from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User @@ -137,6 +139,38 @@ class TestMembershipQuerySet(TestClub): assert set(user.groups.all()).isdisjoint(club_groups) +class TestMembershipEditableBy(TestCase): + @classmethod + def setUpTestData(cls): + Membership.objects.all().delete() + cls.club_a, cls.club_b = baker.make(Club, _quantity=2) + cls.memberships = [ + *baker.make( + Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4 + ), + *baker.make( + Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4 + ), + ] + + def test_admin_user(self): + perm = Permission.objects.get(codename="change_membership") + user = baker.make(User, user_permissions=[perm]) + qs = Membership.objects.editable_by(user).values_list("id", flat=True) + assert set(qs) == set(Membership.objects.values_list("id", flat=True)) + + def test_simple_subscriber_user(self): + user = subscriber_user.make() + assert not Membership.objects.editable_by(user).exists() + + def test_board_member(self): + # a board member can end lower memberships and its own one + user = self.memberships[2].user + qs = Membership.objects.editable_by(user).values_list("id", flat=True) + expected = {self.memberships[2].id, self.memberships[3].id} + assert set(qs) == expected + + class TestMembership(TestClub): def assert_membership_started_today(self, user: User, role: int): """Assert that the given membership is active and started today."""