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:
+83
-26
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user