5372e8cbca
- GET /api/log/summary?date= with totals vs historical target - Nutrition math consolidated in services/nutrition.py (weight vs count) - Null nutrition contributes 0; meals contribute 0 (TODO TICKET-007) - Full suite green (104 passed)
220 lines
7.4 KiB
Python
220 lines
7.4 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 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
|