Build out Positions CRUD and rework the Roles page

Positions had list/create/edit already stubbed as list-only; give it real
forms, with a check mirroring the management_position_implies_staff_position
constraint so a bad combination reads as a form error, not a 500. Roles now
groups by role (excluding the MEMBER everyone holds automatically, which was
just noise), grants via a modal instead of a separate page, and its member
picker is a small typeahead combobox instead of a long native <select>.
This commit is contained in:
2026-08-03 18:45:28 +02:00
parent 9b0af5800a
commit ce35348b31
13 changed files with 500 additions and 82 deletions

View File

@@ -24,7 +24,20 @@ from shop.models import Discount, Invoice, Order, Product
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .bulk_import import build_member_import_template, parse_member_import_rows, read_member_import_workbook
from .forms import AddChildForm, AddParentForm, AttachToFamilyForm, ClubMembershipForm, ClubRoleAssignForm, FamilyCreateForm, GrantLoginForm, MemberForm, MemberImportUploadForm, RecordFeePaymentForm, TeamForm
from .forms import (
AddChildForm,
AddParentForm,
AttachToFamilyForm,
ClubMembershipForm,
ClubRoleAssignForm,
FamilyCreateForm,
GrantLoginForm,
MemberForm,
MemberImportUploadForm,
PositionForm,
RecordFeePaymentForm,
TeamForm,
)
from .pdf import PDFExportError, membership_list_pdf
@@ -707,18 +720,38 @@ class TeamDetailView(ClubStaffRequiredMixin, DetailView):
# granted or taken away) -------------------------------------------------------------
#: What each non-default role actually grants -- shown on the roles overview so an
#: admin granting one knows what they're handing out. See club/services/access.py.
ROLE_DESCRIPTIONS = {
ClubRole.Roles.ADMIN: _("Full control over the club: memberships, positions, roles, teams, shop, and every event."),
ClubRole.Roles.EDITOR: _("Can create and edit events, but not memberships, positions, roles, or shop settings."),
}
class ClubRoleListView(ClubAdminRequiredMixin, ListView):
"""Every non-default role holder, grouped by role. The plain MEMBER role is
excluded entirely -- every active club member holds it automatically
(club/signals.py), so listing it here would just be noise; this page is for
the roles someone was actually *granted*. ClubRole.Roles has few enough
values that a section per role reads better than one flat table."""
template_name = "management/role_list.html"
context_object_name = "roles"
def get_queryset(self):
return ClubRole.objects.filter(club=self.request.club).select_related("member")
return ClubRole.objects.filter(club=self.request.club).exclude(role=ClubRole.Roles.MEMBER).select_related("member")
def get_context_data(self, **kwargs):
sections = [(value, label, ROLE_DESCRIPTIONS.get(value, ""), [role for role in self.object_list if role.role == value]) for value, label in ClubRole.Roles.choices if value != ClubRole.Roles.MEMBER]
return super().get_context_data(sections=sections, role_form=ClubRoleAssignForm(club=self.request.club), **kwargs)
class ClubRoleCreateView(ClubAdminRequiredMixin, CreateView):
model = ClubRole
class ClubRoleCreateView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormView):
"""Reachable only via the "Grant role" modal on the roles overview."""
form_class = ClubRoleAssignForm
template_name = "management/role_form.html"
http_method_names = ["post"]
invalid_redirect_url_name = "management:role_list"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"club": self.request.club}
@@ -729,17 +762,14 @@ class ClubRoleCreateView(ClubAdminRequiredMixin, CreateView):
# so granting ADMIN/EDITOR promotes that existing row rather than inserting a
# second one, exactly like controlpanel.services.admins.grant_club_admin.
member, role = form.cleaned_data["member"], form.cleaned_data["role"]
self.object, created = ClubRole.objects.get_or_create(club=self.request.club, member=member, defaults={"role": role})
if not created and self.object.role != role:
self.object.role = role
self.object.save(update_fields=["role"])
role_obj, created = ClubRole.objects.get_or_create(club=self.request.club, member=member, defaults={"role": role})
if not created and role_obj.role != role:
role_obj.role = role
role_obj.save(update_fields=["role"])
body = _("%(member)s” is now %(role)s.") % {"member": member, "role": self.object.get_role_display()}
body = _("%(member)s” is now %(role)s.") % {"member": member, "role": role_obj.get_role_display()}
notify(self.request, f"s|{_('Role granted')}|{body}")
return redirect(self.get_success_url())
def get_success_url(self):
return reverse("management:role_list")
return redirect("management:role_list")
class ClubRoleRevokeView(ClubAdminRequiredMixin, View):
@@ -875,13 +905,51 @@ class FamilyAddParentView(ClubAdminRequiredMixin, RedirectOnInvalidMixin, FormVi
return redirect("management:family_detail", pk=family.pk)
class PositionListView(ClubAdminRequiredMixin, StubListMixin, ListView):
page_title = _("Positions")
class PositionListView(ClubAdminRequiredMixin, ListView):
template_name = "management/position_list.html"
context_object_name = "positions"
def get_queryset(self):
return Position.objects.filter(club=self.request.club)
class PositionCreateView(ClubAdminRequiredMixin, CreateView):
model = Position
form_class = PositionForm
template_name = "management/position_form.html"
def form_valid(self, form):
form.instance.club = self.request.club
response = super().form_valid(form)
body = _("%(position)s” created.") % {"position": self.object}
notify(self.request, f"s|{_('Position created')}|{body}")
return response
def get_success_url(self):
return reverse("management:position_list")
class PositionUpdateView(ClubAdminRequiredMixin, UpdateView):
model = Position
form_class = PositionForm
template_name = "management/position_form.html"
def get_queryset(self):
return Position.objects.filter(club=self.request.club)
def form_valid(self, form):
response = super().form_valid(form)
body = _("%(position)s” updated.") % {"position": self.object}
notify(self.request, f"s|{_('Position updated')}|{body}")
return response
def get_success_url(self):
return reverse("management:position_list")
def get_context_data(self, **kwargs):
return super().get_context_data(update_view=True, **kwargs)
class RosterListView(ClubStaffRequiredMixin, StubListMixin, ListView):
page_title = _("Roster")