Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend

Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-19 23:34:43 +02:00
parent bff685966d
commit adf1120358
157 changed files with 20342 additions and 4008 deletions

View File

@@ -7,6 +7,7 @@ request cycle.
import uuid
from itertools import groupby
from django.db.models import F
from ninja import Router, Schema
from ninja.errors import HttpError
@@ -84,7 +85,15 @@ def build_roster(team, request) -> RosterOut:
photo = TeamPhoto.objects.filter(team=team, season=season).first()
memberships = TeamMembership.objects.filter(team=team, season=season).select_related("member", "position").order_by("position__ordering", "position__name", "jersey_number")
# nulls_last: a roster spot placed from the Sign-up page (management.forms.
# SignupTeamPlacementForm) has no position yet -- those trail every real
# position group instead of sorting first (NULL's default position varies by
# database backend otherwise).
memberships = (
TeamMembership.objects.filter(team=team, season=season)
.select_related("member", "position")
.order_by(F("position__ordering").asc(nulls_last=True), F("position__name").asc(nulls_last=True), "jersey_number")
)
assignments = team.staff_assignments.filter(season=season).select_related("member", "position").order_by("position__ordering", "position__name", "member__last_name")
# A player's license lives on their club-wide ClubMembership for the season, not on
@@ -92,8 +101,14 @@ def build_roster(team, request) -> RosterOut:
license_by_member_id = dict(ClubMembership.objects.filter(club=team.club, season=season).values_list("member_id", "license"))
# groupby only groups consecutive runs -- relies on the queryset already
# being ordered by position first, which it is.
players = [PositionGroupOut(position=position_name, players=[_to_player_out(m, license_by_member_id) for m in members]) for position_name, members in groupby(memberships, key=lambda m: m.position.name)]
# being ordered by position first, which it is. A member placed with no
# position yet (position_id is None -- see the nulls_last comment above)
# groups together under an empty "" label rather than crashing on
# m.position.name; it's the caller's job to label that group, not this API's.
players = [
PositionGroupOut(position=position_name or "", players=[_to_player_out(m, license_by_member_id) for m in members])
for position_name, members in groupby(memberships, key=lambda m: m.position.name if m.position_id else None)
]
staff = [
StaffMemberOut(id=assignment.member_id, first_name=assignment.member.first_name, last_name=assignment.member.last_name, position=assignment.position.name)
for assignment in assignments

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.6 on 2026-08-17 17:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0009_team_referee_management'),
]
operations = [
migrations.AlterModelOptions(
name='team',
options={'ordering': ['name'], 'verbose_name': 'team', 'verbose_name_plural': 'teams'},
),
migrations.AlterField(
model_name='teammembership',
name='position',
field=models.ForeignKey(blank=True, help_text="Left blank for a member placed on the roster before their position is decided (e.g. from the Sign-up page) -- the team's own manager sets it once known.", limit_choices_to={'staff_position': False}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='team_memberships', to='teams.position', verbose_name='position'),
),
]

View File

@@ -86,7 +86,16 @@ class TeamMembership(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="roster", verbose_name=_("team"))
member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name="team_memberships", verbose_name=_("member"))
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="team_memberships", verbose_name=_("season"))
position = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="team_memberships", verbose_name=_("position"), limit_choices_to={"staff_position": False})
position = models.ForeignKey(
Position,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="team_memberships",
verbose_name=_("position"),
limit_choices_to={"staff_position": False},
help_text=_("Left blank for a member placed on the roster before their position is decided (e.g. from the Sign-up page) -- the team's own manager sets it once known."),
)
jersey_number = models.PositiveSmallIntegerField(_("jersey number"), blank=True, null=True)
is_captain = models.BooleanField(_("is captain"), default=False)

View File

@@ -3,12 +3,13 @@ import datetime
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import TestCase
from django.test import RequestFactory, TestCase
from django.utils import timezone
from club.models import Club, Season
from members.models import Member
from .api import build_roster
from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership, TeamPhoto
@@ -302,3 +303,50 @@ class RefereeProfileModelTests(TeamsTestCase):
self.member.delete()
self.assertFalse(RefereeProfile.objects.filter(pk=profile.pk).exists())
class RosterApiTests(TeamsTestCase):
"""build_roster (teams/api.py) -- called directly, not through Ninja's routing,
same reasoning the module's own docstring gives for splitting it out. A roster
spot placed from the Sign-up page (management.forms.SignupTeamPlacementForm)
has no position yet -- this must group and sort those without crashing on
m.position.name."""
def make_request(self):
return RequestFactory().get("/")
def test_groups_players_by_position(self):
# self.forward (TeamsTestCase's own fixture) sorts second here on purpose --
# confirms ordering follows Position.ordering, not creation/name order.
self.forward.ordering = 2
self.forward.save()
defence = Position.objects.create(club=self.club, name="Defence", ordering=1)
striker = Member.objects.create(first_name="Sam", last_name="Striker")
defender = Member.objects.create(first_name="Dee", last_name="Fender")
TeamMembership.objects.create(team=self.team, member=striker, season=self.season, position=self.forward, jersey_number=9)
TeamMembership.objects.create(team=self.team, member=defender, season=self.season, position=defence, jersey_number=4)
roster = build_roster(self.team, self.make_request())
self.assertEqual([group.position for group in roster.players], ["Defence", "Forward"])
self.assertEqual(roster.players[1].players[0].last_name, "Striker")
def test_a_positionless_member_groups_under_an_empty_label_without_crashing(self):
placed = Member.objects.create(first_name="Noor", last_name="Placed")
TeamMembership.objects.create(team=self.team, member=placed, season=self.season, position=None, jersey_number=None)
roster = build_roster(self.team, self.make_request())
self.assertEqual(len(roster.players), 1)
self.assertEqual(roster.players[0].position, "")
self.assertEqual(roster.players[0].players[0].last_name, "Placed")
def test_positionless_members_sort_after_every_real_position_group(self):
placed_member = Member.objects.create(first_name="Noor", last_name="Placed")
assigned_member = Member.objects.create(first_name="Sam", last_name="Striker")
TeamMembership.objects.create(team=self.team, member=placed_member, season=self.season, position=None)
TeamMembership.objects.create(team=self.team, member=assigned_member, season=self.season, position=self.forward, jersey_number=9)
roster = build_roster(self.team, self.make_request())
self.assertEqual([group.position for group in roster.players], ["Forward", ""])