"""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 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 # ── Log ────────────────────────────────────────────────────────────────────── 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 LogFoodRead(BaseModel): """Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1). Includes name, brand, unit_type, and serving info so the frontend can render log entries without N+1 lookups. Soft-deleted foods render here (§2.1).""" model_config = ConfigDict(from_attributes=True) id: int name: str brand: str | None unit_type: UnitType serving_size_g: float | None serving_name: str | None is_meal: bool deleted_at: datetime | 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 # ── 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