fixes on club group attribution

This commit is contained in:
imperosol 2024-12-03 17:50:14 +01:00
parent 0ce73db818
commit a2a85717a0
4 changed files with 188 additions and 55 deletions

View File

@ -1,6 +1,7 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08 # Generated by Django 4.2.16 on 2024-11-20 17:08
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps from django.db.migrations.state import StateApps
@ -106,4 +107,11 @@ class Migration(migrations.Migration):
to="core.group", to="core.group",
), ),
), ),
migrations.AddConstraint(
model_name="membership",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start",
),
),
] ]

View File

@ -23,7 +23,7 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core import validators from django.core import validators
@ -31,7 +31,7 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -256,13 +256,6 @@ class Club(models.Model):
class MembershipQuerySet(models.QuerySet): class MembershipQuerySet(models.QuerySet):
@staticmethod
def _remove_from_club_groups(users: list[int], clubs: list[int]):
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
).delete()
def ongoing(self) -> Self: def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet.""" """Filter all memberships which are not finished yet."""
return self.filter(Q(end_date=None) | Q(end_date__gt=localdate())) return self.filter(Q(end_date=None) | Q(end_date__gt=localdate()))
@ -279,11 +272,16 @@ class MembershipQuerySet(models.QuerySet):
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
"""Refresh the cache for the elements of the queryset. """Refresh the cache and edit group ownership.
Besides that, does the same job as a regular update method. Update the cache, when necessary, remove
users from club groups they are no more in
and add them in the club groups they should be in.
Be aware that this adds a db query to retrieve the updated objects Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
""" """
nb_rows = super().update(**kwargs) nb_rows = super().update(**kwargs)
if nb_rows == 0: if nb_rows == 0:
@ -291,17 +289,18 @@ class MembershipQuerySet(models.QuerySet):
return 0 return 0
cache_memberships = {} cache_memberships = {}
to_remove = {"users": [], "clubs": []} memberships = set(self.select_related("club"))
for membership in self.all(): # delete all User-Group relations and recreate the necessary ones
cache_key = f"membership_{membership.club_id}_{membership.user_id}" # It's more concise to write and more reliable
if membership.end_date is not None and membership.end_date <= localdate(): Membership._remove_club_groups(memberships)
cache_memberships[cache_key] = "not_member" Membership._add_club_groups(memberships)
to_remove["users"].append(membership.user_id) for member in memberships:
to_remove["clubs"].append(membership.club_id) cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else: else:
cache_memberships[cache_key] = membership cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships) cache.set_many(cache_memberships)
self._remove_from_club_groups(to_remove["users"], to_remove["clubs"])
return nb_rows return nb_rows
def delete(self) -> tuple[int, dict[str, int]]: def delete(self) -> tuple[int, dict[str, int]]:
@ -310,21 +309,24 @@ class MembershipQuerySet(models.QuerySet):
before the deletion, before the deletion,
and a removal of the user from the club groups. and a removal of the user from the club groups.
Be aware that this adds a db query to retrieve the deleted element Be aware that this adds some db queries :
and another to remove users from the groups.
As queries take place before the deletion operation, - 1 to retrieve the deleted elements in order to perform
they will be performed even if the deletion fails. post-delete operations.
As we can't know if a delete will affect rows or not,
this query will always happen
- 1 query to remove the users from the club groups.
If the delete operation affected no row,
this query won't happen.
""" """
ids = list(self.values_list("club_id", "user_id")) memberships = set(self.all())
nb_rows, rows_counts = super().delete() nb_rows, rows_counts = super().delete()
if nb_rows > 0: if nb_rows > 0:
user_ids = [i[0] for i in ids] Membership._remove_club_groups(memberships)
club_ids = [i[1] for i in ids]
self._remove_from_club_groups(user_ids, club_ids)
cache.set_many( cache.set_many(
{ {
f"membership_{club_id}_{user_id}": "not_member" f"membership_{m.club_id}_{m.user_id}": "not_member"
for club_id, user_id in ids for m in memberships
} }
) )
return nb_rows, rows_counts return nb_rows, rows_counts
@ -370,6 +372,13 @@ class Membership(models.Model):
objects = MembershipQuerySet.as_manager() objects = MembershipQuerySet.as_manager()
class Meta:
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), name="end_after_start"
),
]
def __str__(self): def __str__(self):
return ( return (
f"{self.club.name} - {self.user.username} " f"{self.club.name} - {self.user.username} "
@ -378,24 +387,15 @@ class Membership(models.Model):
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# adding must be set before calling super().save()
# because the call will switch _state.adding from True to False
adding = self._state.adding
super().save(*args, **kwargs) super().save(*args, **kwargs)
if adding: # a save may either be an update or a creation
groups = [ # and may result in either an ongoing or an ended membership.
User.groups.through( # It could also be a retrogradation from the board to being a simple member.
user_id=self.user_id, group_id=self.club.members_group_id # To avoid problems, the user is removed from the club groups beforehand ;
) # he will be added back if necessary
] self._remove_club_groups([self])
if self.role > settings.SITH_MAXIMUM_FREE_ROLE:
groups.append(
User.groups.through(
user_id=self.user_id, group_id=self.club.board_group_id
)
)
User.groups.through.objects.bulk_create(groups)
if self.end_date is None: if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self) cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else: else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member") cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
@ -403,15 +403,11 @@ class Membership(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id}) return reverse("club:club_members", kwargs={"club_id": self.club_id})
@property def is_owned_by(self, user: User) -> bool:
def is_ongoing(self):
return self.end_date is None or self.end_date
def is_owned_by(self, user):
"""Method to see if that object can be super edited by the given user.""" """Method to see if that object can be super edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
return user.is_board_member return user.is_root or user.is_board_member
def can_be_edited_by(self, user: User) -> bool: def can_be_edited_by(self, user: User) -> bool:
"""Check if that object can be edited by the given user.""" """Check if that object can be edited by the given user."""
@ -421,9 +417,91 @@ class Membership(models.Model):
return membership is not None and membership.role >= self.role return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}") cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club 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.
After calling this function, he will be in neither.
Returns:
The result of the deletion queryset.
Warnings:
If this function isn't used in combination
with an actual deletion of the memberships,
it will result in an inconsistent state,
where users will be in the clubs, without
having the associated rights.
"""
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))
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club 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
of the club.
Returns:
The created User-Group relations.
Warnings:
If this function isn't used in combination
with an actual update/creation of the memberships,
it will result in an inconsistent state,
where users will have the rights associated to the
club, without actually being part of it.
"""
# only active membership (i.e. `end_date=None`)
# grant the attribution of club groups.
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:
# 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")
)
club_groups = []
for membership in memberships:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_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
)
class Mailing(models.Model): class Mailing(models.Model):
"""A Mailing list for a club. """A Mailing list for a club.

