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:
2026-07-13 15:29:24 +02:00
parent 848a8578de
commit 6f66df3ba4
41 changed files with 1130 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ INSTALLED_APPS = [
"events.apps.EventsConfig",
"formbuilder.apps.FormbuilderConfig",
"shop.apps.ShopConfig",
"controlpanel.apps.ControlpanelConfig",
]
MIDDLEWARE = [

View File

@@ -15,4 +15,5 @@ urlpatterns = [
path("admin/login/", RedirectView.as_view(pattern_name="account_login", query_string=True), name="admin_login_redirect"),
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("controlpanel/", include("controlpanel.urls")),
]

0
controlpanel/__init__.py Normal file
View File

1
controlpanel/admin.py Normal file
View File

@@ -0,0 +1 @@
# Register your models here.

5
controlpanel/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class ControlpanelConfig(AppConfig):
name = "controlpanel"

37
controlpanel/forms.py Normal file
View File

@@ -0,0 +1,37 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from club.models import Club
from .services.admins import find_member_by_email
class ClubForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name", "slug"]
help_texts = {"slug": _("Drives the club's subdomain. Left blank, it is derived from the name.")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["slug"].required = False
class ClubAdminForm(forms.Form):
"""Grant club-admin rights to an email address, creating the person if new."""
email = forms.EmailField(label=_("Email address"), help_text=_("If this email has no account yet, one is created and they set a password via the reset link."))
first_name = forms.CharField(label=_("First name"), required=False)
last_name = forms.CharField(label=_("Last name"), required=False)
def clean(self):
cleaned = super().clean()
email = cleaned.get("email")
# Only a brand-new person needs a name; an existing member already has one.
if email and find_member_by_email(email) is None:
for field in ("first_name", "last_name"):
if not cleaned.get(field):
self.add_error(field, _("Required: this email has no account yet."))
return cleaned

View File

27
controlpanel/mixins.py Normal file
View File

@@ -0,0 +1,27 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.http import Http404
class PlatformStaffRequiredMixin(UserPassesTestMixin):
"""Gate for the platform control panel.
Two rules:
* **Staff only.** ``is_staff`` or ``is_superuser``. Anonymous visitors are
sent to the login page; signed-in non-staff get a 403 (Django's
AccessMixin already distinguishes those two cases). Staff must also hold a
second factor — ``RequireMFAMiddleware`` enforces that, so the panel is
2FA-protected for free.
* **Base domain only.** The panel manages *all* clubs, so it must not be
reachable from inside one. If the tenant middleware resolved a club from
the subdomain, the panel does not exist here.
"""
def dispatch(self, request, *args, **kwargs):
if getattr(request, "club", None) is not None:
raise Http404("The control panel is not available on a club subdomain.")
return super().dispatch(request, *args, **kwargs)
def test_func(self):
user = self.request.user
return user.is_staff or user.is_superuser

1
controlpanel/models.py Normal file
View File

@@ -0,0 +1 @@
# Create your models here.

View File

View File

@@ -0,0 +1,47 @@
"""Granting and revoking club-admin rights from the platform panel."""
from django.contrib.auth import get_user_model
from django.db import transaction
from club.models import ClubRole
from members.models import Member
User = get_user_model()
def find_member_by_email(email):
"""The Member behind a login email, if that account exists at all."""
return Member.objects.filter(user__email__iexact=email).first()
@transaction.atomic
def grant_club_admin(club, email, first_name="", last_name=""):
"""Make the holder of ``email`` an ADMIN of ``club``, creating them if new.
A ClubRole hangs off a Member, and a Member optionally links to a User — so
an admin who has never existed needs both. The account is created without a
usable password; they set one via the password-reset flow.
"""
email = email.lower()
user, created_user = User.objects.get_or_create(email=email, defaults={"is_active": True})
if created_user:
user.set_unusable_password()
user.save(update_fields=["password"])
member, _ = Member.objects.get_or_create(
user=user,
defaults={"first_name": first_name, "last_name": last_name},
)
# One role per member per club, so promote rather than add a second row.
role, created_role = ClubRole.objects.get_or_create(club=club, member=member, defaults={"role": ClubRole.Roles.ADMIN})
if not created_role and role.role != ClubRole.Roles.ADMIN:
role.role = ClubRole.Roles.ADMIN
role.save(update_fields=["role"])
return role
def revoke_club_admin(role):
"""Remove admin rights. The membership-status sync never re-adds ADMIN."""
role.delete()

View File

@@ -0,0 +1,92 @@
"""Platform and per-club statistics.
``club_statistics`` returns a list of stat *groups*, so growing the model later
means adding an entry here and nothing else. ``clubs_with_totals`` annotates in
a single query — the club list must not fan out into N+1.
"""
from decimal import Decimal
from django.db.models import Count, Q, Sum
from django.utils import timezone
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from members.models import Member
from shop.models import Cart, Order
from teams.models import StaffAssignment, Team, TeamMembership
ZERO = Decimal("0.00")
PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED)
OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID)
def clubs_with_totals(queryset=None):
"""Clubs annotated with headline counts (one query, no N+1)."""
clubs = Club.objects.all() if queryset is None else queryset
return clubs.annotate(
member_count=Count("clubmemberships__member", distinct=True),
team_count=Count("teams", distinct=True),
event_count=Count("events", distinct=True),
admin_count=Count("clubroles", filter=Q(clubroles__role=ClubRole.Roles.ADMIN), distinct=True),
)
def platform_totals():
return {
"clubs": Club.objects.active().count(),
"archived_clubs": Club.objects.archived().count(),
"members": Member.objects.count(),
"admins": ClubRole.objects.filter(role=ClubRole.Roles.ADMIN).count(),
}
def _money(queryset):
return queryset.aggregate(total=Sum("total"))["total"] or ZERO
def club_statistics(club):
"""Stat groups for one club. Add new groups here as the domain grows."""
season = Season.covering(club, timezone.localdate())
now = timezone.now()
memberships = ClubMembership.objects.filter(club=club)
events = Event.objects.filter(club=club)
orders = Order.objects.filter(club=club)
return [
{
"title": "Members",
"stats": [
("Members", memberships.values("member").distinct().count()),
("Active this season", memberships.filter(season=season, status=ClubMembership.StatusChoices.ACTIVE).count() if season else 0),
("Pending", memberships.filter(status=ClubMembership.StatusChoices.PENDING).count()),
("Lapsed", memberships.filter(status=ClubMembership.StatusChoices.LAPSED).count()),
],
},
{
"title": "Teams & staff",
"stats": [
("Teams", Team.objects.filter(club=club).count()),
("Players this season", TeamMembership.objects.filter(team__club=club, season=season).count() if season else 0),
("Staff this season", StaffAssignment.objects.filter(team__club=club, season=season).count() if season else 0),
],
},
{
"title": "Events",
"stats": [
("Upcoming", events.filter(start__gte=now).count()),
("This season", events.filter(season=season).count() if season else 0),
],
},
{
"title": "Shop",
"stats": [
("Orders", orders.count()),
("Revenue", _money(orders.filter(status__in=PAID_STATUSES))),
("Outstanding", _money(orders.filter(status__in=OWED_STATUSES))),
("Open carts", Cart.objects.filter(club=club, status=Cart.CartStatus.OPEN).count()),
],
},
]

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}
{% block panel_title %}Control panel{% endblock panel_title %} · ClubManager
{% endblock title %}
{% block main %}
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">
{% block heading %}Control panel{% endblock heading %}
</h1>
{% block subheading %}{% endblock subheading %}
</div>
<div class="flex gap-2">
{% block actions %}{% endblock actions %}
</div>
</div>
<div role="tablist" class="tabs-boxed tabs mb-6 w-fit">
<a role="tab" href="{% url 'controlpanel:dashboard' %}" class="tab {% if nav == 'dashboard' %}tab-active{% endif %}">Dashboard</a>
<a role="tab" href="{% url 'controlpanel:club_list' %}" class="tab {% if nav == 'clubs' %}tab-active{% endif %}">Clubs</a>
</div>
{% block panel %}{% endblock panel %}
{% endblock main %}

