feat(ui): platform control panel and styled auth screens
Add the `controlpanel` app: a platform-wide (not club-scoped) admin panel for creating clubs, archiving/restoring them, managing club admins, and per-club statistics (members, teams & staff, events, shop). Statistics are annotated in one query so the club list cannot fan out into N+1, and are returned as stat *groups* so growing the domain means adding one entry. Two access rules, both enforced by PlatformStaffRequiredMixin: - staff only (is_staff/is_superuser); anonymous are sent to login, signed-in non-staff get a 403. Staff already need a second factor, so the panel is 2FA-protected for free. - base domain only: the panel manages *all* clubs, so it 404s if the tenant middleware resolved a club from the subdomain. Granting admin to an unknown email creates the account (unusable password — they set one via password reset) and the Member behind it, since a ClubRole hangs off a Member. A member who already holds a role is promoted in place, because there is only one role per member per club. UI is Tailwind + daisyUI. allauth ships an element system, so overriding allauth/layouts/base.html plus ~13 element partials restyles *every* auth and 2FA screen at once — login, signup, password reset, the 2FA challenge, TOTP enrolment, passkeys and recovery codes — rather than templating 20+ pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
128
controlpanel/views.py
Normal file
128
controlpanel/views.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView, View
|
||||
|
||||
from club.models import Club, ClubRole
|
||||
|
||||
from .forms import ClubAdminForm, ClubForm
|
||||
from .mixins import PlatformStaffRequiredMixin
|
||||
from .services.admins import grant_club_admin, revoke_club_admin
|
||||
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
|
||||
|
||||
|
||||
class DashboardView(PlatformStaffRequiredMixin, TemplateView):
|
||||
template_name = "controlpanel/dashboard.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
nav="dashboard",
|
||||
totals=platform_totals(),
|
||||
clubs=clubs_with_totals(Club.objects.active()),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ClubListView(PlatformStaffRequiredMixin, ListView):
|
||||
template_name = "controlpanel/club_list.html"
|
||||
context_object_name = "clubs"
|
||||
|
||||
@property
|
||||
def show_archived(self):
|
||||
return self.request.GET.get("archived") == "1"
|
||||
|
||||
def get_queryset(self):
|
||||
clubs = Club.objects.archived() if self.show_archived else Club.objects.active()
|
||||
search = self.request.GET.get("q", "").strip()
|
||||
if search:
|
||||
clubs = clubs.filter(name__icontains=search)
|
||||
return clubs_with_totals(clubs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", show_archived=self.show_archived, search=self.request.GET.get("q", ""), **kwargs)
|
||||
|
||||
|
||||
class ClubCreateView(PlatformStaffRequiredMixin, CreateView):
|
||||
model = Club
|
||||
form_class = ClubForm
|
||||
template_name = "controlpanel/club_form.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Club “{self.object}” created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
|
||||
class ClubUpdateView(PlatformStaffRequiredMixin, UpdateView):
|
||||
model = Club
|
||||
form_class = ClubForm
|
||||
template_name = "controlpanel/club_form.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Club “{self.object}” updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("controlpanel:club_detail", args=[self.object.pk])
|
||||
|
||||
|
||||
class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
|
||||
model = Club
|
||||
template_name = "controlpanel/club_detail.html"
|
||||
context_object_name = "club"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(
|
||||
nav="clubs",
|
||||
groups=club_statistics(self.object),
|
||||
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class ClubArchiveView(PlatformStaffRequiredMixin, View):
|
||||
"""Clubs are archived, never destroyed — their data (and invoices) are kept."""
|
||||
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
club.archive()
|
||||
messages.warning(request, f"Club “{club}” archived. Its subdomain no longer resolves.")
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
class ClubRestoreView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
club = get_object_or_404(Club, pk=pk)
|
||||
club.restore()
|
||||
messages.success(request, f"Club “{club}” restored.")
|
||||
return redirect("controlpanel:club_detail", pk=club.pk)
|
||||
|
||||
|
||||
class ClubAdminAddView(PlatformStaffRequiredMixin, FormView):
|
||||
form_class = ClubAdminForm
|
||||
template_name = "controlpanel/club_admin_form.html"
|
||||
|
||||
@property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, pk=self.kwargs["pk"])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(nav="clubs", club=self.club, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
role = grant_club_admin(self.club, **form.cleaned_data)
|
||||
messages.success(self.request, f"{role.member} is now an admin of {role.club}. They must set up two-factor authentication before they can sign in.")
|
||||
return redirect("controlpanel:club_detail", pk=self.kwargs["pk"])
|
||||
|
||||
|
||||
class ClubAdminRemoveView(PlatformStaffRequiredMixin, View):
|
||||
def post(self, request, pk, role_pk):
|
||||
role = get_object_or_404(ClubRole, pk=role_pk, club_id=pk, role=ClubRole.Roles.ADMIN)
|
||||
member = role.member
|
||||
revoke_club_admin(role)
|
||||
messages.warning(request, f"{member} is no longer an admin of this club.")
|
||||
return redirect("controlpanel:club_detail", pk=pk)
|
||||
Reference in New Issue
Block a user