feat: Team model + assignment endpoints (Phase 10a) #82
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!82
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "81-team-model-assignment-endpoints"
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
Adds the Team entity as the bridge between players and coaches. Teams have division, age_group, and an optional coach assignment. Players get a nullable
team_idFK. All team management is admin-only; coaches can view their own teams via/api/teams/mine.Changes
src/basketball_api/models.py-- AddedAgeGroupenum,Teammodel (id, tenant_id, name, division, age_group, coach_id, created_at),team_idFK on Player, and bidirectional relationships (Team<->Player, Team<->Coach, Team<->Tenant)alembic/versions/008_add_team_model.py-- Migration creatingteamstable withagegroupenum and addingPlayer.team_idFK column (chains from revision 007)src/basketball_api/routes/teams.py-- New route file with 10 endpoints: POST (create), GET (list), GET overview, GET mine, GET detail, PATCH (update), DELETE (unassigns players), POST assign players, DELETE unassign playersrc/basketball_api/main.py-- Registered teams router at/api/teamstests/test_teams.py-- 28 tests covering all CRUD, assignment, coach view, overview, and auth enforcementTest Plan
pytest tests/test_teams.py -v-- 28 tests passpytest tests/ -v-- all 209 tests pass (zero regressions)ruff checkandruff formatcleanReview Checklist
Related
plan-2026-03-08-tryout-prepPhase 10aCloses #81
Self-Review Findings
Two issues found and fixed in commit
dcfd1c8:require_coach_or_adminwas defined at module level but never used in any endpoint. Removed.AgeGroupandDivisionwere imported insidecreate_team()andupdate_team()function bodies viafrom basketball_api.models import ..., but both are already available from the top-level import of themodelsmodule. Moved to module-level import.No functional changes. All 28 team tests and 209 total tests pass.
PR #82 Review
BLOCKERS
1. Cannot clear coach_id or nullable fields via PATCH
In
update_team, theTeamUpdateschema usesNoneas the default for all fields, and the endpoint checksif body.coach_id is not Nonebefore updating. This means there is no way to unassign a coach from a team via PATCH -- sending{"coach_id": null}in JSON deserializes toNone, which is the same as "not provided." The same issue applies todivisionandage_group-- once set, they cannot be cleared back to null.The standard pattern for this is to use a sentinel value or restructure the schema (e.g., use
UNSETsentinel, or accept explicitnullviaOptionalwithexclude_unset). This is a functional gap: admins will need to unassign coaches and clear divisions.File:
/home/ldraney/basketball-api/src/basketball_api/routes/teams.py, lines 42-46 (TeamUpdate) and lines 340-385 (update_team).2.
get_teamdetail endpoint lacks tenant scopingThe
GET /api/teams/{team_id}endpoint (get_team) looks up a team byteam_idalone via_get_team_or_404, with no tenant filter. An admin authenticated against one tenant could view team details from a different tenant by guessing IDs. All other endpoints properly scope bytenant_id, but this one does not.Similarly,
PATCH /api/teams/{team_id}andDELETE /api/teams/{team_id}use_get_team_or_404without tenant scoping. While this is a single-tenant deployment today, it breaks the multi-tenant contract the rest of the codebase enforces. The_get_team_or_404helper should accept atenant_idparameter for the detail/update/delete endpoints, or those endpoints should accept atenant_idquery param and validate it.File:
/home/ldraney/basketball-api/src/basketball_api/routes/teams.py, lines 115-122 (_get_team_or_404), lines 327-334 (get_team), lines 337-385 (update_team), lines 388-407 (delete_team), lines 416-449 (assign_players), lines 452-487 (unassign_player).3. Player cross-tenant assignment is only partially guarded
In
assign_players, the checkPlayer.tenant_id == team.tenant_idcorrectly prevents assigning a player from tenant B to a team in tenant A. However, a player who is already assigned to a different team will be silently reassigned without warning. This may or may not be intentional -- the issue acceptance criteria says "assign player(s) to a team" but doesn't specify behavior for already-assigned players. If re-assignment is intentional, that is fine, but it should be documented in the docstring. If not, the endpoint should warn or reject.This is a soft blocker -- clarify the intended behavior and document it.
NITS
1.
PlayerBriefhas apositionfield butPlayer.positionis not a common field for team rostersThe
PlayerBriefresponse schema includespositionbut notheight,graduating_class, ordate_of_birth. This is a design choice but worth noting -- the roster endpoint includes more player detail. Not blocking.2.
created_atis typed asstrin response schemasTeamResponse.created_atandTeamDetail.created_atare typed asstr, and the helper manually calls.isoformat(). Other endpoints in the codebase (e.g.,RosterResponse) usedatetimeand let Pydantic handle serialization. Consider usingdatetimefor consistency, but this works fine as-is.3.
Player.tenant_idin overview unassigned countThe overview endpoint counts unassigned players via
Player.tenant_id == tenant_idandPlayer.team_id.is_(None). This counts all players in the tenant regardless of whether they have a Registration. The existing roster endpoints filter byjoin(Registration). This means the unassigned count might include players without registrations. Consider whether the count should only include registered players.4. Route ordering:
/mineand/overviewbefore/{team_id}This is correctly done --
/mineand/overvieware defined before/{team_id}in the router, so FastAPI's route matching will not accidentally match "mine" or "overview" as ateam_id. Good.5.
require_adminimport in testsThe test file imports
require_adminfrombasketball_api.routes.teamsand overrides it inapp.dependency_overrides. This follows the exact same pattern asadmin.pywhererequire_admin = require_role("admin")is a module-level dependency. Consistent and correct.6. Missing
__init__.pyor explicit re-export of Team/AgeGroupThe new
AgeGroupenum andTeammodel are added tomodels.pybut not imported in__init__.py. This is consistent with the existing pattern (other models likeCoach,EmailLogare not re-exported either). Not a problem, just noting.7.
tenant_idin the PR body saysplayer.tenant_id-- the migration addsteam_idnottenant_idto PlayerThis is just a PR body phrasing issue, not a code issue.
SOP COMPLIANCE
81-team-model-assignment-endpointsreferences issue #81plan-2026-03-08-tryout-prepPhase 10aCloses #81: Present in the PR body.envfiles, credentials, or API keys in the diffdowngrade()drops column, drops table, drops enumrequire_admin./mineusesget_current_user. Correct.VERDICT: NOT APPROVED
Three blockers need resolution before merge:
The overall quality is high -- clean code, follows existing patterns (module-level
require_admin, Pydantic schemas, helper functions), solid test coverage, and correct migration. The blockers are fixable without major restructuring.1. PATCH cannot clear nullable fields: Use model_dump(exclude_unset=True) so sending {"coach_id": null} clears the coach while omitting coach_id leaves it unchanged. Same for division and age_group. 2. Tenant scoping on 6 endpoints: _get_team_or_404 now accepts optional tenant_id parameter. get_team, update_team, delete_team, assign_players, and unassign_player all require tenant_id query param, preventing cross-tenant access. 3. Silent player re-assignment: assign_players now tracks and returns a "reassigned" list in the response when players are moved from another team, so the caller knows which players were re-assigned. Tests: 216 passing (7 new — clear coach, clear division, omitted fields unchanged, reassign warning, same-team no warning, cross-tenant get 404, cross-tenant delete 404). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>QA Blocker Fixes (commit
2f6b564)All three blockers from the review have been resolved:
1. PATCH cannot clear nullable fields
update_teamnow usesbody.model_dump(exclude_unset=True)to distinguish "not provided" from "explicitly set to null"{"coach_id": null}clears the coach; omittingcoach_idleaves it unchangeddivisionandage_groupname cannot be nullvalidation guard2. Tenant scoping on 6 endpoints
_get_team_or_404now accepts an optionaltenant_idkeyword argument and filters byTeam.tenant_idwhen providedget_team,update_team,delete_team,assign_players,unassign_player) now require atenant_idquery parameter_get_tenant_or_404before querying the team3. Silent player re-assignment documented with warning
reassignedlist withplayer_id,player_name,from_team_id, andfrom_team_namereassignedkey is omitted from the responseNew tests (7 added, 216 total passing)
test_update_team_clear_coach-- verifies{"coach_id": null}clears the coachtest_update_team_clear_division-- verifies{"division": null}clears divisiontest_update_team_omitted_fields_unchanged-- verifies omitted fields stay unchangedtest_reassign_player_returns_warning-- verifiesreassignedlist in responsetest_assign_already_on_same_team_no_warning-- verifies no warning for same-team re-assigntest_get_team_wrong_tenant_returns_404-- verifies cross-tenant isolationtest_delete_team_wrong_tenant_returns_404-- verifies cross-tenant delete blockedPR #82 Review (Re-review)
BLOCKERS
None. All three blockers from the previous review have been resolved.
Previous Blockers -- Verified Fixed
PATCH nullable fields --
update_teamnow usesbody.model_dump(exclude_unset=True)(line 361). Sending{"coach_id": null}clears the coach; omittingcoach_idleaves it unchanged. Same pattern fordivisionandage_group. Explicit null-guard onnameprevents setting it to null (line 364-365). Verified by three new tests:test_update_team_clear_coach,test_update_team_clear_division,test_update_team_omitted_fields_unchanged.Tenant scoping --
_get_team_or_404now accepts atenant_idkeyword argument (line 116). All 6 mutating/detail endpoints passtenant_idto it. Coach lookup in PATCH also scopes byteam.tenant_id(line 400). Player lookups in assign/unassign scope byteam.tenant_id(lines 465, 528). Verified byTestTenantIsolationclass with cross-tenant GET and DELETE tests.Re-assignment warning --
assign_playersnow tracks players moving from a different team and includes areassignedlist withplayer_id,player_name,from_team_id,from_team_name(lines 478-508). The key is omitted when no re-assignments occur (line 506 conditional). Verified bytest_reassign_player_returns_warningandtest_assign_already_on_same_team_no_warning.NITS
The reload calls after commit (lines 229, 411, 495, 544) call
_get_team_or_404(db, team.id)withouttenant_id. This is safe since the team was already tenant-validated earlier in each endpoint, but passingtenant_idconsistently would be more defensive. Non-blocking.Test count in the PR body says "28 tests" and "209 total" but the actual counts are 35 tests in test_teams.py and 216 total. The body should be updated to reflect the fix cycle additions. Non-blocking.
SOP COMPLIANCE
81-team-model-assignment-endpoints-> Issue #81)plan-2026-03-08-tryout-prep Phase 10a) and issue #81Closes #81present in PR bodyVERDICT: APPROVED