feat: add GET /stats endpoint + redeemed_item column #17
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "16-stats-endpoint-redeemed-item"
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 a
GET /statsendpoint returning lifetime gamification stats (level, XP progress, scanned/earned/redeemed/expired counts) and updatePATCH /codes/{id}/redeemto accept an optionalredeemed_itemfield for tracking menu item preferences. Both changes are backend prerequisites for the SvelteKit gamification UX (XP banner, lifetime stats bar, "What did you get?" chip picker).Changes
src/mcd_tracker_api/models.py-- add nullableredeemed_itemString(200) column to CouponUsagesrc/mcd_tracker_api/schemas.py-- addStatsResponse,CodeRedeemRequestschemas; addredeemed_itemtoCodeResponseandCodeRedeemResponsesrc/mcd_tracker_api/routes/locations.py-- addLEVEL_THRESHOLDSconstants and_calculate_level()helper; addGET /statsendpoint; updatePATCH /codes/{id}/redeemto accept optional body withredeemed_itemsrc/mcd_tracker_api/routes/codes.py-- includeredeemed_iteminGET /codesquery and responsealembic/versions/003_add_redeemed_item_column.py-- new migration adding nullable column (safe on existing data)tests/test_stats.py-- 15 new tests covering zero-code stats, all 5 level thresholds, XP progress calculation, expired code counting, user isolation, redeem with/without item, and redeemed_item in code response endpointsTest Plan
pytest tests/ -v-- 144 tests pass (129 existing + 15 new)Review Checklist
ruff checkandruff formatpasspytest tests/ -v)redeemed_itemis backward-compatible (nullable, optional body)Related
plan-mcd-tracker(traceability)Add lifetime stats endpoint returning level, XP progress, and counts (scanned/earned/redeemed/expired). Update PATCH /codes/{id}/redeem to accept optional redeemed_item field for tracking menu item preferences. - Add redeemed_item nullable column to CouponUsage model - Add StatsResponse and CodeRedeemRequest Pydantic schemas - Add GET /stats with level calculation (5 tiers, constants-based) - Update redeem endpoint to accept optional JSON body - Include redeemed_item in all CodeResponse/CodeRedeemResponse outputs - Alembic migration 003 for the new column - 15 new tests covering stats, levels, expiry counts, and redeemed_item Closes #16 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>Self-Review
Verdict: PASS -- no blocking issues found.
Acceptance Criteria Verification
GET /statsreturns correct JSON shapeLEVEL_THRESHOLDSconstantxp_progressis float 0.0-1.0max(0.0, min(1.0, ...))PATCH /codes/{id}/redeemaccepts optionalredeemed_itembodyredeemed_itemin code response objectsCodeResponse,CodeRedeemResponse, and all route constructorsGET /statsrequires authget_current_userdependencyNotes
_calculate_leveluses constants (not hardcoded in queries) per constraintshigh=None) uses a virtual span of 50 for XP progress, filling at 101 redeemedredeemed_itemis treated as falsy (not saved), which is appropriate for the "What did you get?" chip picker UXquery.filter()returns new query objects, so reusingbaseinget_statsis safePR #17 Review
DOMAIN REVIEW
Tech stack: Python 3.14 / FastAPI / SQLAlchemy / Alembic / Pydantic v2 / pytest
This PR implements Phase 13 (Gamification) from
plan-mcd-tracker: aGET /statsendpoint returning lifetime gamification stats and an update toPATCH /codes/{id}/redeemto accept an optionalredeemed_itemfield. 6 files changed, 369 additions, 1 deletion.Models (
models.py): Newredeemed_item: Mapped[str | None]column withString(200)-- nullable, consistent with existing column patterns. Clean.Migration (
003_add_redeemed_item_column.py): Additive nullable column, safe on existing data. Revision chain002 -> 003is correct. Has properdowngrade()that drops the column. Clean.Schemas (
schemas.py):StatsResponse-- 7 fields, all typed, noConfigDict(from_attributes=True)needed since it's constructed manually. Correct.CodeRedeemRequest-- single optional field. Clean.redeemed_itemadded to bothCodeResponseandCodeRedeemResponse. Consistent.Routes (
locations.py):LEVEL_THRESHOLDS-- well-structured constants. Level names match plan ("BOGO Beginner", "Free Food Fan", "BOGO Hunter", "Coupon Legend", "BOGO Master")._calculate_level()-- correct logic with proper clamping viamax(0.0, min(1.0, ...)). Fallback at the end is unreachable but defensive. Docstring present.get_stats()-- properly filtered bykeycloak_sub. Three separate.count()queries is acceptable for a stats endpoint that won't be called in hot loops.redeem_code()update --body: CodeRedeemRequest | None = Nonemakes the body fully optional, maintaining backward compatibility. Theif body and body.redeemed_item:guard handles all three cases (no body, empty body, body with item).redeemed_itemcorrectly plumbed throughlog_code()andlist_codes()response construction.Routes (
codes.py):redeemed_itemadded to the explicit column select list and response construction. Consistent with the join-based query pattern.Tests (
test_stats.py): 15 tests across 5 test classes:redeemed_itemappears in bothGET /codesandGET /locations/{id}/codesresponsesTest helpers
_create_location()and_create_code()are well-designed with sensible defaults and keyword-only flags for clarity.BLOCKERS
None.
All new functionality has test coverage. No unvalidated user input that poses injection risk (Pydantic validates the type, SQLAlchemy parameterizes queries,
String(200)at DB level enforces length). No secrets in code. No DRY violations in auth paths.NITS
redeemed_itemPydantic-level length validation: TheCodeRedeemRequest.redeemed_itemfield has noField(max_length=200)constraint. The DBString(200)will reject overlength strings, but the error will be an unhandledDataError/OperationalErrorrather than a clean 422 from Pydantic. This is consistent with the existing codebase (no schemas useField(max_length=...)anywhere), so not a blocker -- but worth adding as a future hardening pass across all string schemas.redeemed_itemempty string handling:if body and body.redeemed_item:treats""(empty string) as falsy, so an empty string won't be stored. This is probably the desired behavior, but if a user explicitly sends{"redeemed_item": ""}, the field staysNonesilently. Acceptable for this use case.Plan level name mismatch: The plan says
"Free Food Fanatic"but the code uses"Free Food Fan". Minor -- the plan likely reflects an earlier draft. Just flag for plan-code consistency.Three sequential COUNT queries in
get_stats():total_earned,total_redeemed, andtotal_expiredeach hit the DB separately. For a personal tracker this is fine. If this ever becomes a hot endpoint, these could be collapsed into a single query with conditional aggregation (SUM(CASE WHEN ...)). Not actionable now.noqa: E712comments: TheCouponUsage.redeemed == True/== Falsecomparisons are correctly annotated with# noqa: E712. This is the right pattern for SQLAlchemy boolean filters. Clean.SOP COMPLIANCE
16-stats-endpoint-redeemed-itemreferences issue #16plan-mcd-trackerNote: PR body uses "Review Checklist" instead of the standard "Test Plan" as the testing section. The test plan content is present but split across both sections. Minor template deviation.
PROCESS OBSERVATIONS
VERDICT: APPROVED