feat: add single-player contract email endpoint #464
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
ldraney/basketball-api!464
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "462-admin-contract-email-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
Adds
POST /admin/email/contract/{player_id}to send a branded contract email to a single player's parent, reusing the existingsend_contract_reminder_emailservice function with a 1-element player list.Changes
src/basketball_api/routes/admin.py— New endpoint withSingleContractEmailResponsemodel. Supports?test_email=query param to redirect sends. Returns 404 for missing player, 422 for missingcontract_token.tests/test_single_contract_email.py— 4 tests: success (200), player not found (404), no token (422), test_email redirect.Test Plan
pytest tests/test_single_contract_email.py— 4/4 passingpytest tests/test_contract_reminder.py— 12/12 passing (no regressions)ruff checkandruff formatcleanReview Checklist
send_contract_reminder_email— no new email logictest_emailparam temporarily swaps parent email, restores infinallyblockRelated
Related Notes
None — no pal-e-docs notes affected.
QA Review -- PR #464
Self-Review Findings
Bug found and fixed (commit
75c7cf4): Thetest_emailswap mutated the SQLAlchemy-trackedparentobject. Whensend_contract_reminder_emailcallsdb.commit()to persist theEmailLogentry (email.py line 1757), the swapped test email would be flushed to theparentstable, corrupting the real parent email. Thefinallyblock restored the Python attribute but never committed, so the DB row would retain the test address.Fix: Added
db.expunge(parent)before the email swap so the parent object is detached from the session and the commit inside the service function does not flush the mutation.Review Summary
send_contract_reminder_emailwith 1-element listtest_emailparam safely detaches parent before swap_CONTRACT_BASE_URLduplicated from email.pyVERDICT: APPROVED
PR #464 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Gmail SDK
Endpoint design -- Clean. Single-purpose
POST /admin/email/contract/{player_id}with proper admin guard (require_admin), correct HTTP status codes (404/422/500), and a well-typed Pydantic response model. Follows existing admin endpoint patterns in this file.db.expungefor test_email swap -- This is the most interesting design decision. The bulk endpoint (admin_send_contract_reminder) handlestest_emaildifferently: it filters to only parents matching that email. The new endpoint instead expunges the parent from the session and mutatesparent.emailin-memory, then restores it in afinallyblock. This approach works becauseexpungedetaches the object from the session sodb.commit()insidesend_contract_reminder_emailcannot flush the fake email to the database. Thefinallyblock restoringoriginal_emailis defensive but correct -- the object is detached, so the restore is a no-op from the DB perspective but keeps the in-memory object clean. Acceptable pattern.Eager loading --
joinedload(Player.parent)andjoinedload(Player.teams)matches the existing bulk endpoint pattern. Good -- avoids N+1 queries.Error handling -- The endpoint does NOT wrap
send_contract_reminder_emailin a try/except. If Gmail fails, the exception will propagate as a 500. The bulk endpoint catches exceptions and collects them in anerrorslist. For a single-player endpoint, letting it propagate as 500 is acceptable -- the caller gets a clear failure signal. However, the error message will be a raw Python traceback via FastAPI's default handler rather than a structured JSON error. This is a nit, not a blocker.BLOCKERS
None.
Test coverage: 4 tests covering success (200), player not found (404), no token (422), and test_email redirect. All four code paths are covered. The mocks target the correct layer (
get_gmail_clientandload_email_template).No secrets in code: The hardcoded URL
https://westside-contracts.tail5b443a.ts.net/contractis an internal Tailscale hostname, not a secret. It is already hardcoded in 3 other places in this codebase (see nit below).No unvalidated input:
player_idis typed asint(FastAPI validates),test_emailis a query param used only to override the send target. No SQL injection or path traversal risk.NITS
DRY:
_CONTRACT_BASE_URLis now duplicated in 4 files. This PR adds a 4th copy atroutes/admin.pyline ~1135. The same constant already exists in:services/email.py:1612services/contract_offers.py:69services/email_queries.py:30This should be a single constant imported from one canonical location. Not a blocker because it is not in an auth/security path, but it is real DRY debt. File a follow-up issue.
Missing parent email validation on
test_emailparam. The endpoint accepts any string astest_emailwithout validating it looks like an email address. If someone passestest_email=garbage,send_contract_reminder_emailwill attempt to send to "garbage" and fail at the Gmail layer. A lightweightEmailStrvalidation (or even just an@check) would give a better 422 response. Minor.No explicit exception handling around
send_contract_reminder_email. As noted above, Gmail failures will surface as raw 500s. For consistency with the bulk endpoint's structured error reporting, consider wrapping in try/except and returning a structured error. Minor.Test coverage gap: no test for missing parent. The endpoint has an explicit check
if not parent: raise HTTPException(422, "Player has no parent")but no test exercises this path. Consider adding a fixture with an orphan player (noparent_id). Minor.SOP COMPLIANCE
462-admin-contract-email-endpointCloses #462PROCESS OBSERVATIONS
_CONTRACT_BASE_URLduplication (now 4 copies) is accumulating technical debt. A single follow-up ticket to centralize this constant would prevent future divergence if the contracts hostname ever changes.send_contract_reminder_emailservice function rather than writing new email logic. This keeps the email path consistent.test_emailsemantics between the bulk endpoint (filter by matching parent) and the single endpoint (swap parent email) could confuse future maintainers. A brief docstring note explaining why the approach differs would help.VERDICT: APPROVED