diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e96a456f..de547dc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.5 + rev: v0.15.13 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing @@ -14,7 +14,7 @@ repos: - id: biome-check additional_dependencies: ["@biomejs/biome@2.4.6"] - repo: https://github.com/rtts/djhtml - rev: 3.0.10 + rev: 3.0.11 hooks: - id: djhtml name: format templates diff --git a/biome.json b/biome.json index 4b50821d..bd41ee38 100644 --- a/biome.json +++ b/biome.json @@ -38,6 +38,6 @@ } }, "javascript": { - "globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"] + "globals": ["Alpine", "gettext", "interpolate"] } } diff --git a/club/admin.py b/club/admin.py index 0622cb16..6d6d8bb0 100644 --- a/club/admin.py +++ b/club/admin.py @@ -13,8 +13,10 @@ # # from django.contrib import admin +from django.forms.models import ModelForm +from django.http import HttpRequest -from club.models import Club, Membership +from club.models import Club, ClubLink, ClubRole, LinkType, Membership @admin.register(Club) @@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin): "page", ) + def save_model( + self, + request: HttpRequest, + obj: Club, + form: ModelForm, + change: bool, # noqa: FBT001 + ): + super().save_model(request, obj, form, change) + if not change: + obj.create_default_roles() + + +@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): @@ -40,3 +67,18 @@ class MembershipAdmin(admin.ModelAdmin): "club__name", ) autocomplete_fields = ("user",) + + +@admin.register(LinkType) +class LinkTypeAdmin(admin.ModelAdmin): + list_display = ("name", "url_base", "icon") + search_fields = ("name",) + + +@admin.register(ClubLink) +class ClubLinkAdmin(admin.ModelAdmin): + list_display = ("link_type", "club", "url") + list_select_related = ("link_type", "club") + autocomplete_fields = ("link_type", "club") + search_fields = ("link_type__name", "url") + list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter)) diff --git a/club/api.py b/club/api.py index cde007c2..4a055e2c 100644 --- a/club/api.py +++ b/club/api.py @@ -37,10 +37,11 @@ class ClubController(ControllerBase): ) def fetch_club(self, club_id: int): prefetch = Prefetch( - "members", queryset=Membership.objects.ongoing().select_related("user") + "members", + queryset=Membership.objects.ongoing().select_related("user", "role"), ) return self.get_object_or_exception( - Club.objects.prefetch_related(prefetch), id=club_id + Club.objects.prefetch_related(prefetch, "links"), id=club_id ) @@ -59,5 +60,5 @@ class UserClubController(ControllerBase): return ( Membership.objects.ongoing() .filter(user=user) - .select_related("club", "user") + .select_related("club", "user", "role") ) diff --git a/club/forms.py b/club/forms.py index 3c10dfce..7c524f56 100644 --- a/club/forms.py +++ b/club/forms.py @@ -23,13 +23,19 @@ # from django import forms -from django.conf import settings -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models.functions import Lower from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from club.models import Club, Mailing, MailingSubscription, Membership +from club.models import ( + Club, + ClubLink, + ClubRole, + Mailing, + MailingSubscription, + Membership, +) from core.models import User from core.views.forms import SelectDateTime from core.views.widgets.ajax_select import ( @@ -40,6 +46,26 @@ from counter.models import Counter, Selling from counter.schemas import SaleFilterSchema +class ClubLinkForm(forms.ModelForm): + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = ClubLink + fields = ["url", "name", "link_type"] + widgets = { + "url": forms.URLInput( + {"pattern": "https://.*", "placeholder": "https://monlien.com"} + ), + "link_type": forms.HiddenInput(), + } + + +ClubLinkFormSet = forms.inlineformset_factory( + Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False +) + + class ClubEditForm(forms.ModelForm): error_css_class = "error" required_css_class = "required" @@ -49,6 +75,20 @@ class ClubEditForm(forms.ModelForm): fields = ["address", "logo", "short_description"] widgets = {"short_description": forms.Textarea()} + def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs): + super().__init__(*args, prefix=prefix, instance=instance, **kwargs) + self.link_formset = ClubLinkFormSet( + *args, instance=self.instance, prefix="link", **kwargs + ) + + def is_valid(self): + return super().is_valid() and self.link_formset.is_valid() + + def save(self, commit=True): # noqa: FBT002 + res = super().save(commit=commit) + self.link_formset.save(commit=commit) + return res + class ClubAdminEditForm(ClubEditForm): admin_fields = ["name", "parent", "is_active"] @@ -215,9 +255,7 @@ class ClubOldMemberForm(forms.Form): def __init__(self, *args, user: User, club: Club, **kwargs): super().__init__(*args, **kwargs) - self.fields["members_old"].queryset = ( - Membership.objects.ongoing().filter(club=club).editable_by(user) - ) + self.fields["members_old"].queryset = club.members.ongoing().editable_by(user) class ClubMemberForm(forms.ModelForm): @@ -235,19 +273,14 @@ class ClubMemberForm(forms.ModelForm): self.request_user = request_user self.request_user_membership = self.club.get_membership_for(self.request_user) super().__init__(*args, **kwargs) - self.fields["role"].required = True - self.fields["role"].choices = [ - (value, name) - for value, name in settings.SITH_CLUB_ROLES.items() - if value <= self.max_available_role - ] + self.fields["role"].queryset = self.available_roles self.instance.club = club @property - def max_available_role(self): - """The greatest role that will be obtainable with this form.""" + def available_roles(self) -> QuerySet[ClubRole]: + """The roles that will be obtainable with this form.""" # this is unreachable, because it will be overridden by subclasses - return -1 # pragma: no cover + return ClubRole.objects.none() # pragma: no cover class ClubAddMemberForm(ClubMemberForm): @@ -258,21 +291,22 @@ class ClubAddMemberForm(ClubMemberForm): widgets = {"user": AutoCompleteSelectUser} @cached_property - def max_available_role(self): - """The greatest role that will be obtainable with this form. + def available_roles(self): + """The roles that will be obtainable with this form. Admins and the club president can attribute any role. Board members can attribute roles lower than their own. Other users cannot attribute roles with this form """ + qs = self.club.roles.filter(is_active=True) if self.request_user.has_perm("club.add_membership"): - return settings.SITH_CLUB_ROLES_ID["President"] + return qs.all() membership = self.request_user_membership - if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE: - return -1 - if membership.role == settings.SITH_CLUB_ROLES_ID["President"]: - return membership.role - return membership.role - 1 + if membership is None or not membership.role.is_board: + return ClubRole.objects.none() + if membership.role.is_presidency: + return qs.all() + return qs.above_instance(membership.role) def clean_user(self): """Check that the user is not trying to add a user already in the club. @@ -296,13 +330,11 @@ class JoinClubForm(ClubMemberForm): def __init__(self, *args, club: Club, request_user: User, **kwargs): super().__init__(*args, club=club, request_user=request_user, **kwargs) - # this form doesn't manage the user who will join the club, - # so we must set this here to avoid errors self.instance.user = self.request_user @cached_property - def max_available_role(self): - return settings.SITH_MAXIMUM_FREE_ROLE + def available_roles(self): + return self.club.roles.filter(is_board=False, is_active=True) def clean(self): """Check that the user is subscribed and isn't already in the club.""" @@ -339,3 +371,64 @@ class ClubSearchForm(forms.ModelForm): # so we enforce it. self.fields["club_status"].value = True self.fields["name"].required = False + + +class ClubRoleForm(forms.ModelForm): + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = ClubRole + fields = ["name", "description", "is_presidency", "is_board", "is_active"] + widgets = { + "is_presidency": forms.HiddenInput(), + "is_board": forms.HiddenInput(), + "is_active": forms.CheckboxInput(attrs={"class": "switch"}), + } + + def clean(self): + cleaned_data = super().clean() + if "ORDER" in cleaned_data: + self.instance.order = cleaned_data["ORDER"] - 1 + return cleaned_data + + +class ClubRoleCreateForm(forms.ModelForm): + """Form to create a club role. + + Notes: + For UX purposes, users are not meant to fill `is_presidency` + and `is_board`, so those values are required by the form constructor + in order to initialize the instance properly. + """ + + error_css_class = "error" + required_css_class = "required" + + class Meta: + model = ClubRole + fields = ["name", "description"] + + def __init__( + self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs + ): + super().__init__(*args, **kwargs) + self.instance.club = club + self.instance.is_presidency = is_presidency + self.instance.is_board = is_board + + +class ClubRoleBaseFormSet(forms.BaseInlineFormSet): + ordering_widget = forms.HiddenInput() + + +ClubRoleFormSet = forms.inlineformset_factory( + Club, + ClubRole, + ClubRoleForm, + ClubRoleBaseFormSet, + can_delete=False, + can_order=True, + edit_only=True, + extra=0, +) diff --git a/club/migrations/0012_club_board_group_club_members_group.py b/club/migrations/0012_club_board_group_club_members_group.py index e436bcd4..a9ad8d3a 100644 --- a/club/migrations/0012_club_board_group_club_members_group.py +++ b/club/migrations/0012_club_board_group_club_members_group.py @@ -2,12 +2,15 @@ import django.db.models.deletion import django.db.models.functions.datetime -from django.conf import settings from django.db import migrations, models from django.db.migrations.state import StateApps from django.db.models import Q from django.utils.timezone import localdate +# Before the club role rework, the maximum free role +# was the hardcoded highest non-board role +MAXIMUM_FREE_ROLE = 1 + def migrate_meta_groups(apps: StateApps, schema_editor): """Attach the existing meta groups to the clubs. @@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor): ).select_related("user") club.members_group.users.set([m.user for m in memberships]) club.board_group.users.set( - [ - m.user - for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) - ] + [m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)] ) 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..82279f65 --- /dev/null +++ b/club/migrations/0015_clubrole_alter_membership_role.py @@ -0,0 +1,161 @@ +# Generated by Django 5.2.3 on 2025-06-21 21:59 + +import django.db.models.deletion +from django.db import migrations, models +from django.db.migrations.state import StateApps +from django.db.models import Case, When + +PRESIDENCY_ROLES = [10, 9] +MAXIMUM_FREE_ROLE = 1 +SITH_CLUB_ROLES = { + 10: "Président⸱e", + 9: "Vice-Président⸱e", + 7: "Trésorier⸱e", + 5: "Responsable communication", + 4: "Secrétaire", + 3: "Responsable info", + 2: "Membre du bureau", + 1: "Membre actif⸱ve", + 0: "Curieux⸱euse", +} + + +def migrate_roles(apps: StateApps, schema_editor): + ClubRole = apps.get_model("club", "ClubRole") + Membership = apps.get_model("club", "Membership") + + updates = [] + 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 > MAXIMUM_FREE_ROLE, + is_presidency=role in PRESIDENCY_ROLES, + club_id=club_id, + order=max(SITH_CLUB_ROLES) - role, + ) + updates.append(When(club_id=club_id, 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"), + ("core", "0047_alter_notification_date_alter_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="club", + name="page", + field=models.OneToOneField( + blank=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="club", + to="core.page", + ), + ), + 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.AlterField( + model_name="club", + name="board_group", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="club_board", + to="core.group", + ), + ), + migrations.AlterField( + model_name="club", + name="members_group", + field=models.OneToOneField( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="club", + to="core.group", + ), + ), + migrations.AddConstraint( + model_name="clubrole", + constraint=models.CheckConstraint( + condition=models.Q( + ("is_presidency", False), ("is_board", True), _connector="OR" + ), + name="clubrole_presidency_implies_board", + violation_error_message=( + "A role cannot be in the presidency while not being in the 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/migrations/0017_linktype_clublink.py b/club/migrations/0017_linktype_clublink.py new file mode 100644 index 00000000..097e77f3 --- /dev/null +++ b/club/migrations/0017_linktype_clublink.py @@ -0,0 +1,105 @@ +# Generated by Django 5.2.12 on 2026-04-27 07:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("club", "0016_clubrole_alter_membership_role")] + + operations = [ + migrations.CreateModel( + name="LinkType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=40, verbose_name="name")), + ( + "url_base", + models.URLField( + help_text=( + "The base url that links with this type " + "must respect (e.g. `https://www.instagram.com`)" + ), + unique=True, + verbose_name="url base", + ), + ), + ( + "icon", + models.CharField( + help_text=( + "The fontawesome class to use " + "(e.g. `fa-brands fa-instagram`)" + ), + max_length=40, + verbose_name="icon", + ), + ), + ], + options={"verbose_name": "link type", "verbose_name_plural": "link types"}, + ), + migrations.CreateModel( + name="ClubLink", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(blank=True, max_length=40, verbose_name="name"), + ), + ("url", models.URLField(verbose_name="link url")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ( + "club", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="links", + to="club.club", + verbose_name="club", + ), + ), + ( + "link_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="links", + to="club.linktype", + verbose_name="link type", + ), + ), + ], + options={ + "verbose_name": "club link", + "verbose_name_plural": "club links", + "constraints": [ + models.UniqueConstraint( + fields=["club", "url"], + name="club_clublink_unique_club_url", + violation_error_message="Duplicated url", + ) + ], + }, + ), + ] diff --git a/club/models.py b/club/models.py index f6695812..6e98848e 100644 --- a/club/models.py +++ b/club/models.py @@ -28,15 +28,15 @@ from typing import Iterable, Self 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 import ProgrammingError, models, transaction +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 @@ -89,13 +89,13 @@ class Club(models.Model): on_delete=models.SET_NULL, ) page = models.OneToOneField( - Page, related_name="club", blank=True, on_delete=models.CASCADE + Page, related_name="club", blank=True, on_delete=models.PROTECT ) members_group = models.OneToOneField( - Group, related_name="club", on_delete=models.PROTECT + Group, related_name="club", on_delete=models.PROTECT, editable=False ) board_group = models.OneToOneField( - Group, related_name="club_board", on_delete=models.PROTECT + Group, related_name="club_board", on_delete=models.PROTECT, editable=False ) objects = ClubQuerySet.as_manager() @@ -138,9 +138,7 @@ class Club(models.Model): @cached_property def president(self) -> Membership | None: """Fetch the membership of the current president of this club.""" - return self.members.filter( - role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None - ).first() + return self.members.filter(end_date=None).order_by("role__order").first() def check_loop(self): """Raise a validation error when a loop is found within the parent list.""" @@ -185,6 +183,40 @@ class Club(models.Model): self.page.parent = self.parent.page self.page.save(force_lock=True) + def create_default_roles(self): + """Create some roles that should exist by default for this club. + + The created roles are : president, treasurer, active member and curious. + + Warnings: + When calling this method, no club must exist yet for this club. + """ + if self.roles.exists(): + raise ProgrammingError( + "Default roles can be created only for clubs " + "that don't have associated roles yet" + ) + # The names are written in French, because there is no gettext involved + # for strings stored in database, and the majority of users are french. + roles = [ + ClubRole(name="Président⸱e", is_board=True, is_presidency=True), + ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False), + ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False), + ClubRole( + name="Curieux⸱euse", + description=( + "Les gens qui suivent l'activité " + "du club sans forcément y participer" + ), + is_board=False, + is_presidency=False, + ), + ] + for i, role in enumerate(roles): + role.club = self + role.order = i + ClubRole.objects.bulk_create(roles) + def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: self.board_group.delete() self.members_group.delete() @@ -206,9 +238,20 @@ class Club(models.Model): """Method to see if that object can be edited by the given user.""" return self.has_rights_in_club(user) + def can_roles_be_edited_by(self, user: User) -> bool: + """Return True if the given user can edit the roles of this club""" + return user.is_authenticated and ( + user.has_perm("club.change_clubrole") + or self.members.ongoing() + .filter(user=user, role__is_presidency=True) + .exists() + ) + @cached_property def current_members(self) -> list[Membership]: - return list(self.members.ongoing().select_related("user").order_by("-role")) + return list( + self.members.ongoing().select_related("user", "role").order_by("-role") + ) def get_membership_for(self, user: User) -> Membership | None: """Return the current membership of the given user.""" @@ -220,6 +263,95 @@ 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") + 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", + violation_error_message=_( + "A role cannot be in the presidency while not being in the board" + ), + ) + ] + + def __str__(self): + return self.name + + def get_display_name(self): + return f"{self.name} - {self.club.name}" + + def clean(self): + errors = [] + roles = list(self.club.roles.all()) + if ( + self.is_board + and self.order + and any(r.order < self.order and not r.is_board for r in roles) + ): + errors.append( + ValidationError( + _("Role %(role)s cannot be placed below a member role") + % {"role": self.name} + ) + ) + if ( + self.is_presidency + and self.order + and any(r.order < self.order and not r.is_presidency for r in roles) + ): + errors.append( + ValidationError( + _("Role %(role)s cannot be placed below a non-presidency role") + % {"role": self.name} + ) + ) + if errors: + raise ValidationError(errors) + return super().clean() + + def save(self, *args, **kwargs): + auto_order = self.order is None and self.is_board + if not auto_order: + super().save(*args, **kwargs) + return + # get the role that should be placed after the role we are dealing with. + # So, if this is role is presidency, get the first board role ; + # if it is a board role, get the first member role ; + # and if it is a member role, get nothing (OrderedModel.save will + # automatically put it in the last position anyway) + filters = {"is_board": self.is_presidency, "is_presidency": False} + next_role = self.club.roles.filter(**filters).order_by("order").first() + super().save(*args, **kwargs) + if next_role: + self.above(next_role) + + class MembershipQuerySet(models.QuerySet): def ongoing(self) -> Self: """Filter all memberships which are not finished yet.""" @@ -232,9 +364,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 +390,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__lt=OuterRef("role__order"), ) - ), - end_date=None, + ) ) def update(self, **kwargs) -> int: @@ -341,10 +469,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 +491,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 +520,11 @@ 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 + if not membership: + return False + return membership.user_id == user.id or ( + membership.is_board and membership.role.order < self.role.order + ) def delete(self, *args, **kwargs): self._remove_club_groups([self]) @@ -467,7 +600,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, @@ -640,3 +773,81 @@ class MailingSubscription(models.Model): def fetch_format(self): return self.get_email + " " + + +class LinkType(models.Model): + """A link type, in order to group links and give them icons. + + Notes: + Among all club links, there is a special one, with an empty base url + and a default link icon. + It is use as a fallback item when no actual link type can be found. + + Danger: + LinkType.icon is content that will be raw-rendered in the template. + It is NOT safe to allow users to give it. + The edition of this field must be reserved to trusted admins. + """ + + name = models.CharField(_("name"), max_length=40) + url_base = models.URLField( + "url base", + unique=True, + help_text=_( + "The base url that links with this type must respect (e.g. `%(url)s`)" + ) + % {"url": "https://www.instagram.com"}, + ) + icon = models.CharField( + _("icon"), + max_length=40, + help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"), + ) + + class Meta: + verbose_name = _("link type") + verbose_name_plural = _("link types") + + def __str__(self): + return self.name + + +class ClubLink(models.Model): + link_type = models.ForeignKey( + LinkType, + verbose_name=_("link type"), + on_delete=models.CASCADE, + related_name="links", + ) + name = models.CharField(_("name"), max_length=40, blank=True) + url = models.URLField(_("link url")) + club = models.ForeignKey( + Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links" + ) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(_("updated at"), auto_now=True) + + class Meta: + verbose_name = _("club link") + verbose_name_plural = _("club links") + constraints = [ + models.UniqueConstraint( + fields=["club", "url"], + name="club_clublink_unique_club_url", + violation_error_message=_("Duplicated url"), + ) + ] + + def __str__(self): + return self.url + + def save(self, **kwargs): + if not self.name: + self.name = self.link_type.name + return super().save(**kwargs) + + def clean(self): + if not self.url.startswith(self.link_type.url_base): + raise ValidationError( + _("This link doesn't match with the url base of its type.") + ) diff --git a/club/schemas.py b/club/schemas.py index 02622110..99d05fc1 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -2,8 +2,9 @@ from typing import Annotated from django.db.models import Q from ninja import FilterLookup, FilterSchema, ModelSchema +from pydantic import HttpUrl -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.schemas import NonEmptyStr, SimpleUserSchema @@ -39,14 +40,21 @@ class ClubProfileSchema(ModelSchema): return obj.get_absolute_url() +class ClubRoleSchema(ModelSchema): + class Meta: + model = ClubRole + fields = ["id", "name", "is_presidency", "is_board"] + + class ClubMemberSchema(ModelSchema): """A schema to represent all memberships in a club.""" class Meta: model = Membership - fields = ["start_date", "end_date", "role", "description"] + fields = ["start_date", "end_date", "description"] user: SimpleUserSchema + role: ClubRoleSchema class ClubSchema(ModelSchema): @@ -55,6 +63,11 @@ class ClubSchema(ModelSchema): fields = ["id", "name", "logo", "is_active", "short_description", "address"] members: list[ClubMemberSchema] + links: list[HttpUrl] + + @staticmethod + def resolve_links(obj: Club): + return [link.url for link in obj.links.all()] class UserMembershipSchema(ModelSchema): @@ -62,6 +75,7 @@ class UserMembershipSchema(ModelSchema): class Meta: model = Membership - fields = ["id", "start_date", "role", "description"] + fields = ["id", "start_date", "description"] club: SimpleClubSchema + role: ClubRoleSchema diff --git a/club/static/bundled/club/role-list-index.ts b/club/static/bundled/club/role-list-index.ts new file mode 100644 index 00000000..2020bba4 --- /dev/null +++ b/club/static/bundled/club/role-list-index.ts @@ -0,0 +1,61 @@ +import type { AlpineComponent } from "alpinejs"; + +interface RoleGroupData { + isBoard: boolean; + isPresidency: boolean; + roleId: number; +} + +document.addEventListener("alpine:init", () => { + Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({ + confirmOnSubmit: false, + + /** + * Edit relevant item data after it has been moved by x-sort + */ + reorder(item: AlpineComponent, conf: RoleGroupData) { + item.isBoard = conf.isBoard; + item.isPresidency = conf.isPresidency; + // if the user has moved its own role outside the presidency, + // submitting the form will require a confirmation + this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency; + this.resetOrder(); + }, + /** + * Reset the value of the ORDER input of all items in the list. + * This is to be called after any reordering operation, in order to make sure + * that the order that will be saved is coherent with what is displayed. + */ + resetOrder() { + // When moving items with x-sort, the only information we truly have is + // the end position in the target group, not the previous position nor + // the position in the global list. + // To overcome this, we loop through an enumeration of all inputs + // that are in the form `roles-X-ORDER` and sequentially set the value of the field. + const inputs = document.querySelectorAll( + "input[name^='roles'][name$='ORDER']", + ); + for (const [i, elem] of inputs.entries()) { + elem.value = (i + 1).toString(); + } + }, + + /** + * If the user moved its role out of the presidency, ask a confirmation + * before submitting the form + */ + confirmSubmission(event: SubmitEvent) { + if ( + this.confirmOnSubmit && + !confirm( + gettext( + "You're going to remove your own role from the presidency. " + + "You may lock yourself out of this page. Do you want to continue ? ", + ), + ) + ) { + event.preventDefault(); + } + }, + })); +}); diff --git a/club/static/club/detail.scss b/club/static/club/detail.scss new file mode 100644 index 00000000..48ecb36f --- /dev/null +++ b/club/static/club/detail.scss @@ -0,0 +1,66 @@ +#club-detail { + img.club-logo { + display: block; + max-height: 200px; + max-width: 200px; + } + #club-attributes { + ul { + list-style: none; + margin-left: 0; + display: flex; + flex-direction: column; + gap: .75rem; + + li i { + margin-right: .5rem; + } + } + } + + &:not(.has-links) { + #club-attributes { + float: right; + margin: 1em 0 1em 2em; + + @media screen and (max-width: 650px) { + margin-left: 1em; + } + @media screen and (max-width: 400px) { + float: unset; + img.club-logo { + margin: auto; + } + } + } + } + + &.has-links { + display: flex; + flex-direction: row-reverse; + gap: 2em; + + @media screen and (max-width: 650px) { + flex-direction: column; + gap: 1em; + } + + #club-attributes { + display: flex; + flex-direction: column; + gap: 1em; + min-width: 200px; + @media screen and (max-width: 650px) { + margin-top: 1em; + flex-direction: row-reverse; + justify-content: flex-end; + h4 { + margin: 0; + } + img.club-logo { + margin-left: auto; + } + } + } + } +} \ No newline at end of file diff --git a/club/static/club/roles.scss b/club/static/club/roles.scss new file mode 100644 index 00000000..ea10e72e --- /dev/null +++ b/club/static/club/roles.scss @@ -0,0 +1,7 @@ +.fa-grip-vertical { + display: flex; + flex-direction: column; + justify-content: center; + cursor: pointer; + margin-right: .5em; +} \ No newline at end of file diff --git a/club/templates/club/club_detail.jinja b/club/templates/club/club_detail.jinja index 14a5a384..bc1a1c0b 100644 --- a/club/templates/club/club_detail.jinja +++ b/club/templates/club/club_detail.jinja @@ -21,15 +21,43 @@ {% endif %} {% endblock %} +{% block additional_css %} + +{% endblock %} + {% block content %} -
- {% if club.logo %} - - {% endif %} -

