TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan

- services/off.py: single normalizer module (kcal/kJ fallback, not-found
  rule); fetch_product/search_off degrade gracefully on upstream errors
  (503/timeout → None / []), never 500; User-Agent + timeouts on all calls
- routers/off.py: thin routes for GET /api/off/product/{barcode},
  GET /api/off/search, POST /api/off/refresh/{food_id} (404 unknown id,
  400 no-barcode)
- services/foods.py: restore-on-rescan (§3.1) — barcode collision on a
  soft-deleted food clears deleted_at + updates row instead of 409;
  GET /api/foods/recent ordered by most-recent daily_log appearance,
  deduped, soft-deleted excluded
- tests: httpx mocked at the boundary (MockTransport, no real OFF);
  48 new tests (normalizer unit + router + restore + recent + error paths)
- 152 passing (was 104)
This commit is contained in:
Craig
2026-07-26 15:46:24 +01:00
parent f8048da9c1
commit 32461b7405
7 changed files with 1095 additions and 46 deletions
+83 -26
View File
@@ -6,10 +6,10 @@ Handlers stay thin (~15 lines) by calling into these functions.
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from models import Food
from models import DailyLogEntry, Food
from schemas import FoodCreate, FoodRead, FoodUpdate
@@ -66,38 +66,36 @@ def get_food(db: Session, food_id: int) -> FoodRead | None:
def create_food(db: Session, data: FoodCreate) -> FoodRead:
"""Create a food. Checks barcode uniqueness on live foods (409).
"""Create a food. Barcode uniqueness on LIVE foods 409.
Restore-on-rescan (§3.1): when the barcode matches a SOFT-DELETED food
(deleted_at IS NOT NULL), clear deleted_at and UPDATE that existing row
with the new data instead of inserting a duplicate. All in one transaction.
SQLite treats NULL barcodes as distinct, so multiple barcode-less foods
are fine."""
are fine.
"""
if data.barcode is not None:
existing = db.scalar(
select(Food).where(Food.barcode == data.barcode)
)
if existing is not None:
if existing.deleted_at is not None:
# ── Restore-on-rescan: update the soft-deleted row ──
_apply_create_data(existing, data)
existing.deleted_at = None
existing.updated_at = _now()
db.commit()
db.refresh(existing)
return FoodRead.model_validate(existing)
# Live food with same barcode → conflict
raise BarcodeConflictError(data.barcode)
now = datetime.now(timezone.utc).replace(tzinfo=None)
food = Food(
name=data.name,
brand=data.brand,
barcode=data.barcode,
source=data.source,
is_meal=data.is_meal,
unit_type=data.unit_type,
calories_per_unit=data.calories_per_unit,
protein_per_unit=data.protein_per_unit,
carbs_per_unit=data.carbs_per_unit,
fat_per_unit=data.fat_per_unit,
fiber_per_unit=data.fiber_per_unit,
saturated_fat_per_unit=data.saturated_fat_per_unit,
sugars_per_unit=data.sugars_per_unit,
sodium_per_unit=data.sodium_per_unit,
serving_size_g=data.serving_size_g,
serving_name=data.serving_name,
off_data=data.off_data,
created_at=now,
updated_at=now,
)
now = _now()
food = Food()
_apply_create_data(food, data)
food.created_at = now
food.updated_at = now
db.add(food)
db.commit()
db.refresh(food)
@@ -144,6 +142,65 @@ def delete_food(db: Session, food_id: int) -> FoodRead | None:
return FoodRead.model_validate(food)
# ── Recent foods (TICKET-006) ────────────────────────────────────────────────
def get_recent_foods(db: Session, limit: int = 10) -> list[FoodRead]:
"""Return foods ordered by most recent appearance in daily_log.
Deduplicated: each food appears at most once.
Excludes soft-deleted foods (§8.1 rule 7).
Ordered by the daily_log entry's created_at (the moment the food was
logged), NOT by the food's own created_at and NOT by the log ``date``
field. This means a food you just backfilled to an old date still
appears as "recent" because your log *action* was recent — which is
the intended UX.
If no foods have ever been logged, returns an empty list.
"""
last_logged = func.max(DailyLogEntry.created_at).label("last_logged")
stmt = (
select(Food, last_logged)
.join(DailyLogEntry, DailyLogEntry.food_id == Food.id)
.where(_not_deleted())
.group_by(Food.id)
.order_by(desc("last_logged"))
.limit(limit)
)
rows = db.execute(stmt).all()
return [FoodRead.model_validate(row[0]) for row in rows]
# ── Internal helpers ─────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _apply_create_data(food: Food, data: FoodCreate) -> None:
"""Copy FoodCreate fields onto a Food ORM object (used for both insert
and restore-on-rescan update paths)."""
food.name = data.name
food.brand = data.brand
food.barcode = data.barcode
food.source = data.source
food.is_meal = data.is_meal
food.unit_type = data.unit_type
food.calories_per_unit = data.calories_per_unit
food.protein_per_unit = data.protein_per_unit
food.carbs_per_unit = data.carbs_per_unit
food.fat_per_unit = data.fat_per_unit
food.fiber_per_unit = data.fiber_per_unit
food.saturated_fat_per_unit = data.saturated_fat_per_unit
food.sugars_per_unit = data.sugars_per_unit
food.sodium_per_unit = data.sodium_per_unit
food.serving_size_g = data.serving_size_g
food.serving_name = data.serving_name
food.off_data = data.off_data
# ── Errors ───────────────────────────────────────────────────────────────────