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:
@@ -57,6 +57,7 @@ INSTALLED_APPS = [
|
||||
"events.apps.EventsConfig",
|
||||
"formbuilder.apps.FormbuilderConfig",
|
||||
"shop.apps.ShopConfig",
|
||||
"controlpanel.apps.ControlpanelConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -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
0
controlpanel/__init__.py
Normal file
1
controlpanel/admin.py
Normal file
1
controlpanel/admin.py
Normal file
@@ -0,0 +1 @@
|
||||
# Register your models here.
|
||||
5
controlpanel/apps.py
Normal file
5
controlpanel/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ControlpanelConfig(AppConfig):
|
||||
name = "controlpanel"
|
||||
37
controlpanel/forms.py
Normal file
37
controlpanel/forms.py
Normal 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
|
||||
0
controlpanel/migrations/__init__.py
Normal file
0
controlpanel/migrations/__init__.py
Normal file
27
controlpanel/mixins.py
Normal file
27
controlpanel/mixins.py
Normal 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
1
controlpanel/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your models here.
|
||||
0
controlpanel/services/__init__.py
Normal file
0
controlpanel/services/__init__.py
Normal file
47
controlpanel/services/admins.py
Normal file
47
controlpanel/services/admins.py
Normal 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()
|
||||
92
controlpanel/services/statistics.py
Normal file
92
controlpanel/services/statistics.py
Normal 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()),
|
||||
],
|
||||
},
|
||||
]
|
||||
24
controlpanel/templates/controlpanel/base.html
Normal file
24
controlpanel/templates/controlpanel/base.html
Normal 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 %}
|
||||
36
controlpanel/templates/controlpanel/club_admin_form.html
Normal file
36
controlpanel/templates/controlpanel/club_admin_form.html
Normal 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 %}
|
||||
89
controlpanel/templates/controlpanel/club_detail.html
Normal file
89
controlpanel/templates/controlpanel/club_detail.html
Normal 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 %}
|
||||
33
controlpanel/templates/controlpanel/club_form.html
Normal file
33
controlpanel/templates/controlpanel/club_form.html
Normal 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 %}
|
||||
61
controlpanel/templates/controlpanel/club_list.html
Normal file
61
controlpanel/templates/controlpanel/club_list.html
Normal 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 %}
|
||||
64
controlpanel/templates/controlpanel/dashboard.html
Normal file
64
controlpanel/templates/controlpanel/dashboard.html
Normal 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 %}
|
||||
0
controlpanel/templatetags/__init__.py
Normal file
0
controlpanel/templatetags/__init__.py
Normal file
30
controlpanel/templatetags/ui.py
Normal file
30
controlpanel/templatetags/ui.py
Normal 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
255
controlpanel/tests.py
Normal 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 & 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
17
controlpanel/urls.py
Normal 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
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)
|
||||
2
static/css/app.css
Normal file
2
static/css/app.css
Normal file
File diff suppressed because one or more lines are too long
0
templates/allauth/controlpanel/services/__init__.py
Normal file
0
templates/allauth/controlpanel/services/__init__.py
Normal file
4
templates/allauth/elements/alert.html
Normal file
4
templates/allauth/elements/alert.html
Normal 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>
|
||||
1
templates/allauth/elements/badge.html
Normal file
1
templates/allauth/elements/badge.html
Normal file
@@ -0,0 +1 @@
|
||||
{% load allauth %}<span class="badge badge-neutral">{% slot %}{% endslot %}</span>
|
||||
13
templates/allauth/elements/button.html
Normal file
13
templates/allauth/elements/button.html
Normal 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 %}>
|
||||
4
templates/allauth/elements/button_group.html
Normal file
4
templates/allauth/elements/button_group.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{% load allauth %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% slot %}{% endslot %}
|
||||
</div>
|
||||
5
templates/allauth/elements/details.html
Normal file
5
templates/allauth/elements/details.html
Normal 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>
|
||||
24
templates/allauth/elements/fields.html
Normal file
24
templates/allauth/elements/fields.html
Normal 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 %}
|
||||
9
templates/allauth/elements/form.html
Normal file
9
templates/allauth/elements/form.html
Normal 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>
|
||||
1
templates/allauth/elements/h1.html
Normal file
1
templates/allauth/elements/h1.html
Normal file
@@ -0,0 +1 @@
|
||||
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h1 class="card-title text-2xl">{% slot %}{% endslot %}</h1>
|
||||
1
templates/allauth/elements/h2.html
Normal file
1
templates/allauth/elements/h2.html
Normal file
@@ -0,0 +1 @@
|
||||
{% comment %} djlint:off {% endcomment %}{% load allauth %}<h2 class="text-xl font-semibold">{% slot %}{% endslot %}</h2>
|
||||
1
templates/allauth/elements/hr.html
Normal file
1
templates/allauth/elements/hr.html
Normal file
@@ -0,0 +1 @@
|
||||
{% comment %} djlint:off {% endcomment %}{% load allauth %}<div class="divider"></div>
|
||||
1
templates/allauth/elements/p.html
Normal file
1
templates/allauth/elements/p.html
Normal file
@@ -0,0 +1 @@
|
||||
{% comment %} djlint:off {% endcomment %}{% load allauth %}<p class="text-base-content/80">{% slot %}{% endslot %}</p>
|
||||
8
templates/allauth/elements/panel.html
Normal file
8
templates/allauth/elements/panel.html
Normal 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>
|
||||
6
templates/allauth/elements/table.html
Normal file
6
templates/allauth/elements/table.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% load allauth %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
{% slot %}{% endslot %}
|
||||
</table>
|
||||
</div>
|
||||
15
templates/allauth/layouts/base.html
Normal file
15
templates/allauth/layouts/base.html
Normal 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
86
templates/base.html
Normal 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>
|
||||
Reference in New Issue
Block a user