View File

@@ -0,0 +1,36 @@
{% extends "controlpanel/base.html" %}
{% load ui %}
{% block heading %}Add an admin to {{ club }}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<div class="alert alert-info">
<span>A club admin can manage everything in this club. They will be required to set up two-factor authentication before they can sign in.</span>
</div>
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:club_detail' club.pk %}">Cancel</a>
<button class="btn btn-primary" type="submit">Grant admin</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,89 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{{ club.name }}{% endblock heading %}
{% block subheading %}
<p class="text-sm opacity-70">
{{ club.slug }}
{% if club.is_archived %}
<span class="badge badge-warning badge-sm ml-2">Archived</span>
{% endif %}
</p>
{% endblock subheading %}
{% block actions %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_update' club.pk %}">Edit</a>
{% if club.is_archived %}
<form method="post" action="{% url 'controlpanel:club_restore' club.pk %}">
{% csrf_token %}
<button class="btn btn-success" type="submit">Restore</button>
</form>
{% else %}
<form method="post" action="{% url 'controlpanel:club_archive' club.pk %}">
{% csrf_token %}
<button class="btn btn-warning" type="submit">Archive</button>
</form>
{% endif %}
{% endblock actions %}
{% block panel %}
{% if club.is_archived %}
<div class="alert alert-warning mb-6">
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div>
{% endif %}
<div class="mb-6 grid gap-4 md:grid-cols-2">
{% for group in groups %}
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{{ group.title }}</h2>
<dl class="divide-y divide-base-200">
{% for label, value in group.stats %}
<div class="flex items-center justify-between py-2">
<dt class="text-sm opacity-70">{{ label }}</dt>
<dd class="font-semibold tabular-nums">{{ value }}</dd>
</div>
{% endfor %}
</dl>
</div>
</div>
{% endfor %}
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">Club admins</h2>
<a class="btn btn-primary btn-sm" href="{% url 'controlpanel:club_admin_add' club.pk %}">Add admin</a>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th></th>
</tr>
</thead>
<tbody>
{% for role in admins %}
<tr>
<td>{{ role.member }}</td>
<td>{{ role.member.user.email|default:"—" }}</td>
<td class="text-right">
<form method="post" action="{% url 'controlpanel:club_admin_remove' club.pk role.pk %}">
{% csrf_token %}
<button class="btn btn-ghost btn-xs text-error" type="submit">Remove</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center opacity-60">No admins yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,33 @@
{% extends "controlpanel/base.html" %}
{% load ui %}
{% block heading %}{% if object %}Edit {{ object }}{% else %}New club{% endif %}{% endblock heading %}
{% block panel %}
<div class="card max-w-xl bg-base-100 shadow">
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for error in form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in form %}
<div class="form-control my-3 w-full">
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}
<div class="card-actions justify-end pt-2">
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,61 @@
{% extends "controlpanel/base.html" %}
{% block heading %}{% if show_archived %}Archived clubs{% else %}Clubs{% endif %}{% endblock heading %}
{% block actions %}
{% if show_archived %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}">Active clubs</a>
{% else %}
<a class="btn btn-ghost" href="{% url 'controlpanel:club_list' %}?archived=1">Archived</a>
{% endif %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
{% endblock actions %}
{% block panel %}
<form method="get" class="mb-4 flex gap-2">
{% if show_archived %}<input type="hidden" name="archived" value="1">{% endif %}
<input type="search"
name="q"
value="{{ search }}"
placeholder="Search clubs…"
class="input input-bordered w-full max-w-xs">
<button class="btn" type="submit">Search</button>
</form>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Members</th>
<th>Teams</th>
<th>Events</th>
<th>Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}</div>
</td>
<td>{{ club.member_count }}</td>
<td>{{ club.team_count }}</td>
<td>{{ club.event_count }}</td>
<td>{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">
{% if show_archived %}No archived clubs.{% else %}No clubs yet.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

@@ -0,0 +1,64 @@
{% extends "controlpanel/base.html" %}
{% block heading %}Platform overview{% endblock heading %}
{% block actions %}
<a class="btn btn-primary" href="{% url 'controlpanel:club_create' %}">New club</a>
{% endblock actions %}
{% block panel %}
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
<div class="stat-value">{{ totals.clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Archived</div>
<div class="stat-value">{{ totals.archived_clubs }}</div>
</div>
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Clubs</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Club</th>
<th>Members</th>
<th>Teams</th>
<th>Events</th>
<th>Admins</th>
</tr>
</thead>
<tbody>
{% for club in clubs %}
<tr>
<td>
<a class="link link-hover font-medium" href="{% url 'controlpanel:club_detail' club.pk %}">{{ club.name }}</a>
<div class="text-xs opacity-60">{{ club.slug }}</div>
</td>
<td>{{ club.member_count }}</td>
<td>{{ club.team_count }}</td>
<td>{{ club.event_count }}</td>
<td>{{ club.admin_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center opacity-60">No clubs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock panel %}

View File

View File

@@ -0,0 +1,30 @@
"""Template helpers for the daisyUI-based UI."""
from django import forms, template
register = template.Library()
#: daisyUI class per widget kind. Django renders widgets unstyled, so this is
#: what makes every allauth and control-panel form field look right.
WIDGET_CLASSES = (
(forms.CheckboxInput, "checkbox"),
(forms.RadioSelect, "radio"),
(forms.Select, "select select-bordered w-full"),
(forms.Textarea, "textarea textarea-bordered w-full"),
)
DEFAULT_WIDGET_CLASS = "input input-bordered w-full"
@register.filter
def daisy(field):
"""Render a bound form field with the right daisyUI classes."""
widget = field.field.widget
css = next((css for widget_type, css in WIDGET_CLASSES if isinstance(widget, widget_type)), DEFAULT_WIDGET_CLASS)
classes = [widget.attrs.get("class", ""), css]
if field.errors:
classes.append(f"{css.split()[0]}-error")
attrs = dict(widget.attrs)
attrs["class"] = " ".join(part for part in classes if part)
return field.as_widget(attrs=attrs)

255
controlpanel/tests.py Normal file
View File

@@ -0,0 +1,255 @@
from decimal import Decimal
from allauth.mfa.models import Authenticator
from django import forms
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from club.models import Club, ClubMembership, ClubRole, Season
from members.models import Member
from shop.models import Order
from teams.models import Position, Team, TeamMembership
from .services.admins import grant_club_admin
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
from .templatetags.ui import daisy
User = get_user_model()
def enrol_mfa(user):
return Authenticator.objects.create(user=user, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
class ControlPanelTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.staff = User.objects.create_user(email="root@example.com", password="pw-secret-123", is_staff=True)
# Staff must hold a second factor, else RequireMFAMiddleware redirects.
enrol_mfa(self.staff)
self.client.force_login(self.staff)
class AccessTests(ControlPanelTestBase):
def test_staff_can_reach_the_panel(self):
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200)
def test_anonymous_is_sent_to_login(self):
self.client.logout()
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("account_login"), response.url)
def test_signed_in_non_staff_gets_403(self):
self.client.force_login(User.objects.create_user(email="member@example.com", password="pw-secret-123"))
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 403)
def test_superuser_can_reach_the_panel(self):
root = User.objects.create_superuser(email="super@example.com", password="pw-secret-123")
enrol_mfa(root)
self.client.force_login(root)
self.assertEqual(self.client.get(reverse("controlpanel:dashboard")).status_code, 200)
@override_settings(CLUBMANAGER_BASE_DOMAIN="clubmanager.app", ALLOWED_HOSTS=[".clubmanager.app"])
def test_panel_does_not_exist_on_a_club_subdomain(self):
# It manages *all* clubs, so it must not be reachable from inside one.
Club.objects.create(name="Rival FC", slug="rival-fc")
response = self.client.get(reverse("controlpanel:dashboard"), headers={"host": "rival-fc.clubmanager.app"})
self.assertEqual(response.status_code, 404)
def test_staff_without_a_second_factor_is_sent_to_enrolment(self):
self.client.force_login(User.objects.create_user(email="nomfa@example.com", password="pw-secret-123", is_staff=True))
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("mfa_index"))
class ClubManagementTests(ControlPanelTestBase):
def test_dashboard_lists_clubs(self):
self.assertContains(self.client.get(reverse("controlpanel:dashboard")), "Ajax United")
def test_create_club_derives_the_slug(self):
response = self.client.post(reverse("controlpanel:club_create"), {"name": "New Club", "slug": ""})
club = Club.objects.get(name="New Club")
self.assertEqual(club.slug, "new-club")
self.assertRedirects(response, reverse("controlpanel:club_detail", args=[club.pk]))
def test_update_club(self):
self.client.post(reverse("controlpanel:club_update", args=[self.club.pk]), {"name": "Renamed", "slug": self.club.slug})
self.club.refresh_from_db()
self.assertEqual(self.club.name, "Renamed")
def test_club_detail_shows_statistics(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Members")
self.assertContains(response, "Teams &amp; staff")
self.assertContains(response, "Shop")
def test_archive_then_restore(self):
self.client.post(reverse("controlpanel:club_archive", args=[self.club.pk]))
self.club.refresh_from_db()
self.assertTrue(self.club.is_archived)
self.client.post(reverse("controlpanel:club_restore", args=[self.club.pk]))
self.club.refresh_from_db()
self.assertFalse(self.club.is_archived)
def test_list_separates_active_from_archived(self):
Club.objects.create(name="Gone FC").archive()
active = self.client.get(reverse("controlpanel:club_list"))
self.assertContains(active, "Ajax United")
self.assertNotContains(active, "Gone FC")
archived = self.client.get(reverse("controlpanel:club_list"), {"archived": "1"})
self.assertContains(archived, "Gone FC")
self.assertNotContains(archived, "Ajax United")
def test_list_search(self):
Club.objects.create(name="Rival FC")
response = self.client.get(reverse("controlpanel:club_list"), {"q": "Ajax"})
self.assertContains(response, "Ajax United")
self.assertNotContains(response, "Rival FC")
class ClubAdminManagementTests(ControlPanelTestBase):
def add_admin(self, **data):
return self.client.post(reverse("controlpanel:club_admin_add", args=[self.club.pk]), data)
def test_granting_admin_to_a_new_email_creates_the_account(self):
self.add_admin(email="New.Admin@Example.com", first_name="Ada", last_name="Min")
user = User.objects.get(email="new.admin@example.com")
self.assertFalse(user.has_usable_password()) # they set one via password reset
self.assertEqual(ClubRole.objects.get(club=self.club, member__user=user).role, ClubRole.Roles.ADMIN)
def test_a_new_email_must_come_with_a_name(self):
response = self.add_admin(email="nameless@example.com", first_name="", last_name="")
self.assertEqual(response.status_code, 200)
self.assertFalse(ClubRole.objects.exists())
self.assertFormError(response.context["form"], "first_name", "Required: this email has no account yet.")
def test_an_existing_member_is_promoted_rather_than_duplicated(self):
user = User.objects.create_user(email="existing@example.com", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="Ex", last_name="Isting")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER)
self.add_admin(email="existing@example.com")
# One role per member per club, so the MEMBER role is upgraded in place.
self.assertEqual(ClubRole.objects.get(club=self.club, member=member).role, ClubRole.Roles.ADMIN)
self.assertEqual(Member.objects.filter(user=user).count(), 1)
def test_granting_twice_is_idempotent(self):
self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min")
self.add_admin(email="ada@example.com", first_name="Ada", last_name="Min")
self.assertEqual(ClubRole.objects.filter(club=self.club).count(), 1)
def test_remove_admin(self):
role = grant_club_admin(self.club, "ada@example.com", "Ada", "Min")
self.client.post(reverse("controlpanel:club_admin_remove", args=[self.club.pk, role.pk]))
self.assertFalse(ClubRole.objects.filter(pk=role.pk).exists())
def test_admins_are_listed_on_the_club(self):
grant_club_admin(self.club, "ada@example.com", "Ada", "Min")
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "Ada Min")
self.assertContains(response, "ada@example.com")
class StatisticsTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today)
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
def groups_for(self, club):
return {group["title"]: dict(group["stats"]) for group in club_statistics(club)}
def test_platform_totals_split_active_and_archived(self):
Club.objects.create(name="Gone FC").archive()
totals = platform_totals()
self.assertEqual(totals["clubs"], 1)
self.assertEqual(totals["archived_clubs"], 1)
def test_club_totals_are_annotated(self):
Team.objects.create(club=self.club, name="First", short_name="1st")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
club = clubs_with_totals().get(pk=self.club.pk)
self.assertEqual(club.member_count, 1)
self.assertEqual(club.team_count, 1)
self.assertEqual(club.admin_count, 0)
def test_club_statistics_count_members_teams_and_money(self):
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season, status=ClubMembership.StatusChoices.ACTIVE)
team = Team.objects.create(club=self.club, name="First", short_name="1st")
position = Position.objects.create(club=self.club, name="Forward", short_name="FW")
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("20.00"), status=Order.OrderStatus.PENDING)
groups = self.groups_for(self.club)
self.assertEqual(groups["Members"]["Active this season"], 1)
self.assertEqual(groups["Teams & staff"]["Players this season"], 1)
self.assertEqual(groups["Shop"]["Revenue"], Decimal("50.00"))
self.assertEqual(groups["Shop"]["Outstanding"], Decimal("20.00"))
def test_statistics_cope_with_no_current_season(self):
# A brand-new club has no season covering today; it must not blow up.
groups = self.groups_for(Club.objects.create(name="Seasonless FC"))
self.assertEqual(groups["Members"]["Active this season"], 0)
self.assertEqual(groups["Teams & staff"]["Players this season"], 0)
self.assertEqual(groups["Events"]["This season"], 0)
self.assertEqual(groups["Shop"]["Revenue"], Decimal("0.00"))
class SampleForm(forms.Form):
text = forms.CharField()
choice = forms.ChoiceField(choices=[("a", "A")])
note = forms.CharField(widget=forms.Textarea)
agree = forms.BooleanField()
class DaisyFilterTests(TestCase):
def rendered(self, field_name, data=None):
form = SampleForm(data)
if data is not None:
form.is_valid()
return str(daisy(form[field_name]))
def test_widgets_get_the_right_daisyui_class(self):
self.assertIn("input input-bordered", self.rendered("text"))
self.assertIn("select select-bordered", self.rendered("choice"))
self.assertIn("textarea textarea-bordered", self.rendered("note"))
self.assertIn("checkbox", self.rendered("agree"))
def test_invalid_fields_get_an_error_class(self):
self.assertIn("input-error", self.rendered("text", data={}))

