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
@@ -98,7 +98,7 @@ def upgrade() -> None:
sa.Column('dietary_tags', postgresql.ARRAY(sa.String(length=50)), nullable=True),
sa.Column('protein_type', sa.String(length=50), nullable=True),
sa.Column('spice_level', sa.Integer(), nullable=True),
sa.Column('ingredients', postgresql.JSONB(astext=True), nullable=False),
sa.Column('ingredients', postgresql.JSONB(), nullable=False),
sa.Column('instructions', postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column('source_url', sa.Text(), nullable=True),
sa.Column('scraped_at', sa.DateTime(timezone=True), nullable=True),
@@ -286,4 +286,17 @@ def upgrade() -> None:
def downgrade() -> None:
pass
op.execute(
"""
DO $$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'alembic_version') LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
FOR r IN (SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname = 'public') LOOP
EXECUTE 'DROP TYPE IF EXISTS public.' || quote_ident(r.typname) || ' CASCADE';
END LOOP;
END $$;
"""
)
+1 -2
View File
@@ -95,8 +95,6 @@ def upgrade() -> None:
('Sugar', 'sugar', 'lb', 'Pantry', 2.49),
('Brown Rice', 'brown rice', 'lb', 'Grains', 3.49),
('Oats', 'oats', 'lb', 'Grains', 2.99),
('Chickpeas', 'chickpeas', 'can', 'Canned Goods', 1.49),
('Black Beans', 'black beans', 'can', 'Canned Goods', 1.29),
('Kidney Beans', 'kidney beans', 'can', 'Canned Goods', 1.29),
('Corn', 'corn', 'can', 'Canned Goods', 1.49),
('Green Beans', 'green beans', 'can', 'Canned Goods', 1.49),
@@ -107,6 +105,7 @@ def upgrade() -> None:
op.execute(f"""
INSERT INTO ingredient (id, name, name_lower, unit, aisle, typical_price)
VALUES (uuid_generate_v4(), '{name}', '{name_lower}', '{unit}', '{aisle}', {price})
ON CONFLICT (name_lower) DO NOTHING
""")
@@ -0,0 +1,32 @@
"""Add nullable description column to grocery_item.
The Lucky California parser produces a long-form description per coupon
(class ``coupon-card-short-description``); without this column it is dropped
silently when persisting. See R2-A spike notes in
``.agent/phase-summaries/r2a-summary.md``.
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0003'
down_revision: Union[str, None] = '0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"grocery_item",
sa.Column("description", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("grocery_item", "description")
@@ -0,0 +1,30 @@
"""Add calorie_target to family_profile
Revision ID: 0004
Revises: 0003
Create Date: 2026-05-04
The model has carried `calorie_target` on FamilyProfile since Phase 2,
but the initial migration omitted it. SELECT * from family_profile fails
without this column. Adversarial review §1.6 flagged the model/schema
drift.
"""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"family_profile",
sa.Column("calorie_target", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("family_profile", "calorie_target")
@@ -0,0 +1,48 @@
"""Add nullable indexed external_id and source columns to grocery_item.
The Swiftly API exposes a stable ``id`` per product (e.g. ``"46556"``).
Persisting it as ``grocery_item.external_id`` lets the scraper UPSERT by
``(source, external_id)`` rather than create duplicates on every run.
``source`` distinguishes overlapping IDs across future banners (e.g. a
second Save Mart store using the same Swiftly tenant). See R3-0 notes in
``.agent/phase-summaries/r3-0-summary.md``.
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0005'
down_revision: Union[str, None] = '0004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"grocery_item",
sa.Column("external_id", sa.String(length=100), nullable=True),
)
op.add_column(
"grocery_item",
sa.Column(
"source", sa.String(length=50), nullable=True,
),
)
op.create_index(
"ix_grocery_item_source_external_id",
"grocery_item",
["source", "external_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_grocery_item_source_external_id", table_name="grocery_item")
op.drop_column("grocery_item", "source")
op.drop_column("grocery_item", "external_id")