Files
calcount/backend/schemas.py
T
Craig 601c0d461e TICKET-002: Targets CRUD with single-active-target invariant
- POST auto-closes previous target in one transaction
- GET /targets (ordered), GET /targets/current (404 when none)
- PUT with invariant enforcement (409 on double-active attempt)
- Service-layer historical target lookup for TICKET-004
- Full suite green (62 passed)
2026-07-26 13:08:23 +01:00

160 lines
5.6 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 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