"""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 LogEntryRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: int date: date food_id: int quantity: float meal_slot: MealSlot | None sort_order: int # ── 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