feat: sponsor blast endpoint with pitch engine (#325) #330
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!330
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "325-sponsor-blast-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 /sponsors/blastendpoint for targeted sponsor outreach email campaigns with a pitch engine that resolves per-category defaults or custom pitches, sponsorship tiers HTML block, test_email safety valve, rate limiting, and full status/email_log audit trail.Changes
src/basketball_api/services/sponsor_service.py-- AddedCATEGORY_PITCHESdict (all 9 categories with Marcus's outreach copy),SPONSORSHIP_TIERS_HTMLblock,get_pitch()resolver, andblast_sponsors()send function with rate limiting and status trackingsrc/basketball_api/routes/sponsors.py-- AddedBlastRequest/BlastResponseschemas andPOST /sponsors/blastendpoint with admin authtests/test_sponsor_blast.py-- 9 tests: 3 unit (pitch resolution, all categories covered) + 6 integration (test_email mode, real blast, category filter, follow-up re-blast, empty results, tiers HTML passthrough)Note: This branch cherry-picks the Sponsor model commits from
324-sponsor-model-crud(not yet merged to main). Expected merge conflicts on rebase after #324 merges -- the conflicts will be trivial (duplicate additions).Test Plan
pytest tests/test_sponsor_blast.py -v-- all 9 passReview Checklist
Related
Closes #325
Related Notes
None -- no pal-e-docs notes affected.
QA Review -- PR #330
Acceptance Criteria Check
CATEGORY_PITCHESdict with all 9 categoriesget_pitch()returns custom_pitch if set, else category defaultPOST /sponsors/blastaccepts category, status, include_tiers, test_emailload_email_template("sponsor-outreach", data){"sent": N, "failed": N, "sponsors": [...]}Code Quality
require_admindependencytime.sleep(3)between sends (mocked in tests)Nits
The
/blastroute must be registered BEFORE/{sponsor_id}routes in the router, otherwise FastAPI will try to parse "blast" as a sponsor_id path parameter. Currently the route is appended at the bottom of the file after/{sponsor_id}routes. This is actually fine because thePOSTmethod + no path param on/blastvsPATCH/DELETE /{sponsor_id}means no ambiguity -- FastAPI matches on method + path shape, not declaration order for different HTTP methods. ButGET /blastwould collide withGET ""if it existed. Current code is safe.The
Tenantimport was added to routes/sponsors.py but the blast function resolves tenant viadb.query(Tenant).first()-- this is the established single-tenant pattern used elsewhere in the codebase.Blockers
VERDICT: APPROVED -- pending rebase after #324 merges.
8690bd2c62e0fd1323ddPR #330 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Gmail SDK
Pitch engine -- All 9
SponsorCategoryvalues have pitch defaults inCATEGORY_PITCHES. Theget_pitch()resolver correctly falls back to category default whencustom_pitchis not set, with a secondary fallback toSponsorCategory.other. Unit testtest_all_nine_categories_have_pitchesenforces completeness.Sponsorship tiers HTML -- Five tiers present: Title ($5,000+), Elite ($2,500), Team ($1,000), Social ($500), Player Sponsorship ($300--$1,200). HTML is inline-styled for email client compatibility. Matches the stated requirements.
Rate limiting --
time.sleep(3)between sends, correctly skipped before the first send. Tests mocktime.sleepand assertcall_count == 1for a 2-sponsor blast. Adequate.test_email safety valve -- When
test_emailis set: only first matching sponsor's data is used, email goes to the test address, sponsor status is NOT updated, no EmailLog entry created. All verified bytest_blast_with_test_email_sends_one.Status transitions -- Real blast updates
sponsor.statustoSponsorStatus.contactedand setslast_contacted_at. Verified bytest_blast_without_test_email_sends_all. Follow-up re-blast test (test_blast_status_contacted_for_followups) confirms filtering bystatus=contactedworks.EmailType.sponsor_outreach -- Used correctly in EmailLog creation. Integration test verifies log entries exist with this type after a real blast.
Existing service reuse --
load_email_templateandget_gmail_clientimported frombasketball_api.services.email. No duplication of email plumbing.Auth -- Endpoint uses
require_admindependency. Test overrides it viaapp.dependency_overrides. Consistent with codebase pattern.BLOCKERS
None.
Test coverage is solid: 3 unit tests for pitch resolution + 6 integration tests covering test_email mode, real blast, category filter, follow-up re-blast, empty results, and tiers HTML passthrough. Happy paths, edge cases (empty results, follow-up status), and the safety valve are all covered.
No unvalidated user input risks --
categoryandstatusare validated via enum types by Pydantic.test_emailandinclude_tiersare simple typed fields. No SQL injection surface (SQLAlchemy ORM queries). No secrets in code.NITS
Missing error-path test coverage -- No test for the
except Exceptionbranch (Gmail send failure) or theFileNotFoundErrorcatch (missing template). These are defensive paths that would benefit from coverage in a follow-up.No email format validation on
test_email-- The field isstr | None. Consider usingpydantic.EmailStrfor basic format validation to catch typos before attempting to send.Hardcoded sender info --
"Marcus Draney"and"385-450-9963"are hardcoded in the template data dict (sponsor_service.pyaround theload_email_templatecall). Not a secret, but could be extracted to a config constant or tenant field for reusability.Redundant final commit --
db.commit()is called inside the loop for each sponsor, then again after the loop (if test_email is None). The final commit is a no-op if all in-loop commits succeeded. Harmless but unnecessary.Retail and automotive pitches are identical -- Both categories share the exact same pitch text. Intentional? If so, a comment noting this would prevent future "is this a copy-paste bug?" confusion.
SOP COMPLIANCE
325-sponsor-blast-endpointPROCESS OBSERVATIONS
324-sponsor-model-crud. PR body correctly documents this and notes expected trivial merge conflicts after #324 merges. #328 (the PR for #324) shows as closed/merged, so rebase should be straightforward.FileNotFoundErrorat runtime if the template isn't deployed first. Deployment ordering matters.require_adminDRY:require_admin = require_role("admin")is defined independently in 6+ route files across the codebase. This is a pre-existing pattern, not introduced by this PR, but worth noting as accumulated tech debt.VERDICT: APPROVED