TICKET-001: Foods CRUD with soft-delete
- POST/GET/PUT/DELETE /api/foods per spec 3.1 (minus restore) - Service layer (services/foods.py) with shared soft-delete query helper - FoodCreate extended, FoodUpdate/FoodRead schemas added - Barcode conflict returns 409; deleted foods hidden from search, visible by id and via include_deleted=true - 29 new tests, full suite green (36 passed)
This commit is contained in:
+55
-10
@@ -1,25 +1,70 @@
|
||||
"""Foods router (spec §3.1). Thin: validate → service → schema (spec §8.1 rule 2)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import Food
|
||||
from schemas import FoodRead
|
||||
from schemas import FoodCreate, FoodRead, FoodUpdate
|
||||
from services.foods import (
|
||||
BarcodeConflictError,
|
||||
create_food,
|
||||
delete_food,
|
||||
get_food,
|
||||
list_foods,
|
||||
update_food,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/foods", tags=["foods"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[FoodRead])
|
||||
def list_foods(
|
||||
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 local foods. Soft-deleted foods are hidden (spec §8.1 rule 7)."""
|
||||
stmt = select(Food).where(Food.deleted_at.is_(None)).limit(limit).offset(offset)
|
||||
if q:
|
||||
stmt = stmt.where(Food.name.contains(q) | Food.brand.contains(q))
|
||||
return db.scalars(stmt).all()
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user