add ClubRole model

This commit is contained in:
imperosol
2025-06-22 16:59:07 +02:00
parent ffa0b94408
commit 3f313ca984
4 changed files with 232 additions and 22 deletions

View File

@@ -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")

View File

@@ -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 with which this role is associated",
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
]

View File

@@ -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",
),
),
]

View File

@@ -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 with which this role is associated"),
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,