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:
Craig
2026-07-26 12:56:59 +01:00
parent 3ab08c4c90
commit 3f2c765c84
5 changed files with 589 additions and 13 deletions
+53 -1
View File
@@ -7,28 +7,68 @@ Validation happens here at the boundary (spec §8.1 rule 11).
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
UnitType = Literal["weight", "count"]
FoodSource = Literal["openfoodfacts", "manual", "meal"]
MealSlot = Literal["breakfast", "lunch", "dinner", "snack"]
# ── Foods ────────────────────────────────────────────────────────────────────
class FoodCreate(BaseModel):
"""Schema for POST /api/foods. calories_per_unit required unless is_meal."""
name: str
brand: str | None = None
barcode: str | None = None
source: FoodSource = "manual"
is_meal: bool = False
unit_type: UnitType = "weight"
calories_per_unit: float | None = Field(default=None, gt=0)
protein_per_unit: float | None = Field(default=None, ge=0)
carbs_per_unit: float | None = Field(default=None, ge=0)
fat_per_unit: float | None = Field(default=None, ge=0)
fiber_per_unit: float | None = Field(default=None, ge=0)
saturated_fat_per_unit: float | None = Field(default=None, ge=0)
sugars_per_unit: float | None = Field(default=None, ge=0)
sodium_per_unit: float | None = Field(default=None, ge=0)
serving_size_g: float | None = Field(default=None, gt=0)
serving_name: str | None = None
off_data: str | None = None
@model_validator(mode="after")
def _check_calories_for_non_meal(self):
if not self.is_meal and self.calories_per_unit is None:
raise ValueError("calories_per_unit is required when is_meal=false")
return self
class FoodUpdate(BaseModel):
"""Schema for PUT /api/foods/{id}. All fields optional — only supplied
fields are updated."""
name: str | None = None
brand: str | None = None
barcode: str | None = None
unit_type: UnitType | None = None
calories_per_unit: float | None = Field(default=None, gt=0)
protein_per_unit: float | None = Field(default=None, ge=0)
carbs_per_unit: float | None = Field(default=None, ge=0)
fat_per_unit: float | None = Field(default=None, ge=0)
fiber_per_unit: float | None = Field(default=None, ge=0)
saturated_fat_per_unit: float | None = Field(default=None, ge=0)
sugars_per_unit: float | None = Field(default=None, ge=0)
sodium_per_unit: float | None = Field(default=None, ge=0)
serving_size_g: float | None = Field(default=None, gt=0)
serving_name: str | None = None
off_data: str | None = None
class FoodRead(BaseModel):
"""Schema for GET /api/foods responses."""
model_config = ConfigDict(from_attributes=True)
id: int
@@ -42,9 +82,18 @@ class FoodRead(BaseModel):
protein_per_unit: float | None
carbs_per_unit: float | None
fat_per_unit: float | None
fiber_per_unit: float | None
saturated_fat_per_unit: float | None
sugars_per_unit: float | None
sodium_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
# ── Log ──────────────────────────────────────────────────────────────────────
class LogEntryCreate(BaseModel):
@@ -65,6 +114,9 @@ class LogEntryRead(BaseModel):
sort_order: int
# ── Targets ──────────────────────────────────────────────────────────────────
class TargetRead(BaseModel):
model_config = ConfigDict(from_attributes=True)