diff --git a/.python-version b/.python-version index fdcfcfdf..3767b4b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 \ No newline at end of file +3.14 \ No newline at end of file diff --git a/api/admin.py b/api/admin.py index 611bdba0..5aeed933 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,11 +1,15 @@ +from typing import TYPE_CHECKING + from django.contrib import admin, messages -from django.db.models import QuerySet -from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from api.hashers import generate_key from api.models import ApiClient, ApiKey +if TYPE_CHECKING: + from django.db.models import QuerySet + from django.http import HttpRequest + @admin.register(ApiClient) class ApiClientAdmin(admin.ModelAdmin): diff --git a/api/auth.py b/api/auth.py index aac8cf40..97bd96a1 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,9 +1,13 @@ -from django.http import HttpRequest +from typing import TYPE_CHECKING + from ninja.security import APIKeyHeader from api.hashers import get_hasher from api.models import ApiClient, ApiKey +if TYPE_CHECKING: + from django.http import HttpRequest + class ApiKeyAuth(APIKeyHeader): """Authentication through client api keys.""" diff --git a/api/permissions.py b/api/permissions.py index 38377c98..8b1e4f11 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -39,15 +39,17 @@ Example: import operator from functools import reduce -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable -from django.contrib.auth.models import Permission -from django.http import HttpRequest -from ninja_extra import ControllerBase from ninja_extra.permissions import BasePermission from counter.utils import is_logged_in_counter +if TYPE_CHECKING: + from django.contrib.auth.models import Permission + from django.http import HttpRequest + from ninja_extra import ControllerBase + class IsInGroup(BasePermission): """Check that the user is in the group whose primary key is given.""" diff --git a/club/admin.py b/club/admin.py index 6d6d8bb0..522ffe7d 100644 --- a/club/admin.py +++ b/club/admin.py @@ -12,12 +12,16 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # +from typing import TYPE_CHECKING + from django.contrib import admin -from django.forms.models import ModelForm -from django.http import HttpRequest from club.models import Club, ClubLink, ClubRole, LinkType, Membership +if TYPE_CHECKING: + from django.forms.models import ModelForm + from django.http import HttpRequest + @admin.register(Club) class ClubAdmin(admin.ModelAdmin): @@ -46,8 +50,8 @@ class ClubAdmin(admin.ModelAdmin): @admin.register(ClubRole) class ClubRoleAdmin(admin.ModelAdmin): list_display = ("name", "club", "is_board", "is_presidency") - search_fields = ("name",) - autocomplete_fields = ("club",) + search_fields = ("name", "club__name") + autocomplete_fields = ("club", "linked_groups") list_select_related = ("club",) list_filter = ( "is_board", diff --git a/club/forms.py b/club/forms.py index 2f9db670..80d4c058 100644 --- a/club/forms.py +++ b/club/forms.py @@ -479,6 +479,13 @@ class ClubRoleCreateForm(forms.ModelForm): class ClubRoleBaseFormSet(forms.BaseInlineFormSet): ordering_widget = forms.HiddenInput() + def __init__(self, *args, queryset=None, **kwargs): + if queryset is None: + queryset = self.model._default_manager + super().__init__( + *args, queryset=queryset.prefetch_related("linked_groups"), **kwargs + ) + ClubRoleFormSet = forms.inlineformset_factory( Club, 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 a9ad8d3a..75dc86a0 100644 --- a/club/migrations/0012_club_board_group_club_members_group.py +++ b/club/migrations/0012_club_board_group_club_members_group.py @@ -1,12 +1,16 @@ # Generated by Django 4.2.16 on 2024-11-20 17:08 +from typing import TYPE_CHECKING + import django.db.models.deletion import django.db.models.functions.datetime 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 +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + # Before the club role rework, the maximum free role # was the hardcoded highest non-board role MAXIMUM_FREE_ROLE = 1 diff --git a/club/migrations/0015_clubrole_alter_membership_role.py b/club/migrations/0015_clubrole_alter_membership_role.py index 82279f65..73aee1c8 100644 --- a/club/migrations/0015_clubrole_alter_membership_role.py +++ b/club/migrations/0015_clubrole_alter_membership_role.py @@ -1,10 +1,14 @@ # Generated by Django 5.2.3 on 2025-06-21 21:59 +from typing import TYPE_CHECKING + 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 +if TYPE_CHECKING: + from django.db.migrations.state import StateApps + PRESIDENCY_ROLES = [10, 9] MAXIMUM_FREE_ROLE = 1 SITH_CLUB_ROLES = { diff --git a/club/migrations/0018_clubrole_linked_groups.py b/club/migrations/0018_clubrole_linked_groups.py new file mode 100644 index 00000000..0f1aa4e8 --- /dev/null +++ b/club/migrations/0018_clubrole_linked_groups.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.17 on 2026-09-01 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0017_linktype_clublink"), + ("core", "0050_alter_sithfile_moderator"), + ] + + operations = [ + migrations.AddField( + model_name="clubrole", + name="linked_groups", + field=models.ManyToManyField( + help_text=( + "Groups that are automatically given or removed " + "to user receiving or losing this club role" + ), + related_name="linked_roles", + to="core.group", + verbose_name="Linked groups", + ), + ), + ] diff --git a/club/models.py b/club/models.py index a226cbcd..6c2baec8 100644 --- a/club/models.py +++ b/club/models.py @@ -23,6 +23,8 @@ # from __future__ import annotations +import operator +from functools import reduce from typing import Iterable, Self from django.conf import settings @@ -282,6 +284,15 @@ class ClubRole(OrderedModel): "If the role is inactive, people joining the club won't be able to get it." ), ) + linked_groups = models.ManyToManyField( + Group, + verbose_name=_("Linked groups"), + help_text=_( + "Groups that are automatically given or removed " + "to user receiving or losing this club role" + ), + related_name="linked_roles", + ) order_with_respect_to = "club" @@ -534,7 +545,7 @@ class Membership(models.Model): def _remove_club_groups( memberships: Iterable[Membership], ) -> tuple[int, dict[str, int]]: - """Remove users of those memberships from the club groups. + """Remove users of those memberships from the club and club role groups. For example, if a user is in the Troll club board, he is in the board group and the members group of the Troll. @@ -553,15 +564,19 @@ class Membership(models.Model): clubs = {m.club_id for m in memberships} users = {m.user_id for m in memberships} groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs)) + role_groups = [ + Q(user_id=m.user_id, group__linked_roles=m.role_id) for m in memberships + ] return User.groups.through.objects.filter( - Q(group__in=groups) & Q(user__in=users) + (Q(group__in=groups) & Q(user__in=users)) + | reduce(operator.or_, role_groups) ).delete() @staticmethod def _add_club_groups( memberships: Iterable[Membership], ) -> list[User.groups.through]: - """Add users of those memberships to the club groups. + """Add users of those memberships to the club and club role groups. For example, if a user just joined the Troll club board, he will be added in both the members group and the board group @@ -582,34 +597,40 @@ class Membership(models.Model): memberships = [m for m in memberships if m.end_date is None] if not memberships: return [] - - if sum(1 for m in memberships if not hasattr(m, "club")) > 1: + nb_prefetched = sum( + 1 for m in memberships if not hasattr(m, "club") or not hasattr(m, "role") + ) + if nb_prefetched > 1: # if more than one membership hasn't its `club` attribute set # it's less expensive to reload the whole query with # a select_related than perform a distinct query # to fetch each club. ids = {m.id for m in memberships} memberships = list( - Membership.objects.filter(id__in=ids).select_related("club") + Membership.objects.filter(id__in=ids) + .select_related("club", "role") + .prefetch_related("role__linked_groups") ) - club_groups = [] + groups = [] for membership in memberships: - club_groups.append( + groups.append( User.groups.through( user_id=membership.user_id, group_id=membership.club.members_group_id, ) ) if membership.role.is_board: - club_groups.append( + groups.append( User.groups.through( user_id=membership.user_id, group_id=membership.club.board_group_id, ) ) - return User.groups.through.objects.bulk_create( - club_groups, ignore_conflicts=True - ) + groups.extend( + User.groups.through(user_id=membership.user_id, group_id=g.id) + for g in membership.role.linked_groups.all() + ) + return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True) class Mailing(models.Model): diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja index e9e2cbd4..d370a189 100644 --- a/club/templates/club/club_list.jinja +++ b/club/templates/club/club_list.jinja @@ -15,9 +15,6 @@ {% endblock %} {% else %} {% extends "core/base.jinja" %} - {% block additional_css %} - - {% endblock %} {% block description -%} {% trans %}The list of all clubs existing at UTBM.{% endtrans %} {%- endblock %} @@ -26,6 +23,12 @@ {%- endblock %} {% endif %} +{% block additional_css %} + {% if not is_fragment %} + + {% endif %} +{% endblock %} + {% from "core/macros.jinja" import paginate_htmx %} {% block content %} @@ -33,15 +36,17 @@
+ {% trans %}Linked groups : {% endtrans %} + {{ groups|map(attribute="name")|join(", ") }} +
++ {% trans trimmed %} + Users receiving this role will also be assigned to those groups + {% endtrans %} +
+