Files
Meal-Planner/backend/app/schemas/__init__.py
T
admin 7f5757094e
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
feat(meals): suggest complementary sides
2026-06-29 14:59:30 -07:00

432 lines
11 KiB
Python

from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
# Re-export from submodules so forward references resolve
from .recipe import (
RecipeBase,
RecipeCreate,
RecipeIngredientRef,
RecipeRead,
RecipeUpdate,
ResolveIngredientCandidate,
ResolveIngredientRequest,
ResolveIngredientResponse,
)
# These may be referenced by other models; re-export as aliases if needed.
RecipeResponse = RecipeRead
class FamilyMemberRole(str, Enum):
adult = "adult"
child = "child"
class MealType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
class MealPlanStatus(str, Enum):
draft = "draft"
pending_approval = "pending_approval"
approved = "approved"
locked = "locked"
class MealPlanItemStatus(str, Enum):
pending = "pending"
approved = "approved"
denied = "denied"
swapped = "swapped"
class DenialReason(str, Enum):
too_expensive = "too_expensive"
boring = "boring"
disliked_ingredient = "disliked_ingredient"
cultural = "cultural"
other = "other"
class NeverSuggestReason(str, Enum):
allergy = "allergy"
dislike = "dislike"
tried_too_much = "tried_too_much"
other = "other"
class IngredientBase(BaseModel):
name: str
name_lower: str
plural_name: Optional[str] = None
aisle: Optional[str] = None
typical_price: Optional[float] = None
unit: Optional[str] = None
season_months: Optional[List[int]] = None
class IngredientResponse(IngredientBase):
id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class IngredientCreate(IngredientBase):
pass
class FamilyMemberBase(BaseModel):
name: str
email: Optional[str] = None
role: FamilyMemberRole
likes_mushrooms: bool = False
class FamilyMemberResponse(FamilyMemberBase):
id: UUID
family_profile_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class FamilyMemberCreate(FamilyMemberBase):
pass
class FamilyProfileBase(BaseModel):
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
class FamilyProfileResponse(BaseModel):
id: UUID
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
members: List[FamilyMemberResponse] = []
planner_config: Optional[dict] = None
class Config:
from_attributes = True
class PlannerConfigOverride(BaseModel):
recency_weeks: Optional[int] = Field(default=None, ge=0)
calorie_tolerance_pct: Optional[int] = Field(default=None, ge=0, le=100)
max_total_minutes: Optional[int] = Field(default=None, ge=0)
max_meal_cost: Optional[float] = Field(default=None, ge=0)
w_savings: Optional[float] = Field(default=None, ge=0, le=1)
w_coverage: Optional[float] = Field(default=None, ge=0, le=1)
w_pantry: Optional[float] = Field(default=None, ge=0, le=1)
w_time: Optional[float] = Field(default=None, ge=0, le=1)
w_recency: Optional[float] = Field(default=None, ge=0, le=1)
time_ideal_minutes: Optional[int] = Field(default=None, ge=0)
time_full_minutes: Optional[int] = Field(default=None, ge=0)
recency_full_weeks: Optional[int] = Field(default=None, ge=0)
top_k: Optional[int] = Field(default=None, ge=1)
set_size: Optional[int] = Field(default=None, ge=1)
p_protein: Optional[float] = Field(default=None, ge=0)
p_cuisine: Optional[float] = Field(default=None, ge=0)
class PlannerConfigResponse(BaseModel):
recency_weeks: int
calorie_tolerance_pct: int
max_total_minutes: int
max_meal_cost: float
w_savings: float
w_coverage: float
w_pantry: float
w_time: float
w_recency: float
time_ideal_minutes: int
time_full_minutes: int
recency_full_weeks: int
top_k: int
set_size: int
p_protein: float
p_cuisine: float
source: str = "default"
class PlannerConfigUpdateRequest(BaseModel):
planner_config: PlannerConfigOverride
class FamilyProfileCreate(FamilyProfileBase):
pass
class FamilyProfileUpdate(BaseModel):
name: Optional[str] = None
household_size: Optional[int] = None
adult_count: Optional[int] = None
child_count: Optional[int] = None
dietary_notes: Optional[str] = None
budget_per_meal: Optional[float] = None
planner_config: Optional[dict] = None
class RecipeCreate(RecipeBase):
pass
class MealPlanItemBase(BaseModel):
recipe_id: UUID
day_of_week: int = Field(..., ge=1, le=7)
meal_type: MealType
estimated_cost: Optional[float] = None
class MealPlanItemResponse(MealPlanItemBase):
id: UUID
meal_plan_id: UUID
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: when this denial decays. NULL = no decay (approve / never_again).
denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None
components: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
recipe: Optional[RecipeResponse] = None
class Config:
from_attributes = True
class MealPlanItemCreate(MealPlanItemBase):
pass
class MealPlanBase(BaseModel):
week_start_date: date
status: MealPlanStatus = MealPlanStatus.draft
approval_deadline: Optional[datetime] = None
notes: Optional[str] = None
class MealPlanResponse(MealPlanBase):
id: UUID
family_profile_id: UUID
total_estimated_cost: Optional[float] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
items: List[MealPlanItemResponse] = []
class Config:
from_attributes = True
class MealPlanCreate(MealPlanBase):
items: List[MealPlanItemCreate] = []
class VoteRequest(BaseModel):
vote: bool
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: "this_week" (default) or "never_again". Only honored when
# vote=False; ignored for approve votes.
denial_scope: Optional[str] = Field(None, pattern="^(this_week|never_again)$")
class VoteResponse(BaseModel):
id: UUID
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
# Sprint 8: which deny-scope the voter chose. NULL on approve votes.
denial_scope: Optional[str] = None
voted_at: Optional[datetime] = None
class Config:
from_attributes = True
class HomePantryBase(BaseModel):
ingredient_id: UUID
quantity: Optional[float] = None
unit: Optional[str] = None
expires_at: Optional[date] = None
class HomePantryResponse(HomePantryBase):
id: UUID
family_profile_id: UUID
added_at: Optional[datetime] = None
created_at: Optional[datetime] = None
ingredient: Optional[IngredientResponse] = None
class Config:
from_attributes = True
class HomePantryCreate(HomePantryBase):
pass
class HomePantryBulkCreate(BaseModel):
"""Request body for POST /api/pantry/bulk. Accepts a list of items to
add in one call; each item follows the same upsert semantics as
HomePantryCreate (insert or overwrite qty/unit/expires)."""
items: List[HomePantryCreate]
class HomePantryBulkResultItem(BaseModel):
ingredient_id: UUID
status: str # "added" | "updated" | "skipped"
id: Optional[UUID] = None
reason: Optional[str] = None
class HomePantryBulkResult(BaseModel):
"""Response body for POST /api/pantry/bulk. Reports per-item
outcomes so the UI can show a precise toast ("Added 8 items, 1
skipped — no ingredient link"). Total counts are derived for
convenience."""
added: int
updated: int
skipped: int
results: List[HomePantryBulkResultItem]
class FillEmptySlotsRequest(BaseModel):
"""Request body for POST /api/meals/{id}/fill-empty-slots. The
caller selects which meal types to fill (dinner only, or all
three). Days 1-7 are filled automatically; the backend iterates
in day order then meal_type order."""
meal_types: List[str] = Field(
default_factory=lambda: ["breakfast", "lunch", "dinner"],
description="Subset of {breakfast, lunch, dinner} to fill.",
)
class FilledSlot(BaseModel):
day_of_week: int
meal_type: str
item: MealPlanItemResponse
class FailedSlot(BaseModel):
day_of_week: int
meal_type: str
reason: str
class FillEmptySlotsResult(BaseModel):
"""Response body for POST /api/meals/{id}/fill-empty-slots.
Reports each slot as either 'filled' (with the new MealPlanItem)
or 'failed' (with a human-readable reason). Partial success is
the model: the caller decides whether to retry the failed
slots."""
filled: List[FilledSlot]
failed: List[FailedSlot]
class FeedbackBase(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
never_suggest: bool = False
denial_reason: Optional[DenialReason] = None
feedback_text: Optional[str] = None
class FeedbackResponse(FeedbackBase):
id: UUID
family_profile_id: UUID
family_member_id: Optional[UUID] = None
meal_plan_item_id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class FeedbackCreate(FeedbackBase):
meal_plan_item_id: UUID
class ShoppingListItem(BaseModel):
ingredient_id: Optional[UUID] = None
name: str
quantity: Optional[float] = None
unit: Optional[str] = None
aisle: Optional[str] = None
estimated_price: Optional[float] = None
is_on_sale: bool = False
sale_price: Optional[float] = None
in_season: bool = False
in_pantry: bool = False
class ShoppingListResponse(BaseModel):
week_start_date: date
items: List[ShoppingListItem]
total_estimated_cost: float
sale_items_count: int
by_aisle: dict[str, List[ShoppingListItem]]
# Sprint 12: Spoonacular external recipe search. The hit shape is
# what the webui shows in the "Search the web" panel; the import
# request is what the import button POSTs.
class RecipeSearchHit(BaseModel):
external_id: str
external_source: str = "spoonacular"
name: str
image_url: Optional[str] = None
source_url: Optional[str] = None
prep_time_minutes: Optional[int] = None
cook_time_minutes: Optional[int] = None
servings: Optional[int] = None
cuisine_tags: List[str] = Field(default_factory=list)
dietary_tags: List[str] = Field(default_factory=list)
protein_type: Optional[str] = None
calories_per_serving: Optional[int] = None
class RecipeImportRequest(BaseModel):
external_id: str
external_source: str = "spoonacular"
# Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud.
# The prompt is 1-500 chars; the response mirrors the Sprint 11
# create-then-fill shape (plan_id + per-step counts).
class LLMPlanRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
week_start: date
class LLMPlanResponse(BaseModel):
plan_id: str
picked_count: int
filled_count: int
failed_count: int
reasoning: Optional[str] = None