From d31b7ad32d9aef8ab4b77ab64b8b908ddc4c6670 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 10 Apr 2026 19:02:06 +0200 Subject: [PATCH] feat: page to edit and reorder club role --- club/forms.py | 39 ++++++++++ club/static/bundled/club/role-list-index.ts | 37 ++++++++++ club/static/club/roles.scss | 7 ++ club/templates/club/club_roles.jinja | 82 +++++++++++++++++++++ club/urls.py | 2 + club/views.py | 29 +++++++- core/static/core/accordion.scss | 7 +- 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 club/static/bundled/club/role-list-index.ts create mode 100644 club/static/club/roles.scss create mode 100644 club/templates/club/club_roles.jinja diff --git a/club/forms.py b/club/forms.py index 90045112..4e5797e2 100644 --- a/club/forms.py +++ b/club/forms.py @@ -306,3 +306,42 @@ class JoinClubForm(ClubMemberForm): _("You are already a member of this club"), code="invalid" ) return super().clean() + + +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 __init__(self, *args, label_suffix="", **kwargs): + super().__init__(*args, label_suffix=label_suffix, **kwargs) + + def clean(self): + cleaned_data = super().clean() + if "ORDER" in cleaned_data: + self.instance.order = cleaned_data["ORDER"] + return cleaned_data + + +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/static/bundled/club/role-list-index.ts b/club/static/bundled/club/role-list-index.ts new file mode 100644 index 00000000..a750501a --- /dev/null +++ b/club/static/bundled/club/role-list-index.ts @@ -0,0 +1,37 @@ +import type { AlpineComponent } from "alpinejs"; + +interface RoleGroupData { + isBoard: boolean; + isPresidency: boolean; +} + +document.addEventListener("alpine:init", () => { + Alpine.data("clubRoleList", () => ({ + /** + * 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; + 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(); + } + }, + })); +}); 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_roles.jinja b/club/templates/club/club_roles.jinja new file mode 100644 index 00000000..ab9310ba --- /dev/null +++ b/club/templates/club/club_roles.jinja @@ -0,0 +1,82 @@ +{% 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() }} +
+
+
+
+{% endmacro %} + +{% block content %} +
+ {% csrf_token %} + {{ form.management_form }} + {{ form.non_form_errors() }} +

{% trans %}Presidency{% endtrans %}

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

{% trans %}Board{% endtrans %}

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

{% trans %}Members{% endtrans %}

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

+
+{% endblock content %} diff --git a/club/urls.py b/club/urls.py index e33c2a26..7a9bc109 100644 --- a/club/urls.py +++ b/club/urls.py @@ -35,6 +35,7 @@ from club.views import ( ClubPageEditView, ClubPageHistView, ClubRevView, + ClubRoleUpdateView, ClubSellingCSVView, ClubSellingView, ClubToolsView, @@ -71,6 +72,7 @@ urlpatterns = [ ClubOldMembersView.as_view(), name="club_old_members", ), + path("/role/", ClubRoleUpdateView.as_view(), name="club_roles"), 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 46b601f8..478851f2 100644 --- a/club/views.py +++ b/club/views.py @@ -28,7 +28,11 @@ import csv import itertools from typing import TYPE_CHECKING, Any -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 @@ -50,6 +54,7 @@ from club.forms import ( ClubAdminEditForm, ClubEditForm, ClubOldMemberForm, + ClubRoleFormSet, JoinClubForm, MailingForm, SellingsForm, @@ -377,6 +382,28 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView): } +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") + + def test_func(self): + if self.request.user.has_perm("club.change_clubrole"): + return True + club: Club = self.get_object() + return club.members.filter( + user=self.request.user, role__is_presidency=True + ).exists() + + def get_success_url(self): + return self.request.path + + class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView): """Sales of a club.""" 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 :