TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection

This commit is contained in:
Craig
2026-07-26 17:27:15 +01:00
parent 4dd44b08d0
commit a8aed9a84f
9 changed files with 1720 additions and 73 deletions
+88 -19
View File
@@ -4,6 +4,8 @@ Defined separately from ORM models; services convert via from_attributes.
Validation happens here at the boundary (spec §8.1 rule 11).
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
@@ -93,9 +95,55 @@ class FoodRead(BaseModel):
updated_at: datetime
# ── Meal components & MealRead (§3.2, TICKET-007) ────────────────────────────
class MealComponentRead(BaseModel):
"""A single component within a meal, with the component food embedded
so the frontend can render names/nutrition without N+1 lookups."""
model_config = ConfigDict(from_attributes=True)
food_id: int
quantity: float
food: "LogFoodRead"
class MealRead(FoodRead):
"""A meal food with its components and computed per-1.0-meal nutrition.
Returned by GET /api/foods/{id} when the food is a meal. For non-meal
foods the plain FoodRead is returned instead.
"""
components: list[MealComponentRead] = []
computed_nutrition_per_meal: dict[str, float] = Field(default_factory=dict)
# ── Log ──────────────────────────────────────────────────────────────────────
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1).
When is_meal is True, components is populated with the meal's components
(with their own food embedded) so the frontend can render collapsible rows.
"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
components: list[MealComponentRead] | None = None
class LogEntryCreate(BaseModel):
food_id: int
quantity: float = Field(gt=0)
@@ -111,24 +159,6 @@ class LogEntryUpdate(BaseModel):
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, calories_per_unit, 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
calories_per_unit: float | None
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)
@@ -139,6 +169,45 @@ class LogEntryRead(BaseModel):
meal_slot: MealSlot | None
sort_order: int
food: LogFoodRead
computed_nutrition: dict[str, float] | None = None
# ── Meal endpoints (TICKET-007) ──────────────────────────────────────────────
class MealFromLogRequest(BaseModel):
"""Body for POST /api/meals/from-log."""
name: str
date: date
entry_ids: list[int] = Field(min_length=1)
class MealFromLogResponse(BaseModel):
"""Response for POST /api/meals/from-log."""
meal: MealRead
entry: LogEntryRead
class MealUnpackRequest(BaseModel):
"""Body for POST /api/meals/{meal_id}/unpack."""
date: date
entry_id: int | None = None
class MealUnpackResponse(BaseModel):
"""Response for POST /api/meals/{meal_id}/unpack."""
entries: list[LogEntryRead]
class MealComponentInput(BaseModel):
"""A single component in a PUT /api/meals/{meal_id}/components request."""
food_id: int
quantity: float = Field(gt=0)
class MealComponentsUpdateRequest(BaseModel):
"""Body for PUT /api/meals/{meal_id}/components — full replacement."""
components: list[MealComponentInput]
# ── Targets ──────────────────────────────────────────────────────────────────
@@ -218,4 +287,4 @@ class DaySummaryResponse(BaseModel):
date: date
totals: DaySummaryNutrition
target: TargetRead | None
target: TargetRead | None