17
controlpanel/urls.py Normal file
View File

@@ -0,0 +1,17 @@
from django.urls import path
from . import views
app_name = "controlpanel"
urlpatterns = [
path("", views.DashboardView.as_view(), name="dashboard"),
path("clubs/", views.ClubListView.as_view(), name="club_list"),
path("clubs/new/", views.ClubCreateView.as_view(), name="club_create"),
path("clubs/<uuid:pk>/", views.ClubDetailView.as_view(), name="club_detail"),
path("clubs/<uuid:pk>/edit/", views.ClubUpdateView.as_view(), name="club_update"),
path("clubs/<uuid:pk>/archive/", views.ClubArchiveView.as_view(), name="club_archive"),
path("clubs/<uuid:pk>/restore/", views.ClubRestoreView.as_view(), name="club_restore"),
path("clubs/<uuid:pk>/admins/add/", views.ClubAdminAddView.as_view(), name="club_admin_add"),
path("clubs/<uuid:pk>/admins/<uuid:role_pk>/remove/", views.ClubAdminRemoveView.as_view(), name="club_admin_remove"),
]

128
controlpanel/views.py Normal file
View 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)

2
static/css/app.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
{% load allauth %}
<div class="alert {% if attrs.level == 'error' %}alert-error{% elif attrs.level == 'warning' %}alert-warning{% elif attrs.level == 'success' %}alert-success{% else %}alert-info{% endif %} my-2">
<span>{% slot message %}{% endslot %}</span>
</div>