{{ club.name }}

- {% if page_revision %} - {{ page_revision|markdown }} - {% endif %} +

{{ club.name }}

+
+
+ {% if club.logo %} + + {% endif %} + {% if links %} + + {% endif %} +
+
+ {% if page_revision %} + {{ page_revision|markdown }} + {% endif %} +
{% endblock %} diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja index 8ae08bf3..e9e2cbd4 100644 --- a/club/templates/club/club_list.jinja +++ b/club/templates/club/club_list.jinja @@ -1,6 +1,13 @@ {% if is_fragment %} {% extends "core/base_fragment.jinja" %} + {% block metatags %} + + + + + {% endblock %} + {# Don't display tabs and errors #} {% block tabs %} {% endblock %} @@ -62,6 +69,18 @@ {{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %} + {% set links = club.links.all() %} + {% if links %} +
+
+ {% for link in club.links.all() %} + + + {{ link.name }} + + {% endfor %} +
+ {% endif %} {{ club.short_description|markdown }}
diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja index 7e37b04e..fcd4a6d8 100644 --- a/club/templates/club/club_members.jinja +++ b/club/templates/club/club_members.jinja @@ -12,6 +12,15 @@

{% trans %}Club members{% endtrans %}

+ {% if club.can_roles_be_edited_by(user) %} + + {% trans %}Manage roles{% endtrans %} + + {% endif %} + {% if add_member_fragment %}
{{ add_member_fragment }} @@ -41,7 +50,7 @@ {% for m in members %} {{ user_profile_link(m.user) }} - {{ settings.SITH_CLUB_ROLES[m.role] }} + {{ m.role.name }} {{ m.description }} {{ m.start_date }} {%- if can_end_membership -%} diff --git a/club/templates/club/club_old_members.jinja b/club/templates/club/club_old_members.jinja index 75603f9e..216a7437 100644 --- a/club/templates/club/club_old_members.jinja +++ b/club/templates/club/club_old_members.jinja @@ -17,7 +17,7 @@ {% for member in old_members %} {{ user_profile_link(member.user) }} - {{ settings.SITH_CLUB_ROLES[member.role] }} + {{ member.role.name }} {{ member.description }} {{ member.start_date }} {{ member.end_date }} diff --git a/club/templates/club/club_roles.jinja b/club/templates/club/club_roles.jinja new file mode 100644 index 00000000..ddc54b0c --- /dev/null +++ b/club/templates/club/club_roles.jinja @@ -0,0 +1,172 @@ +{% extends "core/base.jinja" %} + +{% block additional_js %} + +{% endblock %} + +{% block additional_css %} + +{% endblock %} + +{% macro display_subform(subform) %} +
+ {# hidden fields #} + {{ subform.ORDER }} + {{ subform.id }} + {{ subform.club }} + {{ subform.is_presidency|add_attr("x-model=isPresidency") }} + {{ subform.is_board|add_attr("x-model=isBoard") }} + +
+ + {{ subform.name.value() }} + {% if not subform.instance.is_active -%} + ({% trans %}inactive{% endtrans %}) + {%- endif %} + +
+ {{ subform.non_field_errors() }} +
+ {{ subform.name.as_field_group() }} +
+
+ {{ subform.description.as_field_group() }} +
+
+
+ {{ subform.is_active }} + {{ subform.is_active.label_tag() }} +
+ + {{ subform.is_active.help_text }} + +
+
+
+
+{% endmacro %} + +{% block content %} +

+ {% trans trimmed %} + Roles give rights on the club. + Higher roles grant more rights, and the members having them are displayed higher + in the club members list. + {% endtrans %} +

+

+ {% trans trimmed %} + On this page, you can edit their name and description, as well as their order. + You can also drag roles from a category to another + (e.g. a board role can be made into a presidency role). + {% endtrans %} +

+
+ {% csrf_token %} + {{ form.management_form }} + {{ form.non_form_errors() }} +

{% trans %}Presidency{% endtrans %}

+ + {% trans %}add role{% endtrans %} + +
+ {% trans %}Help{% endtrans %} + {# The style we use for markdown rendering is quite nice for what we want to display, + so we are just gonna reuse it. #} +
+

{% trans %}Users with a presidency role can :{% endtrans %}

+
    +
  • {% trans %}create new club roles and edit existing ones{% endtrans %}
  • +
  • {% trans %}manage the club counters{% endtrans %}
  • +
  • {% trans %}add new members with any active role and end any membership{% endtrans %}
  • +
+

{% trans %}They also have all the rights of the club board.{% endtrans %}

+
+
+
+ {% for subform in form %} + {% if subform.is_presidency.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+
+

{% trans %}Board{% endtrans %}

+ + {% trans %}add role{% endtrans %} + +
+ {% trans %}Help{% endtrans %} +
+

+ {% trans trimmed %} + Board members can do most administrative actions in the club, including : + {% endtrans %} +

+
    +
  • {% trans %}manage the club posters{% endtrans %}
  • +
  • {% trans %}create news for the club{% endtrans %}
  • +
  • {% trans %}click users on the club's counters{% endtrans %}
  • +
  • + {% trans trimmed %} + add new members and end active memberships + for roles that are lower than their own. + {% endtrans %} +
  • +
+
+
+
+ {% for subform in form %} + {% if subform.is_board.value() and not subform.is_presidency.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+
+

{% trans %}Members{% endtrans %}

+ + {% trans %}add role{% endtrans %} + +
+ {% trans %}Help{% endtrans %} +
+

{% trans %}Simple members cannot perform administrative actions.{% endtrans %}

+
+
+
+ {% for subform in form %} + {% if not subform.is_board.value() %} + {{ display_subform(subform) }} + {% endif %} + {% endfor %} +
+
+

+ +

+
+{% endblock content %} diff --git a/club/templates/club/club_tools.jinja b/club/templates/club/club_tools.jinja index 651cc9bb..a69d5c26 100644 --- a/club/templates/club/club_tools.jinja +++ b/club/templates/club/club_tools.jinja @@ -5,8 +5,19 @@

{% trans %}Communication:{% endtrans %}

    -
  • {% trans %}Create a news{% endtrans %}
  • -
  • {% trans %}Post in the Weekmail{% endtrans %}
  • +
  • + + {% trans %}Create a news{% endtrans %} + +
  • +
  • + + {% trans %}Post in the Weekmail{% endtrans %} + +
  • + {% if object.can_roles_be_edited_by(user) %} +
  • + {% endif %} {% if object.trombi %}
  • {% trans %}Edit Trombi{% endtrans %}
  • {% else %} diff --git a/club/templates/club/edit_club.jinja b/club/templates/club/edit_club.jinja index d70e2140..a8f4093e 100644 --- a/club/templates/club/edit_club.jinja +++ b/club/templates/club/edit_club.jinja @@ -1,9 +1,63 @@ {% extends "core/base.jinja" %} +{% block additional_js %} + +{% endblock %} + {% block title %} {% trans name=object %}Edit {{ name }}{% endtrans %} {% endblock %} +{% macro link_form(form) %} +
    + {{ form.non_field_errors() }} +
    +
    + {{ form.url.label_tag() }} + {{ form.url.errors }} + + {# we change the icon when the user change it and leave the input, + or when it is pasted from the clipboard #} + {{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }} + + +
    +
    {{ form.name.as_field_group() }}
    +
    + {%- if form.DELETE -%} +
    + {{ form.DELETE.as_field_group() }} +
    + {%- else -%} +
    + + {%- endif -%} + {{ form.link_type|add_attr(":value=linkType.id") }} + {%- for field in form.hidden_fields() -%} + {%- if field != form.link_type -%} + {{ field }} + {%- endif -%} + {%- endfor -%} +
    +{% endmacro %} + + {% block content %}

    {% trans name=object %}Edit {{ name }}{% endtrans %}

    @@ -17,7 +71,7 @@ and explicitly separate them from the non-admin ones, with some help text. Non-admin users will only see the regular form fields, - so they don't need thoses explanations #} + so they don't need those explanations #}

    {% trans %}Club properties{% endtrans %}

    {% trans trimmed %} @@ -25,7 +79,7 @@ Only admin users can see and edit them. {% endtrans %}

    -
    +
    {% for field_name in form.admin_fields %} {% set field = form[field_name] %}
    @@ -36,11 +90,13 @@ {# Remove the the admin fields from the form. The remaining non-admin fields will be rendered at once with a simple {{ form.as_p() }} #} - {% set _ = form.fields.pop(field_name) %} + {% do form.fields.pop(field_name) %} {% endfor %}
    + {% endif %} -

    {% trans %}Club informations{% endtrans %}

    +

    {% trans %}Club informations{% endtrans %}

    + {% if form.admin_fields %}

    {% trans trimmed %} The following form fields are linked to the basic description of a club. @@ -48,7 +104,45 @@ {% endtrans %}

    {% endif %} - {{ form.as_p() }} -

    +
    + {{ form.as_p() }} +
    + +

    {% trans %}Club links{% endtrans %}

    +
    + {{ form.link_formset.management_form }} +
    + {%- for f in form.link_formset.forms -%} + {{ link_form(f) }} + {%- endfor -%} +
    + +

    + {% trans trimmed %} + Note: if the icon of one of your links doesn't exist yet, + you can ask the info team to add it. + {% endtrans %} +

    +
    + +
    +
    + {% endblock content %} + +{% block script %} + +{% endblock %} diff --git a/club/tests/base.py b/club/tests/base.py index ca4fc6cf..52e04fca 100644 --- a/club/tests/base.py +++ b/club/tests/base.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from model_bakery import baker from model_bakery.recipe import Recipe -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import User @@ -43,6 +43,11 @@ class TestClub(TestCase): cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) cls.club = baker.make(Club) + cls.president_role = baker.make( + ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0 + ) + cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1) + cls.member_role = baker.make(ClubRole, club=cls.club, order=2) cls.new_members_url = reverse( "club:club_new_members", kwargs={"club_id": cls.club.id} ) @@ -51,12 +56,17 @@ class TestClub(TestCase): yesterday = now() - timedelta(days=1) membership_recipe = Recipe(Membership, club=cls.club) membership_recipe.make( - user=cls.simple_board_member, start_date=a_month_ago, role=3 + user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role + ) + membership_recipe.make(user=cls.richard, role=cls.member_role) + membership_recipe.make( + user=cls.president, start_date=a_month_ago, role=cls.president_role ) - membership_recipe.make(user=cls.richard, role=1) - membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10) membership_recipe.make( # sli was a member but isn't anymore - user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2 + user=cls.sli, + start_date=a_month_ago, + end_date=yesterday, + role=cls.board_role, ) def setUp(self): diff --git a/club/tests/test_club.py b/club/tests/test_club.py index 4c69b2c4..e64b4ce2 100644 --- a/club/tests/test_club.py +++ b/club/tests/test_club.py @@ -1,13 +1,16 @@ from datetime import timedelta import pytest +from django.conf import settings +from django.db import ProgrammingError from django.test import Client from django.urls import reverse from django.utils.timezone import localdate from model_bakery import baker from model_bakery.recipe import Recipe +from pytest_django.asserts import assertRedirects -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import User @@ -19,11 +22,19 @@ def test_club_queryset_having_board_member(): membership_recipe = Recipe( Membership, user=user, start_date=localdate() - timedelta(days=3) ) - membership_recipe.make(club=clubs[0], role=1) - membership_recipe.make(club=clubs[1], role=3) - membership_recipe.make(club=clubs[2], role=7) membership_recipe.make( - club=clubs[3], role=3, end_date=localdate() - timedelta(days=1) + club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False) + ) + membership_recipe.make( + club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True) + ) + membership_recipe.make( + club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True) + ) + membership_recipe.make( + club=clubs[3], + role=baker.make(ClubRole, club=clubs[3], is_board=True), + end_date=localdate() - timedelta(days=1), ) club_ids = Club.objects.having_board_member(user).values_list("id", flat=True) @@ -39,3 +50,78 @@ def test_club_list(client: Client, nb_additional_clubs: int, is_fragment): headers = {"HX-Request": True} if is_fragment else {} res = client.get(reverse("club:club_list"), headers=headers) assert res.status_code == 200 + + +def assert_club_created(club_name: str): + club = Club.objects.last() + assert club.name == club_name + assert club.board_group.name == f"{club_name} - Bureau" + assert club.members_group.name == f"{club_name} - Membres" + # default roles should be added on club creation, + # whether the creation happens on the admin site or on the user site + assert list(club.roles.values("name", "is_presidency", "is_board")) == [ + {"name": "Président⸱e", "is_presidency": True, "is_board": True}, + {"name": "Trésorier⸱e", "is_presidency": False, "is_board": True}, + {"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False}, + {"name": "Curieux⸱euse", "is_presidency": False, "is_board": False}, + ] + + +@pytest.mark.django_db +def test_create_view(admin_client: Client): + """Test that the club creation view works well""" + res = admin_client.get(reverse("club:club_new")) + assert res.status_code == 200 + res = admin_client.post( + reverse("club:club_new"), + data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID}, + ) + club = Club.objects.last() + assertRedirects(res, club.get_absolute_url()) + assert_club_created("foo") + + +@pytest.mark.django_db +def test_default_roles_for_club_with_roles_fails(): + """Test that an Error is raised if trying to create + default roles for a club that already has roles. + """ + club = baker.make(Club) + baker.make(ClubRole, club=club) + with pytest.raises(ProgrammingError): + club.create_default_roles() + + +@pytest.mark.django_db +class TestAdminInterface: + def test_create(self, admin_client: Client): + """Test the creation of a club via the admin interface.""" + res = admin_client.post( + reverse("admin:club_club_add"), + data={ + "name": "foo", + "parent": settings.SITH_MAIN_CLUB_ID, + "address": "Rome", + }, + ) + assertRedirects(res, reverse("admin:club_club_changelist")) + assert_club_created("foo") + + def test_change(self, admin_client: Client): + """Test the edition of a club via the admin interface.""" + club = baker.make(Club) + res = admin_client.post( + reverse("admin:club_club_change", kwargs={"object_id": club.id}), + data={ + "name": "foo", + "page": club.page_id, + "home": club.home_id, + "address": club.address, + }, + ) + assertRedirects(res, reverse("admin:club_club_changelist")) + club.refresh_from_db() + assert club.name == "foo" + # Club roles shouldn't be modified when editing the club on the admin interface + # This club had no roles beforehand, therefore it shouldn't have roles now. + assert not club.roles.exists() diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py index 500dbe6a..18d09020 100644 --- a/club/tests/test_club_controller.py +++ b/club/tests/test_club_controller.py @@ -1,6 +1,7 @@ from datetime import date, timedelta import pytest +from django.conf import settings from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse @@ -8,7 +9,7 @@ from model_bakery import baker from model_bakery.recipe import Recipe from pytest_django.asserts import assertNumQueries -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.models import Group, Page, User @@ -26,8 +27,10 @@ class TestClubSearch(TestCase): "id", flat=True ) ) - Page.objects.exclude(club=None).delete() + Membership.objects.all().delete() + ClubRole.objects.all().delete() Club.objects.all().delete() + Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete() Group.objects.filter(id__in=groups).delete() cls.clubs = baker.make( @@ -85,8 +88,8 @@ class TestFetchClub: def test_fetch_club_nb_queries(self, client: Client, club: Club): user = subscriber_user.make() client.force_login(user) - with assertNumQueries(6): + with assertNumQueries(7): # - 4 queries for authentication - # - 2 queries for the actual data + # - 3 queries for the actual data res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id})) assert res.status_code == 200 diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py new file mode 100644 index 00000000..0173c4d6 --- /dev/null +++ b/club/tests/test_clubrole.py @@ -0,0 +1,253 @@ +from collections.abc import Callable + +import pytest +from django.contrib.auth.models import Permission +from django.test import Client, TestCase +from django.urls import reverse +from model_bakery import baker, seq +from model_bakery.recipe import Recipe +from pytest_django.asserts import assertRedirects + +from club.forms import ClubRoleFormSet +from club.models import Club, ClubRole, Membership +from core.baker_recipes import subscriber_user +from core.models import AnonymousUser, User + + +def make_club(): + # unittest-style tests cannot use fixture, so we create a function + # that will be callable either by a pytest fixture or inside + # a TestCase.setUpTestData method. + club = baker.make(Club) + recipe = Recipe(ClubRole, club=club, name=seq("role ")) + recipe.make( + is_board=iter([True, True, False]), + is_presidency=iter([True, False, False]), + order=iter([0, 1, 2]), + _quantity=3, + _bulk_create=True, + ) + return club + + +@pytest.fixture +def club(db): + """A club with a presidency role, a board role and a member role""" + return make_club() + + +@pytest.mark.django_db +def test_order_auto(club): + """Test that newly created roles are put in the right place.""" + roles = list(club.roles.all()) + # create new roles one by one (like they will be in prod) + # each new role should be placed at the end of its category + recipe = Recipe(ClubRole, club=club, name=seq("new role ")) + role_a = recipe.make(is_board=True, is_presidency=True, order=None) + role_b = recipe.make(is_board=True, is_presidency=False, order=None) + role_c = recipe.make(is_board=False, is_presidency=False, order=None) + assert list(club.roles.order_by("order")) == [ + roles[0], + role_a, + roles[1], + role_b, + roles[2], + role_c, + ] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "is_allowed"), + [ + ( + lambda club: baker.make( + User, + user_permissions=[Permission.objects.get(codename="change_clubrole")], + ), + True, + ), + ( # user with presidency roles can edit the club roles + lambda club: subscriber_user.make( + memberships=[ + baker.make( + Membership, + club=club, + role=club.roles.filter(is_presidency=True).first(), + ) + ] + ), + True, + ), + ( # user in the board but not in the presidency cannot edit roles + lambda club: subscriber_user.make( + memberships=[ + baker.make( + Membership, + club=club, + role=club.roles.filter( + is_presidency=False, is_board=True + ).first(), + ) + ] + ), + False, + ), + (lambda _: AnonymousUser(), False), + ], +) +def test_can_roles_be_edited_by( + club: Club, user_factory: Callable[[Club], User], is_allowed +): + """Test that `Club.can_roles_be_edited_by` return the right value""" + user = user_factory(club) + assert club.can_roles_be_edited_by(user) == is_allowed + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ["route", "is_presidency", "is_board"], + [ + ("club:new_role_president", True, True), + ("club:new_role_board", False, True), + ("club:new_role_member", False, False), + ], +) +def test_create_role_view(client: Client, route: str, is_presidency, is_board): + """Test that the role creation views work.""" + club = baker.make(Club) + role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True) + user = subscriber_user.make() + baker.make(Membership, club=club, role=role, user=user, end_date=None) + url = reverse(route, kwargs={"club_id": club.id}) + client.force_login(user) + + res = client.get(url) + assert res.status_code == 200 + + res = client.post(url, data={"name": "foo"}) + assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id})) + new_role = club.roles.last() + assert new_role.name == "foo" + assert new_role.is_presidency == is_presidency + assert new_role.is_board == is_board + + +class TestClubRoleUpdate(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = make_club() + cls.roles = list(cls.club.roles.all()) + cls.user = subscriber_user.make() + baker.make( + Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None + ) + cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id}) + cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id}) + + def setUp(self): + self.payload = { + "roles-TOTAL_FORMS": 3, + "roles-INITIAL_FORMS": 3, + "roles-MIN_NUM_FORMS": 0, + "roles-MAX_NUM_FORMS": 1000, + "roles-0-ORDER": 1, + "roles-0-id": self.roles[0].id, + "roles-0-club": self.club.id, + "roles-0-is_presidency": True, + "roles-0-is_board": True, + "roles-0-name": self.roles[0].name, + "roles-0-description": self.roles[0].description, + "roles-0-is_active": True, + "roles-1-ORDER": 2, + "roles-1-id": self.roles[1].id, + "roles-1-club": self.club.id, + "roles-1-is_presidency": False, + "roles-1-is_board": True, + "roles-1-name": self.roles[1].name, + "roles-1-description": self.roles[1].description, + "roles-1-is_active": True, + "roles-2-ORDER": 3, + "roles-2-id": self.roles[2].id, + "roles-2-club": self.club.id, + "roles-2-is_presidency": False, + "roles-2-is_board": False, + "roles-2-name": self.roles[2].name, + "roles-2-description": self.roles[2].description, + "roles-2-is_active": True, + } + + def test_view_ok(self): + """Basic test to check that the view works.""" + self.client.force_login(self.user) + res = self.client.get(self.url) + assert res.status_code == 200 + self.payload["roles-2-name"] = "foo" + res = self.client.post(self.url, data=self.payload) + assertRedirects(res, self.redirect_url) + self.roles[2].refresh_from_db() + assert self.roles[2].name == "foo" + + def test_incoherent_order(self): + """Test that placing a member role over a board role fails.""" + self.payload["roles-0-ORDER"] = 4 + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert not formset.is_valid() + assert formset.errors == [ + { + "__all__": [ + f"Le rôle {self.roles[0].name} ne peut pas " + "être placé en-dessous d'un rôle de membre.", + f"Le rôle {self.roles[0].name} ne peut pas être placé " + "en-dessous d'un rôle qui n'est pas de la présidence.", + ] + }, + {}, + {}, + ] + + def test_change_order_ok(self): + """Test that changing order the intended way works""" + self.payload["roles-1-ORDER"] = 3 + self.payload["roles-1-is_board"] = False + self.payload["roles-2-ORDER"] = 2 + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + assert list(self.club.roles.order_by("order")) == [ + self.roles[0], + self.roles[2], + self.roles[1], + ] + self.roles[1].refresh_from_db() + assert not self.roles[1].is_board + + def test_non_board_presidency_is_forbidden(self): + """Test that a role cannot be in the presidency without being in the board.""" + self.payload["roles-0-is_board"] = False + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert not formset.is_valid() + assert formset.errors == [ + { + "__all__": [ + "Un rôle ne peut pas appartenir à la présidence sans être dans le bureau", + ] + }, + {}, + {}, + ] + + def test_president_moves_itself_out_of_the_presidency(self): + """Test that if the user moves its own role out of the presidency, + then it's redirected to another page and loses access to the update page.""" + self.payload["roles-0-is_presidency"] = False + self.client.force_login(self.user) + res = self.client.post(self.url, data=self.payload) + assertRedirects(res, self.redirect_url) + # When the user clicked that button, it still had the right to update roles, + # so the modification should be applied + self.roles[0].refresh_from_db() + assert self.roles[0].is_presidency is False + + res = self.client.get(self.url) + assert res.status_code == 403 diff --git a/club/tests/test_edit.py b/club/tests/test_edit.py index 9da327ea..c8a03b61 100644 --- a/club/tests/test_edit.py +++ b/club/tests/test_edit.py @@ -4,7 +4,7 @@ from django.urls import reverse from model_bakery import baker from pytest_django.asserts import assertRedirects -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user @@ -12,11 +12,22 @@ from core.baker_recipes import subscriber_user def test_club_board_member_cannot_edit_club_properties(client: Client): user = subscriber_user.make() club = baker.make(Club, name="old name", is_active=True, address="old address") - baker.make(Membership, club=club, user=user, role=7) + baker.make( + Membership, + club=club, + user=user, + role=baker.make(ClubRole, club=club, is_board=True), + ) client.force_login(user) res = client.post( reverse("club:club_edit", kwargs={"club_id": club.id}), - {"name": "new name", "is_active": False, "address": "new address"}, + { + "name": "new name", + "is_active": False, + "address": "new address", + "link-TOTAL_FORMS": 0, + "link-INITIAL_FORMS": 0, + }, ) # The request should success, # but admin-only fields shouldn't be taken into account @@ -32,7 +43,12 @@ def test_edit_club_page_doesnt_crash(client: Client): """crash test for club:club_edit""" club = baker.make(Club) user = subscriber_user.make() - baker.make(Membership, club=club, user=user, role=3) + baker.make( + Membership, + club=club, + user=user, + role=baker.make(ClubRole, club=club, is_board=True), + ) client.force_login(user) res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id})) assert res.status_code == 200 diff --git a/club/tests/test_mailing.py b/club/tests/test_mailing.py index e8ea3a9b..2217281c 100644 --- a/club/tests/test_mailing.py +++ b/club/tests/test_mailing.py @@ -3,9 +3,10 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ +from model_bakery import baker from club.forms import MailingForm -from club.models import Club, Mailing, Membership +from club.models import Club, ClubRole, Mailing, Membership from core.models import User @@ -25,7 +26,7 @@ class TestMailingForm(TestCase): user=cls.rbatsbak, club=cls.club, start_date=timezone.now(), - role=settings.SITH_CLUB_ROLES_ID["Board member"], + role=baker.make(ClubRole, club=cls.club, is_board=True), ).save() def test_mailing_list_add_no_moderation(self): diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py index e24fe9d0..285b50f4 100644 --- a/club/tests/test_membership.py +++ b/club/tests/test_membership.py @@ -1,20 +1,20 @@ +import itertools from collections.abc import Callable from datetime import timedelta import pytest 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 Client, TestCase from django.urls import reverse from django.utils.timezone import localdate, localtime, now -from model_bakery import baker +from model_bakery import baker, seq from pytest_django.asserts import assertRedirects from club.forms import ClubAddMemberForm, JoinClubForm -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from club.tests.base import TestClub from core.baker_recipes import subscriber_user from core.models import AnonymousUser, User @@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub): 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) + board_role, member_role = baker.make( + ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True + ) + membership = baker.make( + Membership, end_date=None, user=user, role=board_role, club=board_role.club + ) 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 + user.memberships.update(role=member_role) # 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 + user.memberships.update(role=board_role) # from member to board assert user.groups.contains(members_group) assert user.groups.contains(board_group) @@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub): 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 = baker.make(Club) + roles = baker.make( + ClubRole, + is_board=iter([False, True]), + club=club, + _quantity=2, + _bulk_create=True, + ) + memberships = baker.make( + Membership, club=club, role=iter(roles), user=user, _quantity=2 + ) club_groups = { memberships[0].club.members_group, memberships[1].club.members_group, @@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase): def setUpTestData(cls): Membership.objects.all().delete() cls.club_a, cls.club_b = baker.make(Club, _quantity=2) + roles = baker.make( + ClubRole, + is_presidency=itertools.cycle([True, False, False, False]), + is_board=itertools.cycle([True, True, True, False]), + order=itertools.cycle(range(4)), + club=iter( + [*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)] + ), + _quantity=8, + _bulk_create=True, + ) 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 - ), + *baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4), + *baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4), ] def test_admin_user(self): @@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase): class TestMembership(TestClub): - def assert_membership_started_today(self, user: User, role: int): + def assert_membership_started_today(self, user: User, role: ClubRole): """Assert that the given membership is active and started today.""" membership = user.memberships.ongoing().filter(club=self.club).first() assert membership is not None @@ -189,21 +211,27 @@ class TestMembership(TestClub): "Marquer comme ancien", ] rows = table.find("tbody").find_all("tr") - memberships = self.club.members.ongoing().order_by("-role") - for row, membership in zip( - rows, memberships.select_related("user"), strict=False - ): + memberships = ( + self.club.members.ongoing() + .order_by("role__order") + .select_related("user", "role") + ) + user_role = ClubRole.objects.get(members__user=self.simple_board_member) + for row, membership in zip(rows, memberships, strict=False): user = membership.user user_url = reverse("core:user_profile", args=[user.id]) cols = row.find_all("td") user_link = cols[0].find("a") assert user_link.attrs["href"] == user_url assert user_link.text == user.get_display_name() - assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role] + assert cols[1].text == membership.role.name assert cols[2].text == membership.description assert cols[3].text == str(membership.start_date) - if membership.role < 3 or membership.user_id == self.simple_board_member.id: + if ( + membership.role.order > user_role.order + or membership.user_id == self.simple_board_member.id + ): # 3 is the role of simple_board_member form_input = cols[4].find("input") expected_attrs = { @@ -219,14 +247,15 @@ class TestMembership(TestClub): """Test that root users can add members to clubs""" self.client.force_login(self.root) response = self.client.post( - self.new_members_url, {"user": self.subscriber.id, "role": 3} + self.new_members_url, + {"user": self.subscriber.id, "role": self.board_role.id}, ) assert response.status_code == 200 assert response.headers.get("HX-Redirect", "") == reverse( "club:club_members", kwargs={"club_id": self.club.id} ) self.subscriber.refresh_from_db() - self.assert_membership_started_today(self.subscriber, role=3) + self.assert_membership_started_today(self.subscriber, role=self.board_role) def test_add_unauthorized_members(self): """Test that users who are not currently subscribed @@ -234,7 +263,7 @@ class TestMembership(TestClub): """ for user in self.public, self.old_subscriber: form = ClubAddMemberForm( - data={"user": user.id, "role": 1}, + data={"user": user.id, "role": self.member_role}, request_user=self.root, club=self.club, ) @@ -255,7 +284,7 @@ class TestMembership(TestClub): nb_memberships = self.simple_board_member.memberships.count() self.client.post( self.members_url, - {"users": self.simple_board_member.id, "role": current_membership.role + 1}, + {"users": self.simple_board_member.id, "role": self.member_role}, ) self.simple_board_member.refresh_from_db() assert nb_memberships == self.simple_board_member.memberships.count() @@ -274,7 +303,7 @@ class TestMembership(TestClub): max_id = User.objects.aggregate(id=Max("id"))["id"] for members in [max_id + 1], [max_id + 1, self.subscriber.id]: form = ClubAddMemberForm( - data={"user": members, "role": 1}, + data={"user": members, "role": self.member_role}, request_user=self.root, club=self.club, ) @@ -290,12 +319,13 @@ class TestMembership(TestClub): def test_president_add_members(self): """Test that the president of the club can add members.""" - president = self.club.members.get(role=10).user + president = self.club.members.get(role=self.president_role).user nb_club_membership = self.club.members.count() nb_subscriber_memberships = self.subscriber.memberships.count() self.client.force_login(president) response = self.client.post( - self.new_members_url, {"user": self.subscriber.id, "role": 9} + self.new_members_url, + {"user": self.subscriber.id, "role": self.president_role.id}, ) assert response.status_code == 200 assert response.headers.get("HX-Redirect", "") == reverse( @@ -305,14 +335,17 @@ class TestMembership(TestClub): self.subscriber.refresh_from_db() assert self.club.members.count() == nb_club_membership + 1 assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1 - self.assert_membership_started_today(self.subscriber, role=9) + self.assert_membership_started_today(self.subscriber, role=self.president_role) def test_add_member_greater_role(self): """Test that a member of the club member cannot create a membership with a greater role than its own. """ + user_role = self.simple_board_member.memberships.first().role + other_role = baker.make(ClubRole, club=user_role.club, is_board=True) + other_role.above(user_role) form = ClubAddMemberForm( - data={"user": self.subscriber.id, "role": 10}, + data={"user": self.subscriber.id, "role": other_role.id}, request_user=self.simple_board_member, club=self.club, ) @@ -320,7 +353,10 @@ class TestMembership(TestClub): assert not form.is_valid() assert form.errors == { - "role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."] + "role": [ + "Sélectionnez un choix valide. " + "Ce choix ne fait pas partie de ceux disponibles." + ] } self.club.refresh_from_db() assert nb_memberships == self.club.members.count() @@ -336,8 +372,9 @@ class TestMembership(TestClub): assert form.errors == {"role": ["Ce champ est obligatoire."]} def test_add_member_already_there(self): + role = ClubRole.objects.get(members__user=self.simple_board_member) form = ClubAddMemberForm( - data={"user": self.simple_board_member, "role": 3}, + data={"user": self.simple_board_member, "role": role.id}, request_user=self.root, club=self.club, ) @@ -348,22 +385,27 @@ class TestMembership(TestClub): def test_add_other_member_forbidden(self): non_member = subscriber_user.make() - simple_member = baker.make(Membership, club=self.club, role=1).user + simple_member = baker.make( + Membership, club=self.club, role=self.member_role + ).user for user in non_member, simple_member: form = ClubAddMemberForm( - data={"user": subscriber_user.make(), "role": 1}, + data={"user": subscriber_user.make(), "role": self.member_role.id}, request_user=user, club=self.club, ) assert not form.is_valid() assert form.errors == { - "role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."] + "role": [ + "Sélectionnez un choix valide. " + "Ce choix ne fait pas partie de ceux disponibles." + ] } def test_simple_members_dont_see_form_anymore(self): """Test that simple club members don't see the form to add members""" user = subscriber_user.make() - baker.make(Membership, club=self.club, user=user, role=1) + baker.make(Membership, club=self.club, user=user, role=self.member_role) self.client.force_login(user) res = self.client.get(self.members_url) assert res.status_code == 200 @@ -382,9 +424,10 @@ class TestMembership(TestClub): """Test that board members of the club can end memberships of users with lower roles. """ - # reminder : simple_board_member has role 3 self.client.force_login(self.simple_board_member) - membership = baker.make(Membership, club=self.club, role=2, end_date=None) + role = baker.make(ClubRole, club=self.club, is_board=True) + role.below(self.board_role) + membership = baker.make(Membership, club=self.club, role=role) response = self.client.post(self.members_url, {"members_old": [membership.id]}) self.assertRedirects(response, self.members_url) self.club.refresh_from_db() @@ -394,7 +437,9 @@ class TestMembership(TestClub): """Test that board members of the club cannot end memberships of users with higher roles. """ - membership = self.president.memberships.filter(club=self.club).first() + membership = self.president.memberships.filter( + club=self.club, end_date=None + ).first() self.client.force_login(self.simple_board_member) self.client.post(self.members_url, {"members_old": [membership.id]}) self.club.refresh_from_db() @@ -436,7 +481,9 @@ class TestMembership(TestClub): def test_remove_from_club_group(self): """Test that when a membership ends, the user is removed from club groups.""" user = baker.make(User) - baker.make(Membership, user=user, club=self.club, end_date=None, role=3) + baker.make( + Membership, user=user, club=self.club, end_date=None, role=self.board_role + ) assert user.groups.contains(self.club.members_group) assert user.groups.contains(self.club.board_group) user.memberships.update(end_date=localdate()) @@ -447,18 +494,20 @@ class TestMembership(TestClub): """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.board_group) - baker.make(Membership, club=self.club, user=self.subscriber, role=3) + baker.make( + Membership, club=self.club, user=self.subscriber, role=self.board_role + ) assert self.subscriber.groups.contains(self.club.members_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 + Membership, club=self.club, user=self.subscriber, role=self.board_role ) assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.board_group) - membership.role = 1 + membership.role = self.member_role membership.save() assert self.subscriber.groups.contains(self.club.members_group) assert not self.subscriber.groups.contains(self.club.board_group) @@ -471,7 +520,11 @@ class TestMembership(TestClub): # make sli a board member self.sli.memberships.all().delete() - Membership(club=self.ae, user=self.sli, role=3).save() + Membership( + club=self.ae, + user=self.sli, + role=baker.make(ClubRole, club=self.ae, is_board=True), + ).save() assert self.club.is_owned_by(self.sli) def test_change_club_name(self): @@ -497,7 +550,7 @@ class TestMembership(TestClub): @pytest.mark.django_db def test_membership_set_old(client: Client): - membership = baker.make(Membership, end_date=None, user=(subscriber_user.make())) + membership = baker.make(Membership, end_date=None, user=subscriber_user.make()) client.force_login(membership.user) response = client.post( reverse("club:membership_set_old", kwargs={"membership_id": membership.id}) @@ -524,6 +577,50 @@ def test_membership_delete(client: Client): assert not Membership.objects.filter(id=membership.id).exists() +@pytest.mark.django_db +class TestAddMemberForm(TestCase): + @classmethod + def setUpTestData(cls): + cls.club = baker.make(Club) + cls.roles = baker.make( + ClubRole, + club=cls.club, + is_board=iter([True, True, True, True, False, False]), + is_presidency=iter([True, True, False, False, False, False]), + order=seq(0), + _quantity=6, + _bulk_create=True, + ) + cls.roles[-1].is_active = False + cls.roles[-1].save() + + def test_admin(self): + """Test that admin users can give any active role.""" + user = baker.make( + User, user_permissions=[Permission.objects.get(codename="add_membership")] + ) + form = ClubAddMemberForm(request_user=user, club=self.club) + assert list(form.fields["role"].queryset) == self.roles[:-1] + + def test_president(self): + """Test that someone with a presidency role can give any active role.""" + user = baker.make(Membership, club=self.club, role=self.roles[0]).user + form = ClubAddMemberForm(request_user=user, club=self.club) + assert list(form.fields["role"].queryset) == self.roles[:-1] + + def test_board_member(self): + """Test that someone with a board role can give lower active role.""" + user = baker.make(Membership, club=self.club, role=self.roles[2]).user + form = ClubAddMemberForm(request_user=user, club=self.club) + assert list(form.fields["role"].queryset) == self.roles[3:-1] + + def test_simple_member(self): + """Test that someone with a non-board role cannot give roles.""" + user = baker.make(Membership, club=self.club, role=self.roles[4]).user + form = ClubAddMemberForm(request_user=user, club=self.club) + assert list(form.fields["role"].queryset) == [] + + @pytest.mark.django_db class TestJoinClub: @pytest.fixture(autouse=True) @@ -531,55 +628,64 @@ class TestJoinClub: cache.clear() @pytest.mark.parametrize( - ("user_factory", "role", "errors"), + ("user_factory", "board_role", "errors"), [ ( subscriber_user.make, - 2, + True, { "role": [ - "Sélectionnez un choix valide. 2 n\u2019en fait pas partie." + "Sélectionnez un choix valide. " + "Ce choix ne fait pas partie de ceux disponibles." ] }, ), ( lambda: baker.make(User), - 1, + False, {"__all__": ["Vous devez être cotisant pour faire partie d'un club"]}, ), ], ) def test_join_club_errors( - self, user_factory: Callable[[], User], role: int, errors: dict + self, user_factory: Callable[[], User], board_role, errors: dict ): club = baker.make(Club) user = user_factory() - form = JoinClubForm(club=club, request_user=user, data={"role": role}) + role = baker.make(ClubRole, club=club, is_board=board_role) + form = JoinClubForm(club=club, request_user=user, data={"role": role.id}) assert not form.is_valid() assert form.errors == errors def test_user_already_in_club(self): - club = baker.make(Club) user = subscriber_user.make() - baker.make(Membership, user=user, club=club) - form = JoinClubForm(club=club, request_user=user, data={"role": 1}) + role = baker.make(ClubRole, is_board=False) + baker.make(Membership, user=user, club=role.club) + form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id}) assert not form.is_valid() assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]} def test_ok(self): - club = baker.make(Club) user = subscriber_user.make() - form = JoinClubForm(club=club, request_user=user, data={"role": 1}) + role = baker.make(ClubRole, is_board=False) + form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id}) assert form.is_valid() form.save() - assert Membership.objects.ongoing().filter(user=user, club=club).exists() + assert Membership.objects.ongoing().filter(user=user, club=role.club).exists() class TestOldMembersView(TestCase): @classmethod def setUpTestData(cls): club = baker.make(Club) - roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10] + roles = baker.make( + ClubRole, + club=club, + is_board=itertools.cycle([True, True, False]), + order=seq(0), + _quantity=10, + _bulk_create=True, + ) cls.memberships = baker.make( Membership, role=iter(roles), @@ -604,3 +710,11 @@ class TestOldMembersView(TestCase): self.client.force_login(baker.make(User)) res = self.client.get(self.url) assert res.status_code == 403 + + def test_context_data(self): + # mark a membership as not ended, to make sure it is excluded from the result + self.memberships[0].end_date = None + self.memberships[0].save() + self.client.force_login(subscriber_user.make()) + res = self.client.get(self.url) + assert list(res.context_data.get("old_members")) == self.memberships[1:] diff --git a/club/tests/test_page.py b/club/tests/test_page.py index c368735d..6567a690 100644 --- a/club/tests/test_page.py +++ b/club/tests/test_page.py @@ -5,7 +5,7 @@ from django.urls import reverse from model_bakery import baker from pytest_django.asserts import assertHTMLEqual, assertRedirects -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.baker_recipes import subscriber_user from core.markdown import markdown from core.models import PageRev, User @@ -21,7 +21,7 @@ def test_page_display_on_club_main_page(client: Client): assert res.status_code == 200 soup = BeautifulSoup(res.text, "lxml") - detail_html = soup.find(id="club_detail").find(class_="markdown") + detail_html = soup.find(id="club-page").find(class_="markdown") assertHTMLEqual(detail_html.decode_contents(), markdown(content)) @@ -34,7 +34,7 @@ def test_club_main_page_without_content(client: Client): assert res.status_code == 200 soup = BeautifulSoup(res.text, "lxml") - detail_html = soup.find(id="club_detail") + detail_html = soup.find(id="club-page") assert detail_html.find_all("markdown") == [] @@ -59,7 +59,12 @@ def test_page_revision(client: Client): def test_edit_page(client: Client): club = baker.make(Club) user = subscriber_user.make() - baker.make(Membership, user=user, club=club, role=3) + baker.make( + Membership, + user=user, + club=club, + role=baker.make(ClubRole, club=club, is_board=True), + ) client.force_login(user) url = reverse("club:club_edit_page", kwargs={"club_id": club.id}) content = "# foo\nLorem ipsum dolor sit amet" diff --git a/club/tests/test_user_club_controller.py b/club/tests/test_user_club_controller.py index 2aba7225..dd851d14 100644 --- a/club/tests/test_user_club_controller.py +++ b/club/tests/test_user_club_controller.py @@ -6,7 +6,7 @@ from django.utils.timezone import localdate from model_bakery import baker from model_bakery.recipe import Recipe -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from club.schemas import UserMembershipSchema from core.baker_recipes import subscriber_user from core.models import Page @@ -19,7 +19,10 @@ class TestFetchClub(TestCase): pages = baker.make(Page, _quantity=3, _bulk_create=True) clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True) recipe = Recipe( - Membership, user=cls.user, start_date=localdate() - timedelta(days=2) + Membership, + user=cls.user, + start_date=localdate() - timedelta(days=2), + role=baker.make(ClubRole), ) cls.members = Membership.objects.bulk_create( [ diff --git a/club/urls.py b/club/urls.py index e33c2a26..6a08bbe5 100644 --- a/club/urls.py +++ b/club/urls.py @@ -35,6 +35,10 @@ from club.views import ( ClubPageEditView, ClubPageHistView, ClubRevView, + ClubRoleBoardCreateView, + ClubRoleMemberCreateView, + ClubRolePresidencyCreateView, + ClubRoleUpdateView, ClubSellingCSVView, ClubSellingView, ClubToolsView, @@ -71,6 +75,22 @@ urlpatterns = [ ClubOldMembersView.as_view(), name="club_old_members", ), + path("/role/", ClubRoleUpdateView.as_view(), name="club_roles"), + path( + "/role/new/president/", + ClubRolePresidencyCreateView.as_view(), + name="new_role_president", + ), + path( + "/role/new/board/", + ClubRoleBoardCreateView.as_view(), + name="new_role_board", + ), + path( + "/role/new/member/", + ClubRoleMemberCreateView.as_view(), + name="new_role_member", + ), path("/sellings/", ClubSellingView.as_view(), name="club_sellings"), path( "/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" diff --git a/club/views.py b/club/views.py index 6a9963a9..6f2c7716 100644 --- a/club/views.py +++ b/club/views.py @@ -28,12 +28,16 @@ import csv import itertools from typing import TYPE_CHECKING, Any -from django.conf import settings -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import ( + LoginRequiredMixin, + PermissionRequiredMixin, + UserPassesTestMixin, +) from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.paginator import InvalidPage, Paginator -from django.db.models import F, Q, Sum +from django.db.models import F, Prefetch, Q, Sum +from django.db.models.functions import Length from django.http import Http404, StreamingHttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy @@ -44,24 +48,29 @@ from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView, View from django.views.generic.detail import SingleObjectMixin -from django.views.generic.edit import ( - CreateView, - DeleteView, - FormMixin, - UpdateView, -) +from django.views.generic.edit import CreateView, DeleteView, FormMixin, UpdateView from club.forms import ( ClubAddMemberForm, ClubAdminEditForm, ClubEditForm, ClubOldMemberForm, + ClubRoleCreateForm, + ClubRoleFormSet, ClubSearchForm, JoinClubForm, MailingForm, SellingsForm, ) -from club.models import Club, Mailing, MailingSubscription, Membership +from club.models import ( + Club, + ClubLink, + ClubRole, + LinkType, + Mailing, + MailingSubscription, + Membership, +) from com.models import Poster from com.views import ( PosterCreateBaseView, @@ -205,20 +214,22 @@ class ClubListView(AllowFragment, FormMixin, ListView): template_name = "club/club_list.jinja" form_class = ClubSearchForm - queryset = Club.objects.order_by("name") + queryset = Club.objects.prefetch_related( + Prefetch("links", queryset=ClubLink.objects.select_related("link_type")) + ).order_by("name") paginate_by = 20 def get_form_kwargs(self): res = super().get_form_kwargs() - if self.request.method == "GET": - res |= {"data": self.request.GET, "initial": self.request.GET} + # if request.GET is empty, the form will interpret club_status as None, + # even though we want it to be initially True, + # so we force a defaut True value. + res["data"] = {"club_status": True} | self.request.GET.dict() return res def get_queryset(self): form: ClubSearchForm = self.get_form() qs = self.queryset - if not form.is_bound: - return qs.filter(is_active=True) if not form.is_valid(): return qs.none() if name := form.cleaned_data.get("name"): @@ -244,6 +255,7 @@ class ClubView(ClubTabsMixin, DetailView): .values_list("content", flat=True) .first() ) + kwargs["links"] = list(self.object.links.select_related("link_type").all()) return kwargs @@ -355,7 +367,7 @@ class ClubMembersView( membership = self.object.get_membership_for(self.request.user) if ( membership - and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE + and not membership.role.is_board and not self.request.user.has_perm("club.add_membership") ): # Simple club members won't see the form anymore. @@ -380,8 +392,8 @@ class ClubMembersView( kwargs["members"] = list( self.object.members.ongoing() .annotate(is_editable=Q(id__in=editable)) - .order_by("-role") - .select_related("user") + .order_by("role__order") + .select_related("user", "role") ) kwargs["can_end_membership"] = len(editable) > 0 return kwargs @@ -409,12 +421,125 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView): return super().get_context_data(**kwargs) | { "old_members": ( self.object.members.exclude(end_date=None) - .order_by("-role", "description", "-end_date") - .select_related("user") + .order_by("role__order", "description", "-end_date") + .select_related("user", "role") ) } +class ClubRoleUpdateView( + ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView +): + form_class = ClubRoleFormSet + model = Club + template_name = "club/club_roles.jinja" + pk_url_kwarg = "club_id" + current_tab = "members" + success_message = _("Club roles updated") + + @cached_property + def club(self) -> Club: + return self.get_object() + + def test_func(self): + return self.club.can_roles_be_edited_by(self.request.user) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}} + + def get_success_url(self): + return reverse("club:club_members", kwargs={"club_id": self.club.id}) + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "user_role": ClubRole.objects.filter( + club=self.club, + members__user=self.request.user, + members__end_date=None, + ) + .values_list("id", flat=True) + .first() + } + + +class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView): + """View to create a new Club Role, using [][club.forms.ClubRoleCreateForm]. + + This view isn't meant to be called directly, but rather subclassed for each + type of role that can exist : + + - `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]` + to create a presidency role + - `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]` + to create a board role + - `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]` + to create a member role + + Each subclass have to override the following variables : + + - `is_presidency` and `is_board`, indicating what type of role + the view creates. + - `role_description`, which is the title of the page, indication + the user what kind of role is being created. + + This way, we are making sure the correct type of role will + be created, without bothering the user with the implementation details. + """ + + form_class = ClubRoleCreateForm + model = ClubRole + template_name = "core/create.jinja" + success_message = _("Role %(name)s created") + role_description = "" + is_presidency: bool + is_board: bool + + @cached_property + def club(self): + return get_object_or_404(Club, id=self.kwargs["club_id"]) + + def test_func(self): + return self.request.user.is_authenticated and ( + self.request.user.has_perm("club.add_clubrole") + or self.club.members.filter( + user=self.request.user, role__is_presidency=True + ).exists() + ) + + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "club": self.club, + "is_presidency": self.is_presidency, + "is_board": self.is_board, + } + + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "object_name": self.role_description + } + + def get_success_url(self): + return reverse("club:club_roles", kwargs={"club_id": self.club.id}) + + +class ClubRolePresidencyCreateView(ClubRoleBaseCreateView): + is_presidency = True + is_board = True + role_description = _("club role \u2013 presidency") + + +class ClubRoleBoardCreateView(ClubRoleBaseCreateView): + is_presidency = False + is_board = True + role_description = _("club role \u2013 board") + + +class ClubRoleMemberCreateView(ClubRoleBaseCreateView): + is_presidency = False + is_board = False + role_description = _("club role \u2013 member") + + class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): """Sales of a club.""" @@ -571,6 +696,11 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView): return ClubAdminEditForm return ClubEditForm + def get_context_data(self, **kwargs): + return super().get_context_data(**kwargs) | { + "link_types": list(LinkType.objects.order_by(Length("url_base").desc())) + } + class ClubCreateView(PermissionRequiredMixin, CreateView): """Create a club (for the Sith admin).""" @@ -581,6 +711,11 @@ class ClubCreateView(PermissionRequiredMixin, CreateView): template_name = "core/create.jinja" permission_required = "club.add_club" + def form_valid(self, form): + res = super().form_valid(form) + self.object.create_default_roles() + return res + class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View): """Set a membership as being old.""" @@ -761,9 +896,7 @@ class MailingAutoGenerationView(View): def get(self, request, *args, **kwargs): club = self.mailing.club self.mailing.subscriptions.all().delete() - members = club.members.filter( - role__gte=settings.SITH_CLUB_ROLES_ID["Board member"] - ).exclude(end_date__lte=timezone.now()) + members = club.members.ongoing().filter(role__is_board=True) for member in members.all(): MailingSubscription(user=member.user, mailing=self.mailing).save() return redirect("club:mailing", club_id=club.id) diff --git a/com/static/bundled/com/moderation-alert-index.ts b/com/static/bundled/com/moderation-alert-index.ts index 70dc871c..029ff87e 100644 --- a/com/static/bundled/com/moderation-alert-index.ts +++ b/com/static/bundled/com/moderation-alert-index.ts @@ -1,4 +1,3 @@ -import { exportToHtml } from "#core:utils/globals.ts"; import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; // This will be used in jinja templates, @@ -13,7 +12,8 @@ const AlertState = { // biome-ignore lint/style/useNamingConvention: this feels more like an enum DISPLAYED: 4, // When published at page generation }; -exportToHtml("AlertState", AlertState); +// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better +Object.assign(window, { AlertState }); document.addEventListener("alpine:init", () => { Alpine.data("moderationAlert", (newsId: number) => ({ diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index a1dcb966..e53fbc5c 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -16,9 +16,13 @@ #right_column { flex: 20%; margin: 3.2px; - display: inline-block; vertical-align: top; + + @media screen and (min-width: 800px) { + max-width: 20%; + min-width: 200px; + } } #left_column { @@ -46,7 +50,7 @@ } } - @media screen and (max-width: $small-devices) { + @media screen and (max-width: 800px) { #left_column, #right_column { flex: 100%; @@ -76,8 +80,8 @@ display: block; width: 100%; background: white; - font-size: 70%; margin-bottom: 1em; + font-size: 85%; #links_content { overflow: auto; @@ -96,23 +100,10 @@ li { margin: 10px; - .fa-facebook { - color: $faceblue; - } - - .fa-discord { - color: $discordblurple; - } - - .fa-square-instagram::before { - background: $instagradient; - background-clip: text; - -webkit-text-fill-color: transparent; - } - i { width: 25px; text-align: center; + margin-right: .5rem; } } } @@ -122,12 +113,13 @@ #birthdays_content { box-shadow: $shadow-color 1px 1px 1px; padding: 1em; + ul.birthdays_year { margin: 0; list-style-type: none; font-weight: bold; - >li { + > li { padding: 0.5em; &:nth-child(even) { diff --git a/com/templates/com/screen_slideshow.jinja b/com/templates/com/screen_slideshow.jinja index 08425f7c..0cd7449c 100644 --- a/com/templates/com/screen_slideshow.jinja +++ b/com/templates/com/screen_slideshow.jinja @@ -4,7 +4,7 @@ {% trans %}Slideshow{% endtrans %} - + PopulatedClubs: + ae = Club.objects.create( + id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" + ) + ae.board_group.permissions.add( + *Permission.objects.filter( + codename__in=[ + "view_subscription", + "add_subscription", + "add_membership", + "view_hidden_user", + ] + ) + ) + pdf = Club.objects.create( + id=settings.SITH_PDF_CLUB_ID, + name="PdF", + address="6 Boulevard Anatole France, 90000 Belfort", + ) + troll = Club.objects.create( + name="Troll Penché", address="Terre Du Milieu", parent=ae + ) + refound = Club.objects.create( + name="Carte AE", address="Jamais imprimée", parent=ae + ) + roles = [] + presidency_roles = ["Président⸱e", "Vice-Président⸱e"] + board_roles = [ + "Trésorier⸱e", + "Secrétaire", + "Respo Info", + "Respo Com", + "Membre du bureau", + ] + simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"] + for club in ae, pdf, troll, refound: + for i, role in enumerate(presidency_roles): + roles.append( + ClubRole( + club=club, order=i, name=role, is_presidency=True, is_board=True + ) + ) + for i, role in enumerate(board_roles, start=len(presidency_roles)): + roles.append(ClubRole(club=club, order=i, name=role, is_board=True)) + for i, role in enumerate( + simple_roles, start=len(presidency_roles) + len(board_roles) + ): + roles.append(ClubRole(club=club, order=i, name=role)) + ClubRole.objects.bulk_create(roles) + insta, fb, discord, _ = LinkType.objects.bulk_create( + [ + LinkType( + name="instagram", + icon="fa-brands fa-square-instagram", + url_base="https://www.instagram.com", + ), + LinkType( + name="facebook", + icon="fa-brands fa-facebook", + url_base="https://www.facebook.com", + ), + LinkType( + name="discord", + icon="fa-brands fa-discord", + url_base="https://discord.gg", + ), + LinkType(name="generic", icon="fa fa-link", url_base=""), + ] + ) + ClubLink.objects.bulk_create( + [ + ClubLink( + name="insta AE", + url="https://www.instagram.com/ae_utbm/", + club=ae, + link_type=insta, + ), + ClubLink( + name="insta activités AE", + url="https://www.instagram.com/activites_ae/", + club=ae, + link_type=insta, + ), + ClubLink( + name="facebook AE", + url="https://www.facebook.com/ae_utbm", + club=ae, + link_type=fb, + ), + ClubLink( + name="Discord", + url="https://discord.gg/QvTm3XJrHR", + club=ae, + link_type=discord, + ), + ] + ) + return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound) + def _create_groups(self) -> PopulatedGroups: perms = Permission.objects.all() diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 47cb75d5..14b1c59a 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -11,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery from django.utils.timezone import localdate, make_aware, now from faker import Faker -from club.models import Club, Membership +from club.models import Club, ClubRole, Membership from core.models import Group, User, UserBan from counter.models import ( Counter, @@ -173,20 +173,25 @@ class Command(BaseCommand): Customer.objects.bulk_create(customers, ignore_conflicts=True) def make_club(self, club: Club, members: list[User], old_members: list[User]): - def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]: - roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True)) + roles: list[ClubRole] = list(club.roles.all()) + + def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]: + important_roles = [r for r in roles if r.is_board] + important_roles.sort(key=lambda r: r.order) + simple_board_role = important_roles.pop() + member_roles = [r for r in roles if not r.is_board] user_idx = 0 - while (role := next(roles)) > 2: + for _role in important_roles: # one member for each major role - yield users[user_idx], role + yield users[user_idx], _role user_idx += 1 for _ in range(int(0.3 * (len(users) - user_idx))): # 30% of the remaining in the board - yield users[user_idx], 2 + yield users[user_idx], simple_board_role user_idx += 1 for remaining in users[user_idx + 1 :]: # everything else is a simple member - yield remaining, 1 + yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0] memberships = [] old_members = old_members.copy() @@ -198,19 +203,14 @@ class Command(BaseCommand): start_date=start, end_date=self.faker.past_date(start), user=old, - role=random.choice(list(settings.SITH_CLUB_ROLES.keys())), + role=random.choice(roles), club=club, ) ) for member, role in zip_roles(members): start = self.faker.past_date("-1y") memberships.append( - Membership( - start_date=start, - user=member, - role=role, - club=club, - ) + Membership(start_date=start, user=member, role=role, club=club) ) memberships = Membership.objects.bulk_create(memberships) Membership._add_club_groups(memberships) diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js deleted file mode 100644 index 9a4d7948..00000000 --- a/core/static/bundled/alpine-index.js +++ /dev/null @@ -1,12 +0,0 @@ -import sort from "@alpinejs/sort"; -import Alpine from "alpinejs"; -import { limitedChoices } from "#core:alpine/limited-choices.ts"; -import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts"; - -Alpine.plugin([sort, limitedChoices]); -Alpine.magic("notifications", notificationPlugin); -window.Alpine = Alpine; - -window.addEventListener("DOMContentLoaded", () => { - Alpine.start(); -}); diff --git a/core/static/bundled/base-bundle-index.ts b/core/static/bundled/base-bundle-index.ts new file mode 100644 index 00000000..4dc085ad --- /dev/null +++ b/core/static/bundled/base-bundle-index.ts @@ -0,0 +1,64 @@ +/** + * File containing main functions and library re-exports + * that should be accessible throughout the whole website. + * + * The idea is to group all that shared code into a single bundle, + * for more efficient tree-shaking and gzip compression. + */ + +import sort from "@alpinejs/sort"; +import Alpine from "alpinejs"; +import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; +import htmx from "htmx.org"; +import { limitedChoices } from "#core:alpine/limited-choices"; +import { expireOldStorage } from "#core:core/localstorage"; +import { default as navbar } from "#core:core/navbar"; +import { + type NotificationPlugin, + notificationsPlugin as notifications, +} from "#core:utils/notifications"; + +/** + * Alpine + */ +declare module "alpinejs" { + interface Magics { + $notifications: NotificationPlugin; + } +} + +Alpine.plugin([sort, limitedChoices, notifications]); +// biome-ignore lint/style/useNamingConvention: it's how it's named +Object.assign(window, { Alpine }); + +window.addEventListener("DOMContentLoaded", () => { + Alpine.start(); +}); + +/** + * Polyfill for country flags (used for language choice) + */ +polyfillCountryFlagEmojis(); + +/** + * HTMX + */ +document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => { + event.detail.target.ariaBusy = true; +}); + +document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => { + event.detail.target.ariaBusy = null; +}); + +Object.assign(window, { htmx }); + +/** + * navbar + */ +navbar(); + +/** + * Script that clears the cache when the cache version changes + */ +expireOldStorage(); diff --git a/core/static/bundled/core/localstorage.ts b/core/static/bundled/core/localstorage.ts new file mode 100644 index 00000000..521b7ac2 --- /dev/null +++ b/core/static/bundled/core/localstorage.ts @@ -0,0 +1,72 @@ +/** + * For more detailed infos on how to use this file, + * check /docs/tutorial/front/localstorage.md, + * or https://ae-utbm.github.io/sith/tutorial/front/localstorage/ + */ + +// increment this number when a breaking change is made with localStorage +const CURRENT_LOCALSTORAGE_VERSION = 1; + +/** + * Remove keys that are no longer used from localStorage + */ +export function expireOldStorage() { + const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10); + if (version === CURRENT_LOCALSTORAGE_VERSION) { + // The cache schema is up-to-date. Nothing to do. + return; + } + localStorage.removeItem("basket1"); + // remove all storage items which key is in the form + // `userXXXPictures` or `userXXXPicturesNumber` + Object.keys(localStorage) + .filter( + (key) => + key.startsWith("user") && + (key.endsWith("Pictures") || key.endsWith("PicturesNumber")), + ) + .forEach((key) => { + localStorage.removeItem(key); + }); + localStorage.setItem("version", CURRENT_LOCALSTORAGE_VERSION.toString()); +} + +interface VersionedStorageItem { + version?: number; + val: T | undefined; +} + +export const versionedLocalStorage = { + ...localStorage, + /** + * set this item in localStorage, alongside its version. + * + * Note: this expects an object, not a JSON string, because the parsing + * into JSON needs to be done inside the function. + */ + setItem(key: string, value: T, { version }: { version: number }) { + localStorage.setItem(key, JSON.stringify({ version: version, val: value })); + }, + /** + * Get the item linked with the given key and version from localStorage. + * + * Note: if the given key exists in localStorage but doesn't satisfy + * the given version, it will be cleared from cache. + * + * @return the object if found and with the good version, else null; + */ + getItem(key: string, { version }: { version: number }): T | null { + const stored = localStorage.getItem(key); + if (!stored) { + // this key doesn't exist, return null; + return null; + } + const obj: VersionedStorageItem = JSON.parse(stored); + if (obj.version !== version || obj.val === undefined) { + // The version is wrong, return null and remove this item from cache + localStorage.removeItem(key); + return null; + } + return obj.val; + }, +}; diff --git a/core/static/bundled/core/navbar-index.ts b/core/static/bundled/core/navbar.ts similarity index 84% rename from core/static/bundled/core/navbar-index.ts rename to core/static/bundled/core/navbar.ts index 18f7e3e3..9abc3aec 100644 --- a/core/static/bundled/core/navbar-index.ts +++ b/core/static/bundled/core/navbar.ts @@ -1,12 +1,10 @@ -import { exportToHtml } from "#core:utils/globals.ts"; - -exportToHtml("showMenu", () => { +function showMenu() { const navbar = document.getElementById("navbar-content"); const current = navbar.getAttribute("mobile-display"); navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden"); -}); +} -document.addEventListener("alpine:init", () => { +function navbarInit() { const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu"); const isDesktop = () => { return window.innerWidth >= 500; @@ -33,4 +31,9 @@ document.addEventListener("alpine:init", () => { } }); } -}); +} + +export default () => { + Object.assign(document, { showMenu }); + document.addEventListener("alpine:init", navbarInit); +}; diff --git a/core/static/bundled/country-flags-index.ts b/core/static/bundled/country-flags-index.ts deleted file mode 100644 index 1dc005c3..00000000 --- a/core/static/bundled/country-flags-index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; - -polyfillCountryFlagEmojis(); diff --git a/core/static/bundled/htmx-index.js b/core/static/bundled/htmx-index.js deleted file mode 100644 index 5880668d..00000000 --- a/core/static/bundled/htmx-index.js +++ /dev/null @@ -1,11 +0,0 @@ -import htmx from "htmx.org"; - -document.body.addEventListener("htmx:beforeRequest", (event) => { - event.detail.target.ariaBusy = true; -}); - -document.body.addEventListener("htmx:beforeSwap", (event) => { - event.detail.target.ariaBusy = null; -}); - -Object.assign(window, { htmx }); diff --git a/core/static/bundled/sentry-popup-index.ts b/core/static/bundled/sentry-popup-index.ts index ea16c092..d8b84197 100644 --- a/core/static/bundled/sentry-popup-index.ts +++ b/core/static/bundled/sentry-popup-index.ts @@ -1,6 +1,5 @@ // biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation import * as Sentry from "@sentry/browser"; -import { exportToHtml } from "#core:utils/globals.ts"; interface LoggedUser { name: string; @@ -13,7 +12,7 @@ interface SentryOptions { user?: LoggedUser; } -exportToHtml("loadSentryPopup", (options: SentryOptions) => { +const loadSentryPopup = (options: SentryOptions) => { Sentry.init({ dsn: options.dsn, }); @@ -21,4 +20,5 @@ exportToHtml("loadSentryPopup", (options: SentryOptions) => { eventId: options.eventId, ...(options.user ?? {}), }); -}); +}; +Object.assign(window, { loadSentryPopup }); diff --git a/core/static/bundled/utils/types.d.ts b/core/static/bundled/types/nested-key.d.ts similarity index 100% rename from core/static/bundled/utils/types.d.ts rename to core/static/bundled/types/nested-key.d.ts diff --git a/core/static/bundled/utils/csv.ts b/core/static/bundled/utils/csv.ts index d7356bfc..3172c525 100644 --- a/core/static/bundled/utils/csv.ts +++ b/core/static/bundled/utils/csv.ts @@ -1,4 +1,4 @@ -import type { NestedKeyOf } from "#core:utils/types.ts"; +import type { NestedKeyOf } from "#core:types/nested-key"; interface StringifyOptions { /** The columns to include in the resulting CSV. */ diff --git a/core/static/bundled/utils/globals.ts b/core/static/bundled/utils/globals.ts index b4f9a457..71c15cb0 100644 --- a/core/static/bundled/utils/globals.ts +++ b/core/static/bundled/utils/globals.ts @@ -5,17 +5,3 @@ declare global { const gettext: (text: string) => string; const interpolate: (fmt: string, args: string[] | T, isNamed?: boolean) => string; } - -/** - * Helper function to export typescript functions to regular html and jinja files - * Without it, you either have to use the any keyword and suppress warnings or do a - * very painful type conversion workaround which is only here to please the linter - * - * This is only useful if you're using typescript, this is equivalent to doing - * window.yourFunction = yourFunction - **/ -// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions -export function exportToHtml(name: string, func: any) { - // biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions - (window as any)[name] = func; -} diff --git a/core/static/bundled/utils/notifications.ts b/core/static/bundled/utils/notifications.ts index 8af936e3..f5ee874c 100644 --- a/core/static/bundled/utils/notifications.ts +++ b/core/static/bundled/utils/notifications.ts @@ -1,36 +1,64 @@ +import { Alpine } from "alpinejs"; + export enum NotificationLevel { Error = "error", Warning = "warning", Success = "success", } -export function createNotification(message: string, level: NotificationLevel) { - const element = document.getElementById("quick-notifications"); - if (element === null) { - return false; - } - return element.dispatchEvent( - new CustomEvent("quick-notification-add", { - detail: { text: message, tag: level }, - }), - ); +export interface NotificationPlugin { + /** + * Add an error message to the notifications. + */ + error: (message: string) => void; + /** + * Add a warning message to the notifications + */ + warning: (message: string) => void; + /** + * Add a success message to the notifications + */ + success: (message: string) => void; + /** + * Remove all notifications displayed on the page. + */ + clear: () => void; + /** + * Add multiple notifications at once. + * The added notifications can have different notification levels. + */ + addMany: (notifs: Notification[]) => void; + /** + * Return all notifications displayed on the page. + */ + getAll: () => Notification[]; } -export function deleteNotifications() { - const element = document.getElementById("quick-notifications"); - if (element === null) { - return false; - } - return element.dispatchEvent(new CustomEvent("quick-notification-delete")); +export interface Notification { + tag: NotificationLevel; + text: string; } -export function alpinePlugin() { - return { - error: (message: string) => createNotification(message, NotificationLevel.Error), - warning: (message: string) => - createNotification(message, NotificationLevel.Warning), - success: (message: string) => - createNotification(message, NotificationLevel.Success), - clear: () => deleteNotifications(), - }; +Alpine.store("notifications", [] as Notification[]); + +function createNotification(message: string, level: NotificationLevel) { + (Alpine.store("notifications") as Notification[]).push({ text: message, tag: level }); +} +function createManyNotifications(notifs: Notification[]) { + for (const notif of notifs) { + createNotification(notif.text, notif.tag); + } +} + +export const notifications: NotificationPlugin = { + error: (message: string) => createNotification(message, NotificationLevel.Error), + warning: (message: string) => createNotification(message, NotificationLevel.Warning), + success: (message: string) => createNotification(message, NotificationLevel.Success), + clear: () => Alpine.store("notifications", []), + addMany: (notifs: Notification[]) => createManyNotifications(notifs), + getAll: () => Alpine.store("notifications") as Notification[], +}; + +export function notificationsPlugin(GlobalAlpine: Alpine) { + GlobalAlpine.magic("notifications", () => ({ ...notifications })); } diff --git a/core/static/core/accordion.scss b/core/static/core/accordion.scss index 4f40ba71..28a7f75b 100644 --- a/core/static/core/accordion.scss +++ b/core/static/core/accordion.scss @@ -53,7 +53,7 @@ details.accordion>.accordion-content { opacity: 0; @supports (max-height: calc-size(max-content, size)) { - max-height: 0px; + max-height: 0; } } @@ -71,11 +71,12 @@ details.accordion>.accordion-content { } } -// ::details-content isn't available on firefox yet +// ::details-content is available on firefox only since september 2025 +// (and wasn't available when this code was initially written) // we use .accordion-content as a workaround // But we need to use ::details-content for chrome because it's // not working correctly otherwise -// it only happen in chrome, not safari or firefox +// it only happens in chrome, not safari or firefox // Note: `selector` is not supported by scss so we comment it out to // avoid compiling it and sending it straight to the css // This is a trick that comes from here : diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 67f03898..2a1857b1 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -398,6 +398,28 @@ body { } } + /* Fontawesome icons */ + .fa-brands, .fa-link { + color: black; + } + + .fa-facebook { + color: $faceblue; + } + + .fa-discord { + color: $discordblurple; + } + + .fa-square-instagram::before, .fa-instagram::before { + background: $instagradient; + background-clip: text; + -webkit-text-fill-color: transparent; + } + + .fa-bluesky, .fa-square-bluesky { + color: #0f73ff; + } } @media screen and (max-width: $small-devices) { @@ -749,16 +771,3 @@ textarea { vertical-align: middle; } } - -/*--------------------------------JQuery-------------------------------*/ -#club_detail { - .club_logo { - float: right; - - img { - display: block; - max-height: 10em; - max-width: 10em; - } - } -} \ No newline at end of file diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 025dacdf..0d8a06fe 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -35,12 +35,9 @@ - + - - - - + {% block additional_css %}{% endblock %} {% block additional_js %}{% endblock %} diff --git a/core/templates/core/base/notifications.jinja b/core/templates/core/base/notifications.jinja index 030b2d4e..6eb7b749 100644 --- a/core/templates/core/base/notifications.jinja +++ b/core/templates/core/base/notifications.jinja @@ -1,16 +1,13 @@
    -