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:
+53
-1
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user