feat: add GET /stats endpoint + redeemed_item column #17

Merged
forgejo_admin merged 1 commit from 16-stats-endpoint-redeemed-item into main 2026-03-16 22:41:03 +00:00
Contributor

Summary

Add a GET /stats endpoint returning lifetime gamification stats (level, XP progress, scanned/earned/redeemed/expired counts) and update PATCH /codes/{id}/redeem to accept an optional redeemed_item field 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 nullable redeemed_item String(200) column to CouponUsage
  • src/mcd_tracker_api/schemas.py -- add StatsResponse, CodeRedeemRequest schemas; add redeemed_item to CodeResponse and CodeRedeemResponse
  • src/mcd_tracker_api/routes/locations.py -- add LEVEL_THRESHOLDS constants and _calculate_level() helper; add GET /stats endpoint; update PATCH /codes/{id}/redeem to accept optional body with redeemed_item
  • src/mcd_tracker_api/routes/codes.py -- include redeemed_item in GET /codes query and response
  • alembic/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 endpoints

Test Plan

  • pytest tests/ -v -- 144 tests pass (129 existing + 15 new)
  • Key scenarios: stats with 0 codes (Level 1, all zeros), 6 redeemed (Level 2, xp_progress=0.0), redeem with redeemed_item saves it, redeem without body still works, expired codes counted correctly (only non-redeemed)
  • Migration is additive (nullable column), no data loss risk

Review Checklist

  • ruff check and ruff format pass
  • All 144 tests pass (pytest tests/ -v)
  • Alembic migration included and safe on existing data
  • No unrelated changes
  • Level thresholds are constants, not hardcoded in queries
  • redeemed_item is backward-compatible (nullable, optional body)
  • Plan: plan-mcd-tracker (traceability)
  • Closes #16
## Summary Add a `GET /stats` endpoint returning lifetime gamification stats (level, XP progress, scanned/earned/redeemed/expired counts) and update `PATCH /codes/{id}/redeem` to accept an optional `redeemed_item` field 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 nullable `redeemed_item` String(200) column to CouponUsage - `src/mcd_tracker_api/schemas.py` -- add `StatsResponse`, `CodeRedeemRequest` schemas; add `redeemed_item` to `CodeResponse` and `CodeRedeemResponse` - `src/mcd_tracker_api/routes/locations.py` -- add `LEVEL_THRESHOLDS` constants and `_calculate_level()` helper; add `GET /stats` endpoint; update `PATCH /codes/{id}/redeem` to accept optional body with `redeemed_item` - `src/mcd_tracker_api/routes/codes.py` -- include `redeemed_item` in `GET /codes` query and response - `alembic/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 endpoints ## Test Plan - `pytest tests/ -v` -- 144 tests pass (129 existing + 15 new) - Key scenarios: stats with 0 codes (Level 1, all zeros), 6 redeemed (Level 2, xp_progress=0.0), redeem with redeemed_item saves it, redeem without body still works, expired codes counted correctly (only non-redeemed) - Migration is additive (nullable column), no data loss risk ## Review Checklist - [x] `ruff check` and `ruff format` pass - [x] All 144 tests pass (`pytest tests/ -v`) - [x] Alembic migration included and safe on existing data - [x] No unrelated changes - [x] Level thresholds are constants, not hardcoded in queries - [x] `redeemed_item` is backward-compatible (nullable, optional body) ## Related - Plan: `plan-mcd-tracker` (traceability) - Closes #16
feat: add GET /stats endpoint and redeemed_item column for gamification
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
a3da07c7a2
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>
Author
Contributor

Self-Review

Verdict: PASS -- no blocking issues found.

Acceptance Criteria Verification

Criteria Status
GET /stats returns correct JSON shape Verified in schema + tests
Level thresholds: L1(0-5), L2(6-15), L3(16-30), L4(31-50), L5(51+) Verified in LEVEL_THRESHOLDS constant
Level names match spec Verified
xp_progress is float 0.0-1.0 Clamped with max(0.0, min(1.0, ...))
PATCH /codes/{id}/redeem accepts optional redeemed_item body Verified, backward-compatible
redeemed_item in code response objects Added to CodeResponse, CodeRedeemResponse, and all route constructors
GET /stats requires auth Uses get_current_user dependency
Alembic migration safe on existing data Nullable column, no data loss
Tests pass 144/144 (129 existing + 15 new)

Notes

  • _calculate_level uses constants (not hardcoded in queries) per constraints
  • Level 5 (high=None) uses a virtual span of 50 for XP progress, filling at 101 redeemed
  • Empty string redeemed_item is treated as falsy (not saved), which is appropriate for the "What did you get?" chip picker UX
  • SQLAlchemy query.filter() returns new query objects, so reusing base in get_stats is safe