View File

@ -165,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
assert new_mem != "not_member" assert new_mem != "not_member"
assert new_mem.role == 5 assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(end_date=localdate()) # end the membership
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self): def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache.""" """Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.skia.memberships.get(club=self.club) mem_skia = self.skia.memberships.get(club=self.club)
@ -183,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
) )
assert cached_mem == "not_member" assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
memberships[1].club.board_group,
}
assert set(user.groups.all()) == club_groups
user.memberships.all().delete()
assert user.groups.all().count() == 0
class TestClubModel(TestClub): class TestClubModel(TestClub):
def assert_membership_started_today(self, user: User, role: int): def assert_membership_started_today(self, user: User, role: int):
@ -487,10 +521,22 @@ class TestClubModel(TestClub):
"""Test that when a membership begins, the user is added to the club group.""" """Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group) assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group) assert not self.subscriber.groups.contains(self.club.board_group)
Membership.objects.create(club=self.club, user=self.subscriber, role=3) baker.make(Membership, club=self.club, user=self.subscriber, role=3)
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
def test_club_owner(self): def test_club_owner(self):
"""Test that a club is owned only by board members of the main club.""" """Test that a club is owned only by board members of the main club."""
anonymous = AnonymousUser() anonymous = AnonymousUser()

View File

@ -173,7 +173,8 @@ class Command(BaseCommand):
club=club, club=club,
) )
) )
Membership.objects.bulk_create(memberships) memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self): def create_uvs(self):
root = User.objects.get(username="root") root = User.objects.get(username="root")