feat: add player profile endpoints (GET + PATCH) #90
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!90
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feature/11a-player-profile-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
Add
GET /api/players/{player_id}andPATCH /api/players/{player_id}endpoints for individual player profiles with role-based permissions. This enables the frontend to render player profile pages with edit capabilities gated by user role.Changes
src/basketball_api/routes/players.py(new): Player profile router with GET and PATCH endpoints. Pydantic response models (PlayerProfileResponse,PlayerProfileUpdate,TeamInfo,ParentInfo). Permission model: admin updates any, player updates own (parent email match, case-insensitive), coach gets 403. Read-only fields (name, team_id, division, tryout_number, subscription_status) excluded from update schema.src/basketball_api/main.py: Register players router at/api/playersprefix.tests/test_players.py(new): 18 tests covering GET fields, team+coach inclusion, parent info, 404/401, all three role permissions on PATCH, read-only field protection, partial updates, case-insensitive email matching.Test Plan
pytest tests/test_players.py -v-- 18/18 passedpytest tests/ -v-- 266/266 passed (248 existing + 18 new)ruff format+ruff checkcleanReview Checklist
Related
plan-2026-03-08-tryout-prep-- Phase 11aSelf-Review: PASS
Reviewed the full diff (3 files, +670 lines). No issues found.
Route implementation (
routes/players.py)PlayerProfileUpdate) from read-only response fields (PlayerProfileResponse). Read-only fields (name, team_id, division, tryout_number, subscription_status) cannot be modified via PATCH because they are not in the update model -- FastAPI silently drops unknown fields._get_player_or_404usesjoinedloadfor team and parent relationships, avoiding N+1 queries.player.team.coach-- this is a lazy load but acceptable since team is already eagerly loaded and coach is a single FK relationship.exclude_unset=Trueonmodel_dumpensures partial updates work correctly (omitted fields are not set to None).Router registration (
main.py)/api/playersprefix, alphabetically sorted with other imports. Clean.Tests (
test_players.py)test_teams.py(overrideget_current_userdependency).PARENT@TEST.COM) validates the ownership check.team_id,subscription_status,tryout_number,division, andnamein the PATCH body and verifies none of them changed.No issues
PR #90 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Pydantic v2 / Keycloak OIDC auth.
Route pattern consistency with
teams.py:The new
players.pyfollows the same structural conventions -- separator comments, Pydantic schemas at top, helper functions in the middle, endpoints at bottom. Good.Auth usage:
Uses
get_current_userdependency directly (notrequire_role), which is correct for these endpoints since the permission model is role-checked inline (admin=any, player=own, coach=403 on PATCH; any authenticated user on GET).SQLAlchemy patterns:
_get_player_or_404eager-loadsPlayer.teamandPlayer.parentviajoinedload, but_player_profile_responseaccessesplayer.team.coach(line 97) which is NOT eager-loaded. This works because SQLAlchemy lazy-loads it in a synchronous context, but it is an implicit N+1 query. Theteams.pyroute avoids this by loadingTeam.coachexplicitly in itsjoinedloadoptions. Addjoinedload(Player.team, Team.coach)to_get_player_or_404to match the established pattern and avoid the lazy load.Pydantic models:
PlayerProfileUpdatecorrectly usesmodel_dump(exclude_unset=True)for partial updates -- consistent withteams.pyPATCH pattern.ParentInfohasmodel_config = {"from_attributes": True}but is constructed manually, so it is unused. Harmless but inconsistent --TeamInfodoes NOT havefrom_attributes. Nit-level.Permission model:
"admin" not in user_rolescheck takes priority).BLOCKERS
1. No tenant scoping on player lookup -- cross-tenant data access
_get_player_or_404queriesPlayer.idglobally with no tenant filter. Every other route in this codebase (teams.py,subscriptions.py,tryouts.py,roster.py) scopes queries bytenant_id. This means:_is_player_owner) only verifies parent email, not tenant membership.This is a multi-tenancy authorization bypass. The endpoint should either accept a
tenant_idparameter (consistent withteams.py) or derive tenant scope from the authenticated user's context.Impact: A player in one organization can read profile details (including parent name, email, phone) of players in a completely different organization.
2. Parent PII exposed to all authenticated users via GET
ParentInfoin the GET response includesemailandphonefor every player profile. Any authenticated user (player, coach, admin) from any tenant can read this. Combined with Blocker #1, this is a cross-tenant PII leak.Even within a single tenant, exposing parent phone/email to other parents (player role) may be undesirable. Consider:
ParentInfofields based on the requesting user's roleNITS
1. Implicit lazy load on
player.team.coach(medium priority)As noted in the domain review,
_get_player_or_404does not eager-load the coach relationship through the team. Addjoinedload(Player.team).joinedload(Team.coach)to match the pattern inteams.pyand eliminate the implicit lazy query. This is technically a performance issue, not a correctness bug, since SQLAlchemy handles it transparently in sync code.In file
src/basketball_api/routes/players.py, the_get_player_or_404function at approximately line 72 should use chained joinedload:This would require importing
Team(and possiblyCoach) frombasketball_api.models.2.
ParentInfo.model_configunusedParentInfosetsfrom_attributes = Truebut is always constructed by explicit keyword arguments.TeamInfodoes not set it. Pick one pattern and be consistent.3.
photo_urlaccepts arbitrary stringsNo URL validation on the
photo_urlfield inPlayerProfileUpdate. The existing upload route (upload.py) generates server-side paths, so this PATCH endpoint could be used to inject arbitrary strings (e.g.,javascript:URIs) if the frontend renders the value in an<img>or<a>tag without sanitization. Consider adding aPydanticHttpUrltype or at least a regex pattern validator. Low risk if the frontend already sanitizes, but defense-in-depth is preferred.4. No test for empty PATCH body
Consider adding a test for
PATCH /api/players/{id}with an empty JSON body{}. The current code should handle it correctly (no fields updated, returns current state), but it is an untested edge case.5. Logger call after commit (minor)
In
update_player_profile, thelogger.infocall at line 189 happens after the_get_player_or_404reload but logsprovided.keys()from the original request body. This is fine functionally, but if the reload query fails (unlikely), the log entry would never fire and you would lose the audit trail. Consider logging before the reload.SOP COMPLIANCE
feature/11a-player-profile-endpoints(references phase 11a, not issue #89). Convention expects issue number in branch name.plan-2026-03-08-tryout-prepPhase 11a.PROCESS OBSERVATIONS
Change failure risk: MEDIUM. The cross-tenant data access gap (Blocker #1) is a real authorization vulnerability in a multi-tenant system. Even if there is currently only one tenant, this establishes an insecure pattern that will bite when a second tenant is onboarded. Every other route in the codebase scopes by tenant -- this one should too.
Test coverage is strong for the single-tenant case. The 18 tests cover GET fields, relationships, 404, 401, all three role permissions on PATCH, read-only field protection, partial updates, and case-insensitive email matching. However, no test verifies cross-tenant isolation because the tests only seed one tenant.
Deployment frequency impact: LOW. This is additive (new endpoints, new file, no migrations), so rollback is trivial -- remove the router registration line.
VERDICT: NOT APPROVED
Two blockers must be addressed:
_get_player_or_404(and add tenant_id parameter to endpoints, or derive from auth context) to prevent cross-tenant data access.