View File

@@ -0,0 +1 @@
{% load allauth %}<span class="badge badge-neutral">{% slot %}{% endslot %}</span>

View File

@@ -0,0 +1,13 @@
{% load allauth %}
{% comment %} djlint:off {% endcomment %}
<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %}
class="btn {% if attrs.tags and 'danger' in attrs.tags %}btn-error{% elif attrs.tags and 'secondary' in attrs.tags %}btn-ghost{% elif attrs.tags and 'link' in attrs.tags %}btn-link{% else %}btn-primary{% endif %}"
{% if attrs.form %}form="{{ attrs.form }}"{% endif %}
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}
{% if attrs.name %}name="{{ attrs.name }}"{% endif %}
{% if attrs.value %}value="{{ attrs.value }}"{% endif %}
{% if attrs.type %}type="{{ attrs.type }}"{% endif %}
>
{% slot %}
{% endslot %}
</{% if attrs.href %}a{% else %}button{% endif %}>

View File

@@ -0,0 +1,4 @@
{% load allauth %}
<div class="flex flex-wrap gap-2">
{% slot %}{% endslot %}
</div>

View File

@@ -0,0 +1,5 @@
{% load allauth %}
<details class="collapse-arrow collapse border border-base-300 bg-base-100 my-2" {% if attrs.open %}open{% endif %}>
<summary class="collapse-title font-medium">{% slot summary %}{% endslot %}</summary>
<div class="collapse-content">{% slot body %}{% endslot %}</div>
</details>

