32461b7405
- 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)
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
"""Foods router (spec §3.1). Thin: validate → service → schema (spec §8.1 rule 2)."""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database import get_db
|
|
from schemas import FoodCreate, FoodRead, FoodUpdate
|
|
from services.foods import (
|
|
BarcodeConflictError,
|
|
create_food,
|
|
delete_food,
|
|
get_food,
|
|
get_recent_foods,
|
|
list_foods,
|
|
update_food,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/foods", tags=["foods"])
|
|
|
|
|
|
@router.get("/recent", response_model=list[FoodRead])
|
|
def _recent_foods(
|
|
limit: int = Query(default=10, le=50),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Foods ordered by most recent appearance in daily_log, deduplicated.
|
|
Soft-deleted foods excluded. Ordered by most-recently-LOGGED, not by
|
|
foods.created_at."""
|
|
return get_recent_foods(db, limit=limit)
|
|
|
|
|
|
@router.get("", response_model=list[FoodRead])
|
|
def _list_foods(
|
|
q: str | None = None,
|
|
barcode: str | None = None,
|
|
limit: int = Query(default=50, le=200),
|
|
offset: int = 0,
|
|
include_deleted: bool = False,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Search foods. q matches name AND brand. Soft-deleted excluded by default."""
|
|
return list_foods(db, q=q, barcode=barcode, limit=limit, offset=offset,
|
|
include_deleted=include_deleted)
|
|
|
|
|
|
@router.get("/{food_id}", response_model=FoodRead)
|
|
def _get_food(food_id: int, db: Session = Depends(get_db)):
|
|
"""Get a single food, including soft-deleted ones (for historical logs)."""
|
|
food = get_food(db, food_id)
|
|
if food is None:
|
|
raise HTTPException(status_code=404, detail="Food not found")
|
|
return food
|
|
|
|
|
|
@router.post("", response_model=FoodRead, status_code=201)
|
|
def _create_food(data: FoodCreate, db: Session = Depends(get_db)):
|
|
"""Create a food. Barcode uniqueness enforced on live foods → 409."""
|
|
try:
|
|
return create_food(db, data)
|
|
except BarcodeConflictError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
|
|
|
|
@router.put("/{food_id}", response_model=FoodRead)
|
|
def _update_food(food_id: int, data: FoodUpdate, db: Session = Depends(get_db)):
|
|
"""Update editable fields. Only supplied fields are changed."""
|
|
try:
|
|
result = update_food(db, food_id, data)
|
|
except BarcodeConflictError as e:
|
|
raise HTTPException(status_code=409, detail=str(e))
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Food not found")
|
|
return result
|
|
|
|
|
|
@router.delete("/{food_id}", response_model=FoodRead)
|
|
def _delete_food(food_id: int, db: Session = Depends(get_db)):
|
|
"""Soft-delete: sets deleted_at. Food hidden from search, still visible by id."""
|
|
result = delete_food(db, food_id)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Food not found")
|
|
return result
|