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:
21
teams/api.py
21
teams/api.py
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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", ""])
|
||||
|
||||
Reference in New Issue
Block a user