View File

@@ -0,0 +1,24 @@
{% load ui %}
{% for field in attrs.form.hidden_fields %}{{ field }}{% endfor %}
{% for error in attrs.form.non_field_errors %}
<div class="alert alert-error my-2">
<span>{{ error }}</span>
</div>
{% endfor %}
{% for field in attrs.form.visible_fields %}
<div class="form-control my-3 w-full">
{% if field.field.widget.input_type == "checkbox" %}
<label class="label cursor-pointer justify-start gap-3" for="{{ field.id_for_label }}">
{{ field|daisy }}
<span class="label-text">{{ field.label }}</span>
</label>
{% else %}
<label class="label" for="{{ field.id_for_label }}">
<span class="label-text">{{ field.label }}</span>
</label>
{{ field|daisy }}
{% endif %}
{% if field.help_text %}<span class="label-text-alt mt-1 text-base-content/70">{{ field.help_text }}</span>{% endif %}
{% for error in field.errors %}<span class="label-text-alt mt-1 text-error">{{ error }}</span>{% endfor %}
</div>
{% endfor %}

View File

@@ -0,0 +1,9 @@
{% load allauth %}
<form method="{{ attrs.method }}"
{% if attrs.action %}action="{{ attrs.action }}"{% endif %}
class="space-y-2">
{% slot body %}{% endslot %}
<div class="card-actions justify-end pt-3">
{% slot actions %}{% endslot %}
</div>
</form>

