"""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