Files

290 lines
9.6 KiB
Python

"""Pydantic request/response schemas — the API contract (spec §8.3 rule 1).
Defined separately from ORM models; services convert via from_attributes.
Validation happens here at the boundary (spec §8.1 rule 11).
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
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
name: str
brand: str | None
barcode: str | None
source: FoodSource
is_meal: bool
unit_type: UnitType
calories_per_unit: float | None
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
# ── Meal components & MealRead (§3.2, TICKET-007) ────────────────────────────
class MealComponentRead(BaseModel):
"""A single component within a meal, with the component food embedded
so the frontend can render names/nutrition without N+1 lookups."""
model_config = ConfigDict(from_attributes=True)
food_id: int
quantity: float
food: "LogFoodRead"
class MealRead(FoodRead):
"""A meal food with its components and computed per-1.0-meal nutrition.
Returned by GET /api/foods/{id} when the food is a meal. For non-meal
foods the plain FoodRead is returned instead.
"""
components: list[MealComponentRead] = []
computed_nutrition_per_meal: dict[str, float] = Field(default_factory=dict)
# ── Log ──────────────────────────────────────────────────────────────────────
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1).
When is_meal is True, components is populated with the meal's components
(with their own food embedded) so the frontend can render collapsible rows.
"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
components: list[MealComponentRead] | None = None
class LogEntryCreate(BaseModel):
food_id: int
quantity: float = Field(gt=0)
meal_slot: MealSlot | None = None
date: date # client-supplied YYYY-MM-DD (spec §8.1 rule 8)
class LogEntryUpdate(BaseModel):
"""Schema for PUT /api/log/{id}. All fields optional — only supplied
fields are updated. Send {"meal_slot": null} to clear the slot."""
quantity: float | None = Field(default=None, gt=0)
meal_slot: MealSlot | None = None
sort_order: int | None = None
class LogEntryRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
date: date
food_id: int
quantity: float
meal_slot: MealSlot | None
sort_order: int
food: LogFoodRead
computed_nutrition: dict[str, float] | None = None
# ── Meal endpoints (TICKET-007) ──────────────────────────────────────────────
class MealFromLogRequest(BaseModel):
"""Body for POST /api/meals/from-log."""
name: str
date: date
entry_ids: list[int] = Field(min_length=1)
class MealFromLogResponse(BaseModel):
"""Response for POST /api/meals/from-log."""
meal: MealRead
entry: LogEntryRead
class MealUnpackRequest(BaseModel):
"""Body for POST /api/meals/{meal_id}/unpack."""
date: date
entry_id: int | None = None
class MealUnpackResponse(BaseModel):
"""Response for POST /api/meals/{meal_id}/unpack."""
entries: list[LogEntryRead]
class MealComponentInput(BaseModel):
"""A single component in a PUT /api/meals/{meal_id}/components request."""
food_id: int
quantity: float = Field(gt=0)
class MealComponentsUpdateRequest(BaseModel):
"""Body for PUT /api/meals/{meal_id}/components — full replacement."""
components: list[MealComponentInput]
# ── Targets ──────────────────────────────────────────────────────────────────
class TargetCreate(BaseModel):
"""Schema for POST /api/targets. end_date is auto-managed by the service."""
start_date: date # client-supplied YYYY-MM-DD (spec §8.1 rule 8)
calories: int = Field(gt=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
class TargetUpdate(BaseModel):
"""Schema for PUT /api/targets/{id}. All fields optional — only supplied
fields are updated. Setting end_date to null will reactivate a historical
target only if no other active target exists."""
start_date: date | None = None
end_date: date | None = None
calories: int | None = Field(default=None, gt=0)
protein_g: float | None = Field(default=None, ge=0)
carbs_g: float | None = Field(default=None, ge=0)
fat_g: float | None = Field(default=None, ge=0)
@model_validator(mode="after")
def _check_date_range(self):
if self.start_date is not None and self.end_date is not None:
if self.end_date <= self.start_date:
raise ValueError("end_date must be after start_date")
return self
class TargetRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
start_date: date
end_date: date | None
calories: int
protein_g: float | None
carbs_g: float | None
fat_g: float | None
# ── Day Summary (TICKET-004) ────────────────────────────────────────────────
class DaySummaryNutrition(BaseModel):
"""Summed nutrition totals for a single day.
All fields are floats (summed from scaled per-unit values).
Foods with null nutrition fields contribute 0.
"""
calories: float
protein_g: float
carbs_g: float
fat_g: float
fiber_g: float
saturated_fat_g: float
sugars_g: float
sodium_g: float
class DaySummaryResponse(BaseModel):
"""Response shape for GET /api/log/summary — the contract the frontend
ProgressBar consumes.
Remaining-vs-target arithmetic: LEFT TO THE FRONTEND.
The server returns raw totals and the applicable target (or null).
The frontend computes remaining = target.calories - totals.calories
and similar for macros where both target and total exist.
"""
date: date
totals: DaySummaryNutrition
target: TargetRead | None