View File

@@ -0,0 +1 @@
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h1 class="card-title text-2xl">{% slot %}{% endslot %}</h1>

View File

@@ -0,0 +1 @@
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h2 class="text-xl font-semibold">{% slot %}{% endslot %}</h2>

View File

@@ -0,0 +1 @@
{% comment %} djlint:off {% endcomment %}{% load allauth %}<div class="divider"></div>

View File

@@ -0,0 +1 @@
{% comment %} djlint:off {% endcomment %}{% load allauth %}<p class="text-base-content/80">{% slot %}{% endslot %}</p>

View File

@@ -0,0 +1,8 @@
{% load allauth %}
<div class="card border border-base-300 bg-base-100 my-4">
<div class="card-body gap-2">
{% if slots.title %}<h2 class="card-title text-lg">{% slot title %}{% endslot %}</h2>{% endif %}
<div>{% slot body %}{% endslot %}</div>
{% if slots.actions %}<div class="card-actions justify-end">{% slot actions %}{% endslot %}</div>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% load allauth %}
<div class="overflow-x-auto">
<table class="table">
{% slot %}{% endslot %}
</table>
</div>

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}
{% block head_title %}{% endblock head_title %} · ClubManager
{% endblock title %}
{% block main %}
<div class="flex justify-center">
<div class="card w-full max-w-xl bg-base-100 shadow">
<div class="card-body prose max-w-none">
{% block content %}{% endblock content %}
</div>
</div>
</div>
{% endblock main %}

