feat: add public teams endpoint (GET /public/teams) #178
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!178
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "176-public-teams-endpoint"
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
GET /public/teamsendpoint for the westside-app public website teams pageis_public = trueChanges
src/basketball_api/routes/public.py-- Public router with dedicatedPublicPlayerResponse,PublicTeamResponse,PublicTeamsResponseschemas. No auth dependencies. Hardcodedtenant_id=1.src/basketball_api/main.py-- Register public router at/publicprefix.src/basketball_api/models.py-- Addis_publicboolean column toPlayermodel (defaultfalse).alembic/versions/022_add_is_public_to_players.py-- Migration to addis_publiccolumn to players table.tests/test_public.py-- 13 tests covering all acceptance criteria.Test Plan
TestPublicTeamsUnauthenticated-- 200 without Bearer token, empty when no teamsTestPublicFieldAllowlist-- only 5 player fields + 4 team fields present, 18 sensitive fields verified absentTestPublicPlayerFiltering--is_public=falseexcluded, all-private/empty teams excludedTestPublicTeamsSortOrder-- age group descending, tier within group, full combined sortTestPublicCoachName-- coach name via join, null when no coachruff formatandruff checkpass cleanReview Checklist
Depends(require_role)orDepends(require_admin)on public endpointRelated Notes
public.pywith no conflicts)Self-Review
Result: PASS -- no issues found.
Security
PublicPlayerResponseandPublicTeamResponseschemas with exactly the allowlisted fields (5 player, 4 team). No admin schema reuse.test_sensitive_fields_not_exposed.Depends(get_db).Data Integrity
is_publiccolumn defaults tofalseviaserver_default-- existing players remain private until explicitly toggled.Boolean, server_default=text("false")).Query / Performance
joinedload(Team.coach)andjoinedload(Team.players)avoids N+1 queries.is_publicfiltering happens in Python after eager load. Acceptable at current scale (single tenant). If player count grows significantly, a subquery filter could be added later.Sort Logic
AgeGroupenum.valuemapped to priority integers (U18=0 through U8=5).Tests
PR #178 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Pydantic v2
Security (CRITICAL -- primary concern for this PR):
The core security design is sound. The implementation uses a dedicated allowlist architecture:
PublicPlayerResponse,PublicTeamResponse,PublicTeamsResponse) -- never reuses admin response models. This is the correct pattern._public_player()-- manually maps 5 specific fields (name,jersey_number,position,height,current_school) rather than usingfrom_attributes=TrueORM-to-schema mapping. This means even if new sensitive columns are added to the Player model later, they will NOT leak unless someone explicitly adds them to this function.joinedload(Team.coach)loads the full Coach object, but onlyteam.coach.nameis extracted into thecoach_name: str | Nonefield. Coach email, phone, Stripe Connect account ID, tax ID, contractor agreement details, and invite tokens are never serialized.tenant_id,division,age_group,coach_id,groupme_group_id,groupme_share_url,created_atare all excluded by the schema.is_publicfiltering -- players default tois_public=false, so no data leaks until explicitly opted in. Teams with zero public players are excluded entirely.Depends(require_role)orDepends(require_admin), making it truly public.Test coverage is strong: 13 tests across 5 test classes covering unauthenticated access, field allowlisting (with set-equality assertion), sensitive field absence (18 fields explicitly checked),
is_publicfiltering, sort order (3 scenarios), and coach name join behavior.SQLAlchemy patterns:
joinedload(Team.coach)andjoinedload(Team.players)avoid N+1 queries -- correct.Team.playersrelationship already useslazy="selectin"(line 304 of models.py), so the explicitjoinedload(Team.players)in the query is redundant but not harmful. Thejoinedload(Team.coach)is the one that actually matters here.Depends(get_db)is standard pattern.FastAPI specifics:
response_model=PublicTeamsResponseon the endpoint ensures FastAPI's response serialization is also schema-bound.model_configneeded since schemas are constructed manually (not from ORM objects).Pydantic/PEP compliance:
BLOCKERS
1. Duplicate Alembic revision ID -- migration will fail
The new migration
022_add_is_public_to_players.pyhasrevision = "022"anddown_revision = "021". However,022_merge_heads.pyalready exists on main withrevision = "022"anddown_revision = ("019", "021"). Additionally,023_backfill_player_jersey_from_orders.pyalready exists withdown_revision = "022".This creates two problems:
"021"instead of the current head"023".Fix required: Rename to
024_add_is_public_to_players.pywithrevision = "024"anddown_revision = "023".Files:
/home/ldraney/basketball-api/alembic/versions/022_add_is_public_to_players.py(new, conflicts)/home/ldraney/basketball-api/alembic/versions/022_merge_heads.py(existing on main)/home/ldraney/basketball-api/alembic/versions/023_backfill_player_jersey_from_orders.py(existing on main)NITS
Redundant
joinedload(non-blocking):joinedload(Team.players)inpublic.py:109is redundant becauseTeam.playersalready haslazy="selectin"on the relationship definition. Not harmful, but could be removed for clarity. Alternatively, a comment explaining it's intentional override would help.Hardcoded
tenant_id=1: Documented in the docstring as "single-tenant" and matches the current architecture. When multi-tenancy is needed, this will need a path or header parameter. Fine for now, but worth tracking.Tier extraction from team name (fragile):
_team_sort_keyparses tier (Elite/Select/Local) from the team name string using substring matching. If a team is named "17U Selection Committee" it would match "select". This works for the current naming convention but is brittle. Consider atiercolumn on Team if this grows. Non-blocking for now.Sensitive fields test list could be more complete: The
test_sensitive_fields_not_exposedforbidden list has 18 fields but missestenant_id,graduating_class,team_preference,division,checked_in,subscription_status,contract_signed_at,contract_signed_by,contract_signed_ip,contract_signature_url,contract_version,jersey_order_status,custom_notes,is_public,created_at. This is NOT a blocker becausetest_player_fields_are_allowlistedusesassert set(p.keys()) == allowed_keyswhich catches any leak. The forbidden list is secondary documentation. Still, expanding it would make intent even clearer.SOP COMPLIANCE
176-public-teams-endpointmatches issue #176)PROCESS OBSERVATIONS
public.py. The file structure supports this cleanly -- a new endpoint can be added to the same router without conflict.VERDICT: NOT APPROVED
One blocker: duplicate Alembic revision ID
"022"conflicts with the existing022_merge_heads.pyon main. The migration must be renumbered to024(withdown_revision = "023") before merge. All other aspects of this PR -- security design, code quality, test coverage, SOP compliance -- are excellent.forgejo_admin referenced this pull request2026-03-27 03:43:33 +00:00