TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection
This commit is contained in:
+88
-19
@@ -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
|
||||
Reference in New Issue
Block a user