Files
calcount/backend/schemas.py
T
Craig 5372e8cbca TICKET-004: Day summary endpoint
- 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)
2026-07-26 13:27:03 +01:00

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