86
templates/base.html Normal file
View File

@@ -0,0 +1,86 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block title %}ClubManager{% endblock title %}
</title>
{# Apply the stored theme before first paint, otherwise the page flashes
the wrong colours. With no stored preference we set nothing, so
daisyUI's `dark --prefersdark` follows the OS. #}
<script>
(() => {
const stored = localStorage.getItem("theme");
if (stored) document.documentElement.setAttribute("data-theme", stored);
})();
</script>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="min-h-screen bg-base-200">
<div class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost text-xl" href="/">ClubManager</a>
{% if user.is_authenticated and user.is_staff %}
<a class="btn btn-ghost btn-sm" href="{% url 'controlpanel:dashboard' %}">Control panel</a>
{% endif %}
</div>
<div class="flex-none gap-2">
<button class="btn btn-ghost btn-circle"
aria-label="Toggle theme"
data-theme-toggle
type="button">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
</button>
{% if user.is_authenticated %}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">{{ user }}</div>
<ul tabindex="0"
class="menu dropdown-content z-10 mt-2 w-56 rounded-box bg-base-100 p-2 shadow">
<li>
<a href="{% url 'mfa_index' %}">Two-factor authentication</a>
</li>
<li>
<a href="{% url 'account_change_password' %}">Change password</a>
</li>
<li>
<a href="{% url 'account_logout' %}">Sign out</a>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-primary btn-sm" href="{% url 'account_login' %}">Sign in</a>
{% endif %}
</div>
</div>
{% if messages %}
<div class="mx-auto mt-4 w-full max-w-5xl space-y-2 px-4">
{% for message in messages %}
<div class="alert {% if message.tags == 'error' %}alert-error{% elif message.tags == 'warning' %}alert-warning{% elif message.tags == 'success' %}alert-success{% else %}alert-info{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<main class="mx-auto w-full max-w-5xl p-4">
{% block main %}{% endblock main %}
</main>
<script>
// No stored preference means "follow the OS", so read the effective theme
// from the OS when nothing is set yet.
document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const current =
document.documentElement.getAttribute("data-theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
});
});
</script>
</body>
</html>