feat: generic email blast with pluggable audience queries #461
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!461
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "456-email-blast-system"
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
Extends the generic email blast system with pluggable audience queries, Queens pink brand tokens, a reusable
build_action_emailbuilder, andquery_paramspassthrough so new email types ship as config instead of code changes.Changes
src/basketball_api/brand.py-- AddedCOLOR_PINKandCOLOR_PINK_HOVERQueens design tokens alongside existing Kings red tokenssrc/basketball_api/models.py-- ExtendedEmailTypeenum withtournament_feeandpayment_requestvaluessrc/basketball_api/services/email.py-- Addedbuild_action_email()function that renders brand-aware inline-style HTML (Kings red for boys, Queens pink for girls)src/basketball_api/services/email_queries.py-- Addedquery_tournament_committed()query that returns signed players on specifiedteam_ids, withteam_genderfor brand-aware renderingsrc/basketball_api/routes/admin.py-- Addedquery_paramsfield toBlastRequestand wired it through to query functions viainspect.signaturedetectionalembic/versions/039_add_tournament_fee_email_types.py-- Migration to add new enum values to PostgreSQLtests/test_tournament_blast.py-- 14 tests covering query, builder, and endpoint integrationTest Plan
pytest tests/test_tournament_blast.py -v-- 14 tests pass (query unit tests, build_action_email unit tests, blast endpoint integration)pytest tests/test_email_blast.py -v-- 25 existing tests pass (no regressions)ruff checkandruff formatcleanReview Checklist
Related Notes
Related
Closes #456
QA Review -- PR #461
Summary
Extends the generic email blast system with
tournament_committedquery,build_action_emailbuilder, Queens pink brand tokens,query_paramspassthrough, and newEmailTypeenum values. 14 new tests, 25 existing tests confirmed passing.Findings
Nit --
import inspectinside function body (admin.py L1190)The
import inspectis inside theadmin_email_blastendpoint handler. Should be at module top level. Not a blocker (Python caches imports), but not idiomatic for this codebase.Nit --
accent_hoverassigned but unused (email.pybuild_action_email)The
# noqa: F841suppresses the lint warning, but the variable is genuinely unused. Inline CSS in emails cannot use:hoverpseudo-selectors, so the hover color serves no purpose here. Consider removing the variable and the import ofCOLOR_PINK_HOVER/COLOR_RED_HOVERrather than suppressing the warning.Note --
build_action_emailnot yet wired into blast endpointThe function is defined and tested but the blast endpoint uses
send_templated_emailwith MJML templates. This is fine per issue spec (building block for downstream #457), just noting for awareness.VERDICT: APPROVED (with nits)
All acceptance criteria met. No regressions. Migration slot correct. Brand tokens properly extracted. Tests thorough.
PR #461 Review
Branch:
456-email-blast-system-- follows{issue-number}-{kebab-case-purpose}convention.Tech stack: Python / FastAPI / SQLAlchemy / Alembic
Files changed: 7 (585 additions, 1 deletion)
DOMAIN REVIEW
Python / FastAPI / SQLAlchemy analysis
email_queries.py--query_tournament_committed(): Clean query pattern. JoinsPlayer -> player_teams -> Team, filters bytenant_id,team_ids, andContractStatus.signed. Usesjoinedloadforparentandteamsto avoid N+1. The.distinct()is correct since joins can produce duplicates. Returns well-structured dicts with all required per-recipient fields (to,parent_id,parent_name,player_name,team_name,team_gender,checkout_url).email_queries.py-- Registry pattern:QUERY_REGISTRYdict maps string names to callables.tournament_committedcorrectly registered. The 422 for unknown query names is handled by the existing blast endpoint code (not in this diff, but referenced).routes/admin.py--query_paramspassthrough viainspect.signature: This is a pragmatic backward-compatible approach. Existing query functions (unsigned_contracts,incomplete_profiles) lack thequery_paramsparameter and won't receive it. New queries that need it declare it in their signature. Theinspectmodule is stdlib, no new dependency.services/email.py--build_action_email(): Good structure. Usesescape()onheadline,cta_text,cta_url, andfooter_note(XSS protection). Reuses existing_brand_wrapper()for consistent email chrome. Brand-aware accent color selection (COLOR_PINKfor girls,COLOR_REDfor boys) is clean.brand.py:COLOR_PINKandCOLOR_PINK_HOVERadded alongside existing Kings red tokens. Existing tokens untouched. Clean.models.py:EmailTypeenum extended withtournament_feeandpayment_request. Additive only. Existing values untouched.Migration
039: UsesADD VALUE IF NOT EXISTSwhich is safe for idempotent reruns. Downgrade is a no-op (correct -- PostgreSQL cannot remove enum values). Known issue: Migration slot is 039 but should be 046 per the user's note. Flagged but not blocking per instruction.Test coverage (
test_tournament_blast.py): 14 tests across three test classes:TestQueryTournamentCommitted(6 tests): signed players returned, unsigned excluded, empty without team_ids, girls gender detection, test_email filtering, registry presence.TestBuildActionEmail(6 tests): Kings red for boys, Queens pink for girls, headline/CTA presence, footer note included/omitted, XSS escaping of headline.TestBlastWithQueryParams(2 tests): full integration with mock Gmail -- blast with test_email, blast without test_email.Type hints: All function signatures have proper PEP 484 type hints.
query_params: dict | None = Noneuses modern union syntax (Python 3.10+).Docstrings: PEP 257 compliant.
build_action_emailhas complete Args/Returns documentation.query_tournament_committeddocuments result dict fields.BLOCKERS
None.
All new functionality has test coverage. No unvalidated user input reaches SQL (team_ids are used in
.in_()which parameterizes). No secrets in code. No DRY violations in auth paths -- reuses existingrequire_admindependency.NITS
build_action_emailbodyparameter is raw HTML (line ~280 in email.py diff): Thebodyparam is inserted withoutescape(), whileheadline,cta_text,cta_url, andfooter_noteare all escaped. The docstring documents this ("may contain<p>tags"), and the endpoint is admin-only behindrequire_admin, so this is not a security blocker. However, consider adding a docstring note explicitly stating thatbodyis trusted HTML and callers must sanitize if sourcing from user input.checkout_urldefaults to empty string: Inquery_tournament_committed,checkout_urlis always""with a comment "populated by body.data override." The placeholder resolution happens in the blast endpoint's template rendering loop. This works but the coupling is implicit -- a caller who forgets to passcheckout_urlindatawill send emails with empty links. Consider raising a warning ifcheckout_urlis empty after placeholder resolution.test_footer_note_omitted_when_empty(line ~340 in test diff): The assertionassert "font-size: 12px" not in html or "text-align: center" in htmlis a weak check. Since"text-align: center"appears in the CTA div regardless of footer, this assertion is always true. A stronger check would be:assert html.count('font-size: 12px') == 0or check that no<p>withfont-size: 12pxexists after the CTA.Migration slot 039: As noted by the user, remote main has through 044 and PR #460 takes 045. This migration needs to be renumbered to 046 before merge. Acknowledged as a known issue being handled separately.
SOP COMPLIANCE
456-email-blast-system)feat: generic email blast with pluggable audience queries)PROCESS OBSERVATIONS
inspect.signature). Migration usesIF NOT EXISTS.test_footer_note_omitted_when_empty(nit #3) is minor.VERDICT: APPROVED
Clean, well-tested, additive feature with no blockers. The migration slot renumbering (039 -> 046) must happen before merge but is being tracked separately per the user's note. Nits are non-blocking quality suggestions.
44aa88a60368eb10f2e3