## Self-Review **Verdict: PASS** -- no blocking issues found. ### Acceptance Criteria Verification | Criteria | Status | |----------|--------| | `GET /stats` returns correct JSON shape | Verified in schema + tests | | Level thresholds: L1(0-5), L2(6-15), L3(16-30), L4(31-50), L5(51+) | Verified in `LEVEL_THRESHOLDS` constant | | Level names match spec | Verified | | `xp_progress` is float 0.0-1.0 | Clamped with `max(0.0, min(1.0, ...))` | | `PATCH /codes/{id}/redeem` accepts optional `redeemed_item` body | Verified, backward-compatible | | `redeemed_item` in code response objects | Added to `CodeResponse`, `CodeRedeemResponse`, and all route constructors | | `GET /stats` requires auth | Uses `get_current_user` dependency | | Alembic migration safe on existing data | Nullable column, no data loss | | Tests pass | 144/144 (129 existing + 15 new) | ### Notes - `_calculate_level` uses constants (not hardcoded in queries) per constraints - Level 5 (`high=None`) uses a virtual span of 50 for XP progress, filling at 101 redeemed - Empty string `redeemed_item` is treated as falsy (not saved), which is appropriate for the "What did you get?" chip picker UX - SQLAlchemy `query.filter()` returns new query objects, so reusing `base` in `get_stats` is safe
Author
Contributor

PR #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: a GET /stats endpoint returning lifetime gamification stats and an update to PATCH /codes/{id}/redeem to accept an optional redeemed_item field. 6 files changed, 369 additions, 1 deletion.

Models (models.py): New redeemed_item: Mapped[str | None] column with String(200) -- nullable, consistent with existing column patterns. Clean.

Migration (003_add_redeemed_item_column.py): Additive nullable column, safe on existing data. Revision chain 002 -> 003 is correct. Has proper downgrade() that drops the column. Clean.

Schemas (schemas.py):

  • StatsResponse -- 7 fields, all typed, no ConfigDict(from_attributes=True) needed since it's constructed manually. Correct.
  • CodeRedeemRequest -- single optional field. Clean.
  • redeemed_item added to both CodeResponse and CodeRedeemResponse. 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 via max(0.0, min(1.0, ...)). Fallback at the end is unreachable but defensive. Docstring present.
  • get_stats() -- properly filtered by keycloak_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 = None makes the body fully optional, maintaining backward compatibility. The if body and body.redeemed_item: guard handles all three cases (no body, empty body, body with item).
  • redeemed_item correctly plumbed through log_code() and list_codes() response construction.

