feat: contract reminder email endpoint (#270) #271
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!271
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "270-contract-reminder-email"
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
POST /admin/email/contract-reminderendpoint that sends branded reminder emails to parents of unsigned rostered players, grouping multiple unsigned players under one email per parent with individual contract sign links.Changes
src/basketball_api/routes/admin.py— NewPOST /email/contract-reminderendpoint with?test_emailparam for test mode. Queries unsigned rostered players (contract_status != signed, has contract_token, on at least one team), groups by parent, sends one email per parent.src/basketball_api/services/email.py— Newsend_contract_reminder_email()function and_build_player_section()helper. Single-player emails get one CTA button; multi-player emails get a bullet list with individual CTA buttons. Usesload_email_template("contract-reminder")with plain-text fallback. Logs toemail_logtable.data/email-templates/contract-reminder.html— Standalone HTML email template with Westside dark branding (dark bg #0a0a0a, pink accents #e91e8c, white text). Placeholders:{{parent_name}},{{player_section}},{{deadline}}.tests/test_contract_reminder.py— 11 tests covering: single/multi player HTML builder, test email mode, send-to-all, no-match filtering, signed player exclusion, unrostered player exclusion, parent grouping, error handling, and contract URL format verification.Test Plan
Review Checklist
contract_tokennotregistration_token?test_emailparam for test modeemail_logtable withEmailType.reminderRelated Notes
None.
Related
Closes #270
Self-Review Findings
Bug Found and Fixed
Duplicate players in multi-team scenario -- The query joins on
player_teamsto filter rostered players, but if a player belongs to multiple teams, the join produces multiple rows for that player. Combined withjoinedload(Player.teams), this would cause the same player to appear multiple times in the grouping dict, resulting in duplicate entries in the reminder email. Fixed by adding.distinct()to the query. Pushed in commitc8f0f1a.Verified Correct
player.contract_token(notregistration_token)contract_status != signed), rostered only (join onplayer_teams), has token (contract_token.isnot(None))?test_emailparam correctly filters after groupingescape()EmailType.reminder(no migration needed)No Issues Remaining
VERDICT: APPROVED
PR #271 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy 2.0+ / Gmail SDK
Correctness
Query logic is correct. The inner join on
player_teamsproperly filters to rostered-only players. Thecontract_status != ContractStatus.signedfilter correctly catches bothnoneandofferedstatuses. Thecontract_token.isnot(None)filter is a sound belt-and-suspenders check since unregistered players likely lack tokens.Parent grouping works. The
parent_playersdict keyed onparent_idcorrectly collapses multiple unsigned players per parent into one email. The testtest_groups_multiple_players_per_parentvalidates this with 3 players across 2 teams.Contract URL uses
contract_token, notregistration_token. Verified in code (f"{_CONTRACT_BASE_URL}/{player.contract_token}") and explicitly tested intest_contract_url_uses_contract_token.test_emailfiltering works. Applied post-query on the parent email, matching the jersey-reminder pattern of filtering parents by email.Email logging uses
EmailType.reminder. Note: the jersey-reminder usesEmailType.announcement, and the profile-reminder usesEmailType.reminder. There is no dedicatedcontract_reminderenum value. Usingreminderis acceptable but makes it harder to distinguish contract reminders from profile reminders in the email_log table. Consider adding acontract_reminderenum value in a follow-up.Pattern compliance (vs. jersey-reminder)
The endpoint follows the jersey-reminder pattern closely:
DEFAULT_TENANT_SLUGtest_emailquery param approachtry/excepterror collection per parentsend_*_email()function signature (tenant, parent, players, db)load_email_template()+ plaintext fallbackEmailLogentry +db.commit()patternOne structural difference: the jersey-reminder queries Parents and iterates, while the contract-reminder queries Players then groups by parent. This is the correct approach given the additional filtering requirements (contract_status, contract_token, rostered).
SQLAlchemy
The
joinedload(Player.teams)in the query overrides the model's defaultlazy="selectin"on theteamsrelationship. Combined withdistinct(), this creates a potential ORM-level deduplication concern. In practice, SQLAlchemy 2.0's legacyQuery.all()auto-uniquifiesjoinedloadresults, so this will function correctly. However, droppingjoinedload(Player.teams)and relying on the defaultselectinstrategy would be cleaner and avoid the interaction withDISTINCTentirely. Thejoinedload(Player.parent)is fine (many-to-one, no row multiplication). See nit below.Security
_build_player_section()correctly useshtml.escape()onplayer_name,team_name, andcontract_url. Good.parent_nameis passed unescaped toload_email_template()via the{{parent_name}}placeholder (raw string replacement, no HTML escaping). This matches the existing jersey-reminder pattern where{{name}}is also unescaped. The risk is minimal (admin-only endpoint, parent names from managed DB, email HTML context), but it is worth noting as a pre-existing pattern gap.Email template
#0a0a0abackground,#e91e8cpink accents, white text.max-width: 600pxcontainer, viewport meta tag.&and—HTML entities used correctly.Test coverage
11 tests covering: single/multi player HTML builder, HTML escaping, test email mode, send-to-all, no-match, signed exclusion, unrostered exclusion, parent grouping, error handling, and contract URL format. This is thorough. Tests properly mock the Gmail client and template loader, use real DB fixtures via conftest, and verify both the response payload and the arguments passed to
send_message.BLOCKERS
None.
NITS
joinedload(Player.teams)is redundant and potentially risky withdistinct(). Theteamsrelationship already haslazy="selectin"in the model. Removejoinedload(Player.teams)from the query.options()and let the default selectin strategy handle it. Keepjoinedload(Player.parent)since Parent has no eager-load default. This avoids any interaction between JOIN-based loading andDISTINCT._CONTRACT_DEADLINE = "April 2"is hardcoded. This will need updating for future seasons. Consider making it a query param or config value. Not blocking for this PR since the jersey-reminder has similar hardcoded values, but worth tracking._CONTRACT_BASE_URLis a hardcoded Tailscale URL. Same pattern as other URLs in the codebase, but ideally this would come from config/settings. Consistent with existing pattern, so not blocking.EmailType.reminderis shared with profile reminders. If you need to query "all contract reminders sent" from the email_log later, you cannot distinguish them from profile reminders. A dedicatedcontract_reminderenum value would be cleaner. Low priority -- file as discovered scope if desired.Unescaped
parent_namein HTML template. The{{parent_name}}placeholder is injected viaload_email_template()without HTML escaping, matching the existing{{name}}pattern in jersey-reminder. Pre-existing pattern gap, not a new regression. A future improvement would be to escape all template values by default.SOP COMPLIANCE
270-contract-reminder-emailfollows{issue-number}-{kebab-case-purpose}PROCESS OBSERVATIONS
test_escapes_html_in_names) and the contract token verification test show security awareness.ContractReminderResponsemodel addsskipped_no_tokenfield beyond whatJerseyReminderResponsehas -- good operational visibility.mergeable: falsein the PR metadata may indicate a merge conflict that needs resolution before merge.VERDICT: APPROVED
c8f0f1a2a5b91aebb3dd