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:
2026-05-05 14:08:19 -07:00
co-authored by Claude Opus 4.7
parent b9434967ed
commit 8e89f793d5
58 changed files with 3594 additions and 348 deletions
+89
View File
@@ -0,0 +1,89 @@
"""
Per-voter approval tokens.
Stateless signed tokens (itsdangerous) keyed on settings.SECRET_KEY with a
versioned salt. Single-use is enforced by the presence of a MealPlanVote
row for (item, voter) — the table already has UniqueConstraint on that
pair, so the DB is the source of truth, not a token-status column.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import HTTPException
from itsdangerous import (
BadSignature,
SignatureExpired,
URLSafeTimedSerializer,
)
from app.config import settings
from app.models import FamilyMember, MealPlanVote
if TYPE_CHECKING: # pragma: no cover
from sqlalchemy.orm import Session
SALT = "meal-approval-v1"
DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 3600
def _serializer() -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(secret_key=settings.SECRET_KEY, salt=SALT)
def issue_token(meal_plan_item_id: UUID, family_member_id: UUID) -> str:
payload = {
"item": str(meal_plan_item_id),
"voter": str(family_member_id),
}
return _serializer().dumps(payload)
def verify_token(token: str, max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> dict:
try:
payload = _serializer().loads(token, max_age=max_age_seconds)
except SignatureExpired:
raise HTTPException(status_code=401, detail="Token expired")
except BadSignature:
raise HTTPException(status_code=401, detail="Invalid token")
if not isinstance(payload, dict) or "item" not in payload or "voter" not in payload:
raise HTTPException(status_code=401, detail="Invalid token payload")
return payload
def consume_token(
db: "Session",
token: str,
meal_plan_item_id: UUID,
max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS,
) -> FamilyMember:
"""Verify + match URL + enforce single-use. Returns the voter on success.
Single-use is checked by looking for an existing MealPlanVote row for
(item, voter). If one exists, raise 409.
"""
payload = verify_token(token, max_age_seconds=max_age_seconds)
if str(payload["item"]) != str(meal_plan_item_id):
raise HTTPException(status_code=400, detail="Token not valid for this meal")
voter_id = UUID(str(payload["voter"]))
voter = db.query(FamilyMember).filter(FamilyMember.id == voter_id).first()
if not voter:
raise HTTPException(status_code=404, detail="Voter not found")
existing = (
db.query(MealPlanVote)
.filter(
MealPlanVote.meal_plan_item_id == meal_plan_item_id,
MealPlanVote.family_member_id == voter_id,
)
.first()
)
if existing:
raise HTTPException(status_code=409, detail="Already voted")
return voter