Routes (codes.py): redeemed_item added 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:

  • Zero-code baseline (Level 1, all zeros)
  • All 5 level thresholds tested at boundary values
  • XP progress midpoint calculation verified
  • Expired code counting (non-redeemed only, correctly excludes redeemed+expired)
  • User isolation (other user's data not counted)
  • Redeem with item, without body, with empty body
  • redeemed_item appears in both GET /codes and GET /locations/{id}/codes responses

Test 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

  1. redeemed_item Pydantic-level length validation: The CodeRedeemRequest.redeemed_item field has no Field(max_length=200) constraint. The DB String(200) will reject overlength strings, but the error will be an unhandled DataError/OperationalError rather than a clean 422 from Pydantic. This is consistent with the existing codebase (no schemas use Field(max_length=...) anywhere), so not a blocker -- but worth adding as a future hardening pass across all string schemas.

  2. redeemed_item empty 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 stays None silently. Acceptable for this use case.

  3. 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.

  4. Three sequential COUNT queries in get_stats(): total_earned, total_redeemed, and total_expired each 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.

  5. noqa: E712 comments: The CouponUsage.redeemed == True / == False comparisons are correctly annotated with # noqa: E712. This is the right pattern for SQLAlchemy boolean filters. Clean.

SOP COMPLIANCE

  • Branch named after issue: 16-stats-endpoint-redeemed-item references issue #16
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related section references plan-mcd-tracker
  • PR body says "Closes #16" -- proper issue linking
  • Tests exist: 15 new tests, PR claims 144 total pass
  • No secrets committed
  • No unrelated file changes -- all 6 files are directly related to the feature
  • Migration included for schema change
  • Review Checklist in PR body (ruff check, ruff format, tests, migration)

Note: 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

  • Deployment frequency: Clean, well-scoped PR. 6 files, single feature. Merges cleanly. No deployment risk.
  • Change failure risk: Low. Additive nullable column, backward-compatible API change, comprehensive test coverage at all boundary conditions.
  • Phase 13 scope: The plan describes a full gamification system including streaks, mascot, Lottie animations. This PR correctly limits scope to the backend prerequisites (stats endpoint + redeemed_item). Frontend gamification UX is a separate concern. Good scope discipline.
  • Epilogue candidates: Nits 1 (Pydantic max_length hardening) and 3 (plan level name sync) should go to the plan epilogue.

VERDICT: APPROVED

## PR #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`: a `GET /stats` endpoint returning lifetime gamification stats and an update to `PATCH /codes/{id}/redeem` to accept an optional `redeemed_item` field. 6 files changed, 369 additions, 1 deletion. **Models** (`models.py`): New `redeemed_item: Mapped[str | None]` column with `String(200)` -- nullable, consistent with existing column patterns. Clean. **Migration** (`003_add_redeemed_item_column.py`): Additive nullable column, safe on existing data. Revision chain `002 -> 003` is correct. Has proper `downgrade()` that drops the column. Clean. **Schemas** (`schemas.py`): - `StatsResponse` -- 7 fields, all typed, no `ConfigDict(from_attributes=True)` needed since it's constructed manually. Correct. - `CodeRedeemRequest` -- single optional field. Clean. - `redeemed_item` added to both `CodeResponse` and `CodeRedeemResponse`. 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 via `max(0.0, min(1.0, ...))`. Fallback at the end is unreachable but defensive. Docstring present. - `get_stats()` -- properly filtered by `keycloak_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 = None` makes the body fully optional, maintaining backward compatibility. The `if body and body.redeemed_item:` guard handles all three cases (no body, empty body, body with item). - `redeemed_item` correctly plumbed through `log_code()` and `list_codes()` response construction. **Routes** (`codes.py`): `redeemed_item` added 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: - Zero-code baseline (Level 1, all zeros) - All 5 level thresholds tested at boundary values - XP progress midpoint calculation verified - Expired code counting (non-redeemed only, correctly excludes redeemed+expired) - User isolation (other user's data not counted) - Redeem with item, without body, with empty body - `redeemed_item` appears in both `GET /codes` and `GET /locations/{id}/codes` responses Test 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 1. **`redeemed_item` Pydantic-level length validation**: The `CodeRedeemRequest.redeemed_item` field has no `Field(max_length=200)` constraint. The DB `String(200)` will reject overlength strings, but the error will be an unhandled `DataError`/`OperationalError` rather than a clean 422 from Pydantic. This is consistent with the existing codebase (no schemas use `Field(max_length=...)` anywhere), so not a blocker -- but worth adding as a future hardening pass across all string schemas. 2. **`redeemed_item` empty 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 stays `None` silently. Acceptable for this use case. 3. **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. 4. **Three sequential COUNT queries in `get_stats()`**: `total_earned`, `total_redeemed`, and `total_expired` each 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. 5. **`noqa: E712` comments**: The `CouponUsage.redeemed == True` / `== False` comparisons are correctly annotated with `# noqa: E712`. This is the right pattern for SQLAlchemy boolean filters. Clean. ### SOP COMPLIANCE - [x] Branch named after issue: `16-stats-endpoint-redeemed-item` references issue #16 - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related section references `plan-mcd-tracker` - [x] PR body says "Closes #16" -- proper issue linking - [x] Tests exist: 15 new tests, PR claims 144 total pass - [x] No secrets committed - [x] No unrelated file changes -- all 6 files are directly related to the feature - [x] Migration included for schema change - [x] Review Checklist in PR body (ruff check, ruff format, tests, migration) **Note**: 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 - **Deployment frequency**: Clean, well-scoped PR. 6 files, single feature. Merges cleanly. No deployment risk. - **Change failure risk**: Low. Additive nullable column, backward-compatible API change, comprehensive test coverage at all boundary conditions. - **Phase 13 scope**: The plan describes a full gamification system including streaks, mascot, Lottie animations. This PR correctly limits scope to the backend prerequisites (stats endpoint + redeemed_item). Frontend gamification UX is a separate concern. Good scope discipline. - **Epilogue candidates**: Nits 1 (Pydantic max_length hardening) and 3 (plan level name sync) should go to the plan epilogue. ### VERDICT: APPROVED
forgejo_admin deleted branch 16-stats-endpoint-redeemed-item 2026-03-16 22:41:03 +00:00
Commenting is not possible because the repository is archived.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ldraney/mcd-tracker-api!17
No description provided.