Public Access
feat: phase r1+r2 recovery + r3-0 swiftly api ingestion
R1 stabilization: pytest harness with transactional db fixture, smoke + alembic + auth + scrape + approval + swiftly tests, github actions ci yaml. Bearer-token admin auth + signed-cookie session for family ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns 202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL fail-fast on empty. R2 deferred-risk spikes: live lucky california fetch (R2-A), full email+per-voter approval click round trip with single-use enforcement (R2-B, console email backend, sendgrid stub). R3-0 phase 3 redesign: replaced playwright html scraper with requests based swiftly json api client. 17 categories, ~10k products per scrape, upsert by (source, external_id). 401 surfaces actionable token-refresh message via ScrapeLog.error_message. Pre-existing defects fixed: shopping_list.py syntax error blocking app import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid kwarg, missing requests dep, calorie_target schema drift, every SQLEnum needed values_callable, 0001 had empty downgrade(), seed had duplicate ingredient rows. Migrations added: 0003 grocery_item.description, 0004 family_profile. calorie_target, 0005 grocery_item.external_id + source + composite index. Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean, frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# R2-B — Email + per-voter approval round-trip spike
|
||||
|
||||
## Schema gap (top, per spec)
|
||||
|
||||
The spike surfaced a pre-existing **model relationship bug** that blocks
|
||||
the round-trip even after a clean schema bootstrap:
|
||||
`backend/app/models/__init__.py:201` declares
|
||||
`MealPlan.votes = relationship("MealPlanVote", back_populates="meal_plan", ...)`
|
||||
but `MealPlanVote` has no `meal_plan` FK or relationship. SQLAlchemy
|
||||
fails at first mapper configure with `NoForeignKeysError`. Details +
|
||||
two pre-existing migration bug in `r2b-blockers.md`. Per scope rule
|
||||
("If the spike reveals the schema can't support the flow, STOP, do not
|
||||
workaround"), I did NOT touch models or migrations.
|
||||
|
||||
## What landed
|
||||
|
||||
- `backend/app/services/email.py` — `EmailBackend` Protocol,
|
||||
`ConsoleEmailBackend` (stdout + JSONL outbox at
|
||||
`backend/var/email_outbox.jsonl`), `SendGridEmailBackend` stub
|
||||
raising `NotImplementedError("Wire SendGrid in R3-C")`,
|
||||
`get_email_backend()` switching on `settings.EMAIL_BACKEND`.
|
||||
- `backend/app/services/approval.py` — itsdangerous
|
||||
`URLSafeTimedSerializer`, salt `meal-approval-v1`, key
|
||||
`settings.SECRET_KEY`. `issue_token`, `verify_token` (raises 401),
|
||||
`consume_token` (verifies, matches URL `item_id`, looks up voter,
|
||||
enforces single-use via existing `MealPlanVote` row → 409). Single-
|
||||
use enforcement lives ONLY in `consume_token`.
|
||||
- `backend/app/api/meals.py` — replaced two vote routes:
|
||||
- `GET /api/meals/vote/{item_id}?token=...` → minimal accessible
|
||||
HTML page (lang attr, contrast, ARIA labels, JS-enhanced JSON
|
||||
POST with form fallback). Token only inside form `action`,
|
||||
never visible body.
|
||||
- `POST /api/meals/vote/{item_id}?token=...` body `{"vote": "approve"|"deny"}`.
|
||||
Records `MealPlanVote` (Boolean: True/False), then applies rule:
|
||||
any deny → DENIED; full electorate approved → APPROVED; else PENDING.
|
||||
Returns `{"status": "recorded", "item_status": "..."}`.
|
||||
- `backend/app/config.py` — added `EMAIL_BACKEND: str = "console"`.
|
||||
- `backend/requirements-dev.txt` — added `freezegun>=1.4`.
|
||||
- `.gitignore` — appended `backend/var/`.
|
||||
- `scripts/send_test_approval.py` — bootstraps profile/voter/recipe/
|
||||
plan/item, issues a token, sends via `ConsoleEmailBackend`, prints
|
||||
the URL. `--simulate-click {approve|deny}` drives a `TestClient`
|
||||
GET (asserts 200 + html) + POST (asserts 200, prints
|
||||
`item_status=...`) + a second POST (asserts 409 single-use).
|
||||
- `backend/tests/test_approval.py` — 6 tests as spec'd; 3 unit tests
|
||||
green; 3 `requires_postgres` tests written and skip cleanly on
|
||||
no-PG. They will pass once Blocker 1 is fixed.
|
||||
|
||||
## URL contract
|
||||
|
||||
`/api/meals/vote/{item_id}?token=<itsdangerous-signed JSON>`.
|
||||
Token payload: `{"item": str(uuid), "voter": str(uuid)}`. TTL 7 days.
|
||||
Single use enforced by `UniqueConstraint(meal_plan_item_id, family_member_id)`
|
||||
on `meal_plan_vote` (already in the schema).
|
||||
|
||||
## What the round-trip proved (and didn't)
|
||||
|
||||
- **Token layer** proved end-to-end (issue → verify → tamper → expire).
|
||||
- **Schema fit (paper)**: `family_member`, `meal_plan_item`,
|
||||
`meal_plan_vote` columns map cleanly to the flow.
|
||||
- **Schema fit (runtime)**: BLOCKED by `MealPlan.votes` defect.
|
||||
Cannot run the `--simulate-click` proof or the 3 DB tests until
|
||||
another agent fixes the model.
|
||||
|
||||
## Verification (what I ran)
|
||||
|
||||
- `pytest backend/tests/test_approval.py -q` → **3 passed, 3 skipped**.
|
||||
- `python -c "from app.services.email import get_email_backend; print(...)"`
|
||||
→ `ConsoleEmailBackend`.
|
||||
- `python scripts/send_test_approval.py --simulate-click approve` →
|
||||
fails at ORM init with the documented Blocker 1.
|
||||
|
||||
## Files
|
||||
|
||||
- created: `backend/app/services/__init__.py`,
|
||||
`backend/app/services/email.py`, `backend/app/services/approval.py`,
|
||||
`scripts/send_test_approval.py`,
|
||||
`backend/tests/test_approval.py`,
|
||||
`.agent/phase-summaries/r2b-blockers.md`,
|
||||
`.agent/phase-summaries/r2b-summary.md`.
|
||||
- modified: `backend/app/api/meals.py`, `backend/app/config.py`,
|
||||
`backend/requirements-dev.txt`, `.gitignore`.
|
||||
Reference in New Issue
Block a user