mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 15:51:19 +00:00
fixes on club group attribution
This commit is contained in:
parent
0ce73db818
commit
a2a85717a0
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
184
club/models.py
184
club/models.py
@ -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.
|
||||||
|
@ -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()
|
||||||
|
@ -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")
|
||||||
|
Loading…
Reference in New Issue
Block a user