Public Access
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>
90 lines
2.2 KiB
Python
90 lines
2.2 KiB
Python
"""
|
|
Email backends.
|
|
|
|
R2-B spike: only ConsoleEmailBackend is functional. SendGrid is a stub
|
|
intentionally left to be wired in R3-C.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional, Protocol
|
|
|
|
from app.config import settings
|
|
|
|
|
|
# Repository convention: backend/var/email_outbox.jsonl
|
|
# This module lives at backend/app/services/email.py — go up two levels to
|
|
# reach backend/, then var/.
|
|
_BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
OUTBOX_PATH = _BACKEND_ROOT / "var" / "email_outbox.jsonl"
|
|
|
|
|
|
class EmailBackend(Protocol):
|
|
def send(
|
|
self,
|
|
to: str,
|
|
subject: str,
|
|
html: str,
|
|
text: Optional[str] = None,
|
|
) -> None:
|
|
...
|
|
|
|
|
|
class ConsoleEmailBackend:
|
|
"""Dev/spike backend. Prints to stdout AND appends a JSON line to
|
|
backend/var/email_outbox.jsonl so the round-trip can be inspected
|
|
after the fact.
|
|
"""
|
|
|
|
def send(
|
|
self,
|
|
to: str,
|
|
subject: str,
|
|
html: str,
|
|
text: Optional[str] = None,
|
|
) -> None:
|
|
record = {
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"to": to,
|
|
"subject": subject,
|
|
"html": html,
|
|
"text": text,
|
|
}
|
|
print(
|
|
f"[ConsoleEmailBackend] -> {to} | {subject}",
|
|
file=sys.stdout,
|
|
flush=True,
|
|
)
|
|
OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with OUTBOX_PATH.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record) + "\n")
|
|
|
|
|
|
class SendGridEmailBackend:
|
|
"""Stub. Wire in R3-C (real SendGrid client + sandbox mode + retries).
|
|
|
|
Deliberately raises so a misconfigured prod env fails loudly instead of
|
|
silently dropping mail.
|
|
"""
|
|
|
|
def send(
|
|
self,
|
|
to: str,
|
|
subject: str,
|
|
html: str,
|
|
text: Optional[str] = None,
|
|
) -> None: # pragma: no cover - stub
|
|
raise NotImplementedError("Wire SendGrid in R3-C")
|
|
|
|
|
|
def get_email_backend() -> EmailBackend:
|
|
backend = (settings.EMAIL_BACKEND or "console").lower()
|
|
if backend == "sendgrid":
|
|
return SendGridEmailBackend()
|
|
return ConsoleEmailBackend()
|