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:
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()),
|
||||
],
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user