diff --git a/club/admin.py b/club/admin.py index 0622cb16..bff21208 100644 --- a/club/admin.py +++ b/club/admin.py @@ -14,7 +14,7 @@ # from django.contrib import admin -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership @admin.register(Club) @@ -30,6 +30,20 @@ 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",) + list_select_related = ("club",) + list_filter = ( + "is_board", + "is_presidency", + ("club", admin.RelatedOnlyFieldListFilter), + ) + show_facets = admin.ModelAdmin.show_facets.ALWAYS + + @admin.register(Membership) class MembershipAdmin(admin.ModelAdmin): list_display = ("user", "club", "role", "start_date", "end_date") diff --git a/club/migrations/0015_clubrole_alter_membership_role.py b/club/migrations/0015_clubrole_alter_membership_role.py new file mode 100644 index 00000000..2701169e --- /dev/null +++ b/club/migrations/0015_clubrole_alter_membership_role.py @@ -0,0 +1,118 @@ +# Generated by Django 5.2.3 on 2025-06-21 21:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import Case, When +from django.utils import translation + + +def migrate_roles(apps: StateApps, schema_editor): + ClubRole = apps.get_model("club", "ClubRole") + Membership = apps.get_model("club", "Membership") + + translation.activate("fr") + + updates = [] + presidency = settings.SITH_CLUB_ROLES_ID["President"] + for club_id, role in Membership.objects.values_list("club", "role").distinct(): + new_role = ClubRole.objects.create( + name=SITH_CLUB_ROLES[role], + is_board=role > settings.SITH_MAXIMUM_FREE_ROLE, + is_presidency=role == presidency, + club_id=club_id, + order=presidency - role, + ) + updates.append(When(role=role, then=new_role.id)) + # all updates must happen at the same time + # otherwise, the 10 first created ClubRole would be + # re-modified after their initial creation, and it would + # result in an incoherent state. + # To avoid that, all updates are wrapped in a single giant Case(When) statement + # cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update + Membership.objects.update(role=Case(*updates)) + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more") + ] + + operations = [ + migrations.CreateModel( + name="ClubRole", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "order", + models.PositiveIntegerField( + db_index=True, editable=False, verbose_name="order" + ), + ), + ( + "club", + models.ForeignKey( + help_text="The club in which this role exists", + on_delete=django.db.models.deletion.CASCADE, + related_name="roles", + to="club.club", + verbose_name="club", + ), + ), + ("name", models.CharField(max_length=50, verbose_name="name")), + ( + "description", + models.TextField( + default="", blank=True, verbose_name="description" + ), + ), + ( + "is_board", + models.BooleanField(default=False, verbose_name="Board role"), + ), + ( + "is_presidency", + models.BooleanField(default=False, verbose_name="Presidency role"), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text=( + "If the role is inactive, people joining the club " + "won't be able to get it." + ), + verbose_name="is active", + ), + ), + ], + options={ + "ordering": ("order",), + "verbose_name": "club role", + "verbose_name_plural": "club roles", + }, + ), + migrations.AddConstraint( + model_name="clubrole", + constraint=models.CheckConstraint( + condition=models.Q( + ("is_presidency", False), ("is_board", True), _connector="OR" + ), + name="clubrole_presidency_implies_board", + ), + ), + migrations.RunPython(migrate_roles, migrations.RunPython.noop), + # because Postgres migrations run in a single transaction, + # we cannot change the actual values of Membership.role + # and apply the FOREIGN KEY constraint in the same migration. + # The constraint is created in the next migration + ] diff --git a/club/migrations/0016_clubrole_alter_membership_role.py b/club/migrations/0016_clubrole_alter_membership_role.py new file mode 100644 index 00000000..2031911c --- /dev/null +++ b/club/migrations/0016_clubrole_alter_membership_role.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.3 on 2025-09-27 09:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("club", "0015_clubrole_alter_membership_role")] + + operations = [ + # because Postgres migrations run in a single transaction, + # we cannot change the actual values of Membership.role + # and apply the FOREIGN KEY constraint in the same migration. + # The data migration was made in the previous migration. + migrations.AlterField( + model_name="membership", + name="role", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="members", + to="club.clubrole", + verbose_name="role", + ), + ), + ] diff --git a/club/models.py b/club/models.py index f6695812..31d8b8fd 100644 --- a/club/models.py +++ b/club/models.py @@ -29,14 +29,14 @@ from django.conf import settings 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, Value -from django.db.models.functions import Greatest +from django.db.models import Exists, F, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.text import slugify from django.utils.timezone import localdate from django.utils.translation import gettext_lazy as _ +from ordered_model.models import OrderedModel from core.fields import ResizedImageField from core.models import Group, Notification, Page, SithFile, User @@ -220,6 +220,62 @@ class Club(models.Model): return user.is_in_group(pk=self.board_group_id) +class ClubRole(OrderedModel): + club = models.ForeignKey( + Club, + verbose_name=_("club"), + help_text=_("The club in which this role exists"), + related_name="roles", + on_delete=models.CASCADE, + ) + name = models.CharField(_("name"), max_length=50) + description = models.TextField(_("description"), blank=True, default="") + is_board = models.BooleanField(_("Board role"), default=False) + is_presidency = models.BooleanField(_("Presidency role"), default=False) + is_active = models.BooleanField( + _("is active"), + default=True, + help_text=_( + "If the role is inactive, people joining the club won't be able to get it." + ), + ) + + order_with_respect_to = "club" + + class Meta(OrderedModel.Meta): + verbose_name = _("club role") + verbose_name_plural = _("club roles") + abstract = False + constraints = [ + # presidency IMPLIES board <=> NOT presidency OR board + # cf. MT1 :) + models.CheckConstraint( + condition=Q(is_presidency=False) | Q(is_board=True), + name="clubrole_presidency_implies_board", + ) + ] + + def __str__(self): + return f"{self.name} - {self.club.name}" + + def get_display_name(self): + return f"{self.name} - {self.club.name}" + + def get_absolute_url(self): + return reverse("club:club_roles", kwargs={"club_id": self.club_id}) + + def clean(self): + if self.is_presidency and not self.is_board: + raise ValidationError( + _( + "Role %(name)s was declared as a presidency role " + "without being a board role" + ) + % {"name": self.name} + ) + return super().clean() + + class MembershipQuerySet(models.QuerySet): def ongoing(self) -> Self: """Filter all memberships which are not finished yet.""" @@ -232,9 +288,10 @@ class MembershipQuerySet(models.QuerySet): are included, even if there are no more members. If you want to get the users who are currently in the board, - mind combining this with the `ongoing` queryset method + mind combining this with the [MembershipQuerySet.ongoing][] + queryset method """ - return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) + return self.filter(role__is_board=True) def editable_by(self, user: User) -> Self: """Filter Memberships that this user can edit. @@ -257,21 +314,16 @@ class MembershipQuerySet(models.QuerySet): """ if user.has_perm("club.change_membership"): return self.all() - return self.filter( + return self.ongoing().filter( Q(user=user) | Exists( - Membership.objects.filter( - Q( - role__gt=Greatest( - OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE) - ) - ), + Membership.objects.ongoing().filter( user=user, - end_date=None, club=OuterRef("club"), + role__is_board=True, + role__order__gt=OuterRef("role__order"), ) - ), - end_date=None, + ) ) def update(self, **kwargs) -> int: @@ -341,10 +393,11 @@ class Membership(models.Model): ) start_date = models.DateField(_("start date"), default=timezone.now) end_date = models.DateField(_("end date"), null=True, blank=True) - role = models.IntegerField( - _("role"), - choices=sorted(settings.SITH_CLUB_ROLES.items()), - default=sorted(settings.SITH_CLUB_ROLES.items())[0][0], + role = models.ForeignKey( + ClubRole, + verbose_name=_("role"), + related_name="members", + on_delete=models.PROTECT, ) description = models.CharField( _("description"), max_length=128, null=False, blank=True @@ -362,7 +415,7 @@ class Membership(models.Model): def __str__(self): return ( f"{self.club.name} - {self.user.username} " - f"- {settings.SITH_CLUB_ROLES[self.role]} " + f"- {self.role.name} " f"- {str(_('past member')) if self.end_date is not None else ''}" ) @@ -391,7 +444,7 @@ class Membership(models.Model): if user.is_root or user.is_board_member: return True membership = self.club.get_membership_for(user) - return membership is not None and membership.role >= self.role + return membership is not None and membership.role.order < self.role.order def delete(self, *args, **kwargs): self._remove_club_groups([self]) @@ -467,7 +520,7 @@ class Membership(models.Model): group_id=membership.club.members_group_id, ) ) - if membership.role > settings.SITH_MAXIMUM_FREE_ROLE: + if membership.role.is_board: club_groups.append( User.groups.through( user_id=membership.user_id,