mirror of
https://github.com/ae-utbm/sith.git
synced 2026-04-16 16:28:24 +00:00
feat: page to edit and reorder club role
This commit is contained in:
@@ -306,3 +306,42 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
_("You are already a member of this club"), code="invalid"
|
_("You are already a member of this club"), code="invalid"
|
||||||
)
|
)
|
||||||
return super().clean()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
37
club/static/bundled/club/role-list-index.ts
Normal file
37
club/static/bundled/club/role-list-index.ts
Normal file
@@ -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<RoleGroupData>, 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<HTMLInputElement>(
|
||||||
|
"input[name^='roles'][name$='ORDER']",
|
||||||
|
);
|
||||||
|
for (const [i, elem] of inputs.entries()) {
|
||||||
|
elem.value = (i + 1).toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
7
club/static/club/roles.scss
Normal file
7
club/static/club/roles.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.fa-grip-vertical {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
82
club/templates/club/club_roles.jinja
Normal file
82
club/templates/club/club_roles.jinja
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro display_subform(subform) %}
|
||||||
|
<div
|
||||||
|
class="row"
|
||||||
|
x-data="{
|
||||||
|
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||||
|
isBoard: {{ subform.is_board.value()|lower }},
|
||||||
|
}"
|
||||||
|
x-sort:item="$data"
|
||||||
|
>
|
||||||
|
{# hidden fields #}
|
||||||
|
{{ subform.ORDER }}
|
||||||
|
{{ subform.id }}
|
||||||
|
{{ subform.club }}
|
||||||
|
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||||
|
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||||
|
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||||
|
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
{{ subform.name.value() }}
|
||||||
|
{% if not subform.instance.is_active -%}
|
||||||
|
({% trans %}inactive{% endtrans %})
|
||||||
|
{%- endif %}
|
||||||
|
</summary>
|
||||||
|
<div class="accordion-content">
|
||||||
|
{{ subform.non_field_errors() }}
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.name.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.description.as_field_group() }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ subform.is_active }}
|
||||||
|
{{ subform.is_active.label_tag() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" x-data="clubRoleList">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.management_form }}
|
||||||
|
{{ form.non_form_errors() }}
|
||||||
|
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||||
|
<div x-sort="reorder($item, { isBoard: true, isPresidency: true })" x-sort:group="roles">
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||||
|
<div x-sort="reorder($item, { isBoard: true, isPresidency: false })" x-sort:group="roles">
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||||
|
<div x-sort="reorder($item, { isBoard: false, isPresidency: false })" x-sort:group="roles">
|
||||||
|
{% for subform in form %}
|
||||||
|
{% if not subform.is_board.value() %}
|
||||||
|
{{ display_subform(subform) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
@@ -35,6 +35,7 @@ from club.views import (
|
|||||||
ClubPageEditView,
|
ClubPageEditView,
|
||||||
ClubPageHistView,
|
ClubPageHistView,
|
||||||
ClubRevView,
|
ClubRevView,
|
||||||
|
ClubRoleUpdateView,
|
||||||
ClubSellingCSVView,
|
ClubSellingCSVView,
|
||||||
ClubSellingView,
|
ClubSellingView,
|
||||||
ClubToolsView,
|
ClubToolsView,
|
||||||
@@ -71,6 +72,7 @@ urlpatterns = [
|
|||||||
ClubOldMembersView.as_view(),
|
ClubOldMembersView.as_view(),
|
||||||
name="club_old_members",
|
name="club_old_members",
|
||||||
),
|
),
|
||||||
|
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||||
path(
|
path(
|
||||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import csv
|
|||||||
import itertools
|
import itertools
|
||||||
from typing import TYPE_CHECKING, Any
|
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.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
||||||
from django.core.paginator import InvalidPage, Paginator
|
from django.core.paginator import InvalidPage, Paginator
|
||||||
@@ -50,6 +54,7 @@ from club.forms import (
|
|||||||
ClubAdminEditForm,
|
ClubAdminEditForm,
|
||||||
ClubEditForm,
|
ClubEditForm,
|
||||||
ClubOldMemberForm,
|
ClubOldMemberForm,
|
||||||
|
ClubRoleFormSet,
|
||||||
JoinClubForm,
|
JoinClubForm,
|
||||||
MailingForm,
|
MailingForm,
|
||||||
SellingsForm,
|
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):
|
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||||
"""Sales of a club."""
|
"""Sales of a club."""
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
@supports (max-height: calc-size(max-content, size)) {
|
@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
|
// we use .accordion-content as a workaround
|
||||||
// But we need to use ::details-content for chrome because it's
|
// But we need to use ::details-content for chrome because it's
|
||||||
// not working correctly otherwise
|
// 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
|
// Note: `selector` is not supported by scss so we comment it out to
|
||||||
// avoid compiling it and sending it straight to the css
|
// avoid compiling it and sending it straight to the css
|
||||||
// This is a trick that comes from here :
|
// This is a trick that comes from here :
|
||||||
|
|||||||
Reference in New Issue
Block a user