Files
calcount/backend/services/foods.py
T
Craig 3f2c765c84 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)
2026-07-26 12:56:59 +01:00

155 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Foods service layer — business logic + DB access (spec §8.1 rules 23, 7).
ORM objects never leave this module; functions return Pydantic schemas.
Handlers stay thin (~15 lines) by calling into these functions.
"""
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from models import Food
from schemas import FoodCreate, FoodRead, FoodUpdate
# ── Shared query helpers (§8.1 rule 7) ───────────────────────────────────────
def _not_deleted():
"""Shared filter: exclude soft-deleted foods. Use everywhere search/recent
queries filter — never copy-paste per-endpoint."""
return Food.deleted_at.is_(None)
def _base_search_stmt(db: Session, include_deleted: bool = False):
"""Base statement for food queries. Applies soft-delete filter unless
include_deleted is True."""
stmt = select(Food)
if not include_deleted:
stmt = stmt.where(_not_deleted())
return stmt
# ── CRUD ─────────────────────────────────────────────────────────────────────
def list_foods(
db: Session,
q: str | None = None,
barcode: str | None = None,
limit: int = 50,
offset: int = 0,
include_deleted: bool = False,
) -> list[FoodRead]:
"""Search foods. Supports q (matches name AND brand), barcode (exact match),
limit/offset pagination, and include_deleted toggle."""
stmt = _base_search_stmt(db, include_deleted=include_deleted)
if q:
stmt = stmt.where(Food.name.contains(q) | Food.brand.contains(q))
if barcode:
stmt = stmt.where(Food.barcode == barcode)
stmt = stmt.limit(limit).offset(offset)
foods = db.scalars(stmt).all()
return [FoodRead.model_validate(f) for f in foods]
def get_food(db: Session, food_id: int) -> FoodRead | None:
"""Get a single food by id. INCLUDES soft-deleted foods (historical log
rendering depends on this — spec §2.1). Returns None for unknown id."""
food = db.get(Food, food_id)
if food is None:
return None
return FoodRead.model_validate(food)
def create_food(db: Session, data: FoodCreate) -> FoodRead:
"""Create a food. Checks barcode uniqueness on live foods (409).
SQLite treats NULL barcodes as distinct, so multiple barcode-less foods
are fine."""
if data.barcode is not None:
existing = db.scalar(
select(Food).where(Food.barcode == data.barcode)
)
if existing is not None:
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,
)
db.add(food)
db.commit()
db.refresh(food)
return FoodRead.model_validate(food)
def update_food(db: Session, food_id: int, data: FoodUpdate) -> FoodRead | None:
"""Update editable fields on a food. Only supplied (non-None) fields are
changed. Returns None if food not found."""
food = db.get(Food, food_id)
if food is None:
return None
update_data = data.model_dump(exclude_unset=True)
# Check barcode uniqueness if barcode is being changed to a non-None value
if "barcode" in update_data and update_data["barcode"] is not None:
new_bc = update_data["barcode"]
existing = db.scalar(
select(Food).where(Food.barcode == new_bc, Food.id != food_id)
)
if existing is not None:
raise BarcodeConflictError(new_bc)
for field, value in update_data.items():
setattr(food, field, value)
food.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
db.refresh(food)
return FoodRead.model_validate(food)
def delete_food(db: Session, food_id: int) -> FoodRead | None:
"""Soft-delete a food: sets deleted_at timestamp. Returns None if not found."""
food = db.get(Food, food_id)
if food is None:
return None
food.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
food.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
db.commit()
db.refresh(food)
return FoodRead.model_validate(food)
# ── Errors ───────────────────────────────────────────────────────────────────
class BarcodeConflictError(Exception):
"""Raised when a barcode already exists on another food."""
def __init__(self, barcode: str):
super().__init__(f"Barcode '{barcode}' already exists")
self.barcode = barcode