feat: many-to-many player-team assignments (junction table) #164
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api!164
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "124-player-team-junction"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add
player_teamsjunction table to support many-to-many player-team assignments. Players like Seydou Goudiaby can now be rostered on multiple teams simultaneously (e.g. 17U Elite, Select, and Local). Includes Alembic migration with data migration from the old single FK, backwards-compat properties, and updated routes/tests.Changes
src/basketball_api/models.py-- Addplayer_teamsassociation table, replacePlayer.team_idFK withPlayer.teamsM2M relationship, add backwards-compatPlayer.teamandPlayer.team_idpropertiessrc/basketball_api/routes/teams.py-- Update assign/unassign/delete/overview to use junction table instead of direct FKsrc/basketball_api/routes/admin.py-- Update draft board GET (addteam_ids/team_namesfields) and POST to use junction tablesrc/basketball_api/routes/players.py-- Updatejoinedload(Player.team)tojoinedload(Player.teams)src/basketball_api/routes/account.py-- Updatejoinedload(Player.team)tojoinedload(Player.teams)alembic/versions/019_player_teams_junction.py-- Reversible migration: create junction table, migrate existing data, dropteam_idcolumntests/test_player_teams_junction.py-- 8 new tests: multi-team assign, remove-one-keeps-others, idempotent, draft board round-trip, roster, backwards-compat propertiesTest Plan
pytest tests/ -v-- 503+ tests pass (excluding 3 pre-existing broken test files unrelated to this PR)Review Checklist
Player.teamandPlayer.team_idproperties preservedRelated
PR #164 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / PostgreSQL
This PR replaces the single FK
Player.team_id -> teams.idwith a many-to-many junction tableplayer_teams, enabling players like Seydou Goudiaby to be rostered on multiple teams simultaneously. The migration preserves existing data, provides a downgrade path, and adds backwards-compatplayer.team/player.team_idproperties. 16 files changed, 349 additions, 80 deletions.Migration analysis (019_player_teams_junction.py):
INSERT INTO ... SELECT, then drops the FK constraint, index, and column. Order of operations is correct -- data is migrated before the column is dropped.DISTINCT ON (player_id)to pick one team per player (deterministic -- picks lowest team_id), then drops the junction table. This is a lossy but correct downgrade.players_team_id_fkeymatches PostgreSQL's default naming for the FK created in migration 008 viasa.ForeignKey("teams.id"). Verified against008_add_team_model.py.ondelete="CASCADE", which is correct -- deleting a player or team should clean up junction rows.Backwards-compat properties (
Player.teamandPlayer.team_id):account.py,admin.py(roster export, player list),players.py(outbox payload, profile response). All existing callers work correctly through these properties.player.team.coachaccess inplayers.py:93-96will trigger a lazy load on the coach relationship. This is the same behavior as before (team was joinedloaded, coach was always lazy). Not a regression.Relationship loading: Both
Player.teamsandTeam.playersuselazy="selectin". All query paths also usejoinedload(Player.teams)orjoinedload(Team.players). This is slightly redundant (selectin at model level + joinedload at query level) but not harmful -- SQLAlchemy will prefer the explicit query-level strategy.Route updates verified:
teams.py: assign, unassign, delete, overview -- all correctly use junction tableadmin.py: draft board GET addsteam_ids/team_namesfields, draft board POST usesplayer.teams.append()/.clear()players.py:joinedloadupdated toPlayer.teamsaccount.py:joinedloadupdated toPlayer.teamsOutbox payload (
players.py:311):"team_id": player.team_idnow uses the backwards-compat property. This returns the first team's ID, which the outbox worker uses to look up the GroupMe share URL. Semantically correct -- the outbox just needs a team for the GroupMe link.Overview endpoint (
teams.py:293-303): Correctly counts unassigned players by subtracting distinct assigned player IDs from total players. A player on 3 teams counts once in the assigned set.Test coverage: 8 new tests in
test_player_teams_junction.pycovering multi-team assignment, selective removal, idempotent assignment, draft board GET with multi-team data, draft board save, roster overview count, and backwards-compat properties (assigned + unassigned). 10 existing test files updated to use junction table fixtures. Coverage is thorough.BLOCKERS
1. SCOPE CREEP: Unrelated model additions without migration
The
models.pydiff includes changes that do NOT belong to this PR:email_verification = "email_verification"added toEmailTypeenum (line 62)email_verifiedandemail_verified_atcolumns added toParent(lines 164-165)EmailVerificationTokenmodel added (lines 396-407)These belong to PR #162 ("feat: email verification before payment"). There is no Alembic migration in this PR for these additions (no migration creates the
email_verification_tokenstable or adds theemail_verified/email_verified_atcolumns toparents).This means running
alembic upgrade headwith this PR will NOT create these columns/tables, but the ORM models expect them to exist. This will cause runtime errors if any code path touches these fields.Resolution: Remove the
EmailVerificationTokenmodel,email_verified/email_verified_atParent columns, andemail_verificationenum value from this PR. They should land exclusively with PR #162 and their corresponding migration.NITS
Draft board save behavior change (
admin.py:730-732): Whentid is None, the code callsplayer.teams.clear()which removes the player from ALL teams. The old behavior wasplayer.team_id = Nonewhich also cleared the single assignment. The semantics are equivalent for the draft board UI, but documenting this in the docstring would help future readers understand the "unassign from everything" intent.Draft board save only appends (
admin.py:748-750, 768-770): The save endpoint only adds a new team association; it never removes existing ones. If the UI sendsteam_id: 3for a player already on teams 1 and 2, the player ends up on teams 1, 2, and 3. This is correct for a many-to-many model where the draft board only adds, and theNonecase handles full removal. Just worth confirming this matches the frontend's expectations.Redundant loading strategies:
Player.teamshaslazy="selectin"at the model level, but all query paths also usejoinedload(Player.teams). Consider dropping the explicitjoinedloadcalls (since selectin will handle it) or dropping the model-levellazy="selectin"and relying on query-level loading. Not urgent, just minor inconsistency.total_players_assignedin overview (teams.py:294):total_assigned = sum(len(t.players) for t in teams)counts total roster slots, not unique players. A player on 3 teams counts as 3 towardtotal_players_assigned. Meanwhiletotal_players_unassignedcounts unique unassigned players. The semantics are intentionally different but could confuse API consumers. Consider documenting this in the response schema or renaming tototal_roster_slots.SOP COMPLIANCE
124-player-team-junctionmatches issue #124)EmailVerificationToken,email_verified/email_verified_at, andemail_verificationenum value are scope creep from issue #162PROCESS OBSERVATIONS
VERDICT: NOT APPROVED
Reason: Scope creep in
models.py-- unrelatedEmailVerificationTokenmodel,email_verified/email_verified_atParent columns, andemail_verificationenum value have no corresponding Alembic migration in this PR. These belong to PR #162 and must be removed from this PR to prevent ORM/DB schema mismatch at runtime. Once those hunks are reverted, this is ready to merge.PR #164 Review (Re-review after fix commit)
BLOCKER RESOLUTION
Previous review found 1 blocker: PR #162 contamination (EmailVerificationToken model, email_verified/email_verified_at columns on Parent, email_verification enum value, OAuthToken model).
Verification results:
EmailVerificationTokenmodel in models.py -- confirmed cleanemail_verified/email_verified_atcolumns on Parent -- confirmed cleanemail_verificationenum value -- confirmed cleanOAuthTokenmodel -- confirmed cleanplayer_teamsjunction table + backwards-compat properties ARE present -- confirmed correctsrc/andalembic/directories foremail_verif,OAuthToken,EmailVerificationToken-- zero matchesThe blocker is fully resolved.
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Pydantic
Migration (019_player_teams_junction.py):
DISTINCT ONto pick one team per player. Correct.(player_id, team_id)with CASCADE deletes on both FKs. Correct.INSERT INTO ... SELECT ... WHERE team_id IS NOT NULL.Model changes (models.py):
player_teamsassociation table defined at module level usingTable()-- standard SQLAlchemy M2M pattern.Player.teamsrelationship withsecondary=player_teams,lazy="selectin". Good choice for avoiding N+1 on the read path.Team.playersmirrors with same secondary and back_populates. Correct.Player.teamandPlayer.team_idbackwards-compat properties return first team or None. Clean.Route changes:
teams.py: assign_players uses idempotent set-check (existing_player_ids), unassign_player iteratesteam.playersto find the player, delete_team clears junction before delete. All correct.admin.py: draft board GET returns bothteam_id/team(compat) andteam_ids/team_names(new M2M fields). Save handles three cases (unassign, temp team, existing team) usingplayer.teams.clear()/player.teams.append().players.py,account.py:joinedload(Player.team)updated tojoinedload(Player.teams).team_overview: unassigned count now uses a subquery onplayer_teamswith.distinct()instead ofPlayer.team_id.is_(None). Correct for M2M.Test coverage (test_player_teams_junction.py): 8 new tests across 4 test classes:
TestMultiTeamAssignment: multi-team assign, selective removal, idempotent assignmentTestDraftBoardMultiTeam: draft board shows multi-team data, save assigns correctlyTestRosterMultiTeam: overview counts correct with multi-team playerTestBackwardsCompat:.teamproperty returns first team, unassigned player returns None10 existing test files updated to use junction table pattern (
player.teams.append(team)instead ofteam_id=team.id).BLOCKERS
None. Previous blocker resolved.
NITS
total_players_assignedsemantics shift (teams.pyline 294):total_assigned = sum(len(t.players) for t in teams)now counts total roster slots (a player on 3 teams counts as 3), whileunassignedcounts distinct players not on any team. This is arguably correct (assigned = "how many slots are filled") but the two numbers no longer add up to total players. Consider documenting this in the docstring or renaming tototal_roster_slotsto avoid confusion downstream. Non-blocking since the test explicitly asserts this behavior.admin_teams_saveunassign clears ALL teams (admin.pyline 731-733): Whenteam_idisNone,player.teams.clear()removes the player from ALL teams. This matches the old behavior (single FK = set to None) but in a multi-team world, the draft board save may want to unassign from a specific team rather than all. Flagging as a design consideration, not a bug -- the current behavior matches what the UI sends.Minor: unused
total_assignedvariable is computed at line 294 ofteams.pybut never used for any downstream logic beyond the response. It is used in the response (total_players_assigned), so this is fine -- just noting it for clarity.SOP COMPLIANCE
124-player-team-junctionmatches issue #124PROCESS OBSERVATIONS
Player.teamandPlayer.team_idproperties prevent breaking existing code that expects the old single-FK interface.VERDICT: APPROVED