Public Access
147 lines
5.4 KiB
Python
147 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from typing import List
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models import FamilyProfile, MealPlan, MealPlanItem
|
|
from app.schemas.meal_plan_generation import (
|
|
GenerateRequest,
|
|
GenerationDebug,
|
|
GenerationItem,
|
|
GenerationResponse,
|
|
RegenerateRequest,
|
|
)
|
|
from app.security import require_admin
|
|
from app.services.planner.config import DEFAULT
|
|
from app.services.planner.generate import generate_meal_plan
|
|
from app.services.planner.types import GenerationResult
|
|
|
|
|
|
admin_router = APIRouter(
|
|
prefix="/api/admin/meal-plans",
|
|
tags=["meal-plans-admin"],
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
|
|
|
|
|
def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse:
|
|
item_payloads: List[GenerationItem] = []
|
|
score_by_recipe = {s.recipe_id: s for s in result.selected}
|
|
for it in items:
|
|
scored = score_by_recipe.get(it.recipe_id)
|
|
item_payloads.append(
|
|
GenerationItem(
|
|
recipe_id=it.recipe_id,
|
|
day_of_week=it.day_of_week,
|
|
estimated_cost=it.estimated_cost or 0,
|
|
score=scored.score if scored else 0.0,
|
|
components={k: float(v) for k, v in (scored.components.items() if scored else [])},
|
|
)
|
|
)
|
|
return GenerationResponse(
|
|
meal_plan_id=plan_id,
|
|
week_start_date=week_start,
|
|
items=item_payloads,
|
|
debug=GenerationDebug(
|
|
feasible_count=result.feasible_count,
|
|
rejected_summary=result.rejected_summary,
|
|
set_score=result.set_score,
|
|
),
|
|
)
|
|
|
|
|
|
@admin_router.post("/generate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
|
|
def generate(payload: GenerateRequest, db: Session = Depends(get_db)) -> GenerationResponse:
|
|
family = db.query(FamilyProfile).filter(FamilyProfile.id == payload.family_profile_id).first()
|
|
if family is None:
|
|
raise HTTPException(status_code=404, detail="family_profile not found")
|
|
try:
|
|
result = generate_meal_plan(
|
|
db,
|
|
family_id=payload.family_profile_id,
|
|
week_start_date=payload.week_start_date,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
items = (
|
|
db.query(MealPlanItem)
|
|
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
|
|
.order_by(MealPlanItem.day_of_week)
|
|
.all()
|
|
)
|
|
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
|
|
|
|
|
|
@admin_router.post("/regenerate", response_model=GenerationResponse, status_code=status.HTTP_201_CREATED)
|
|
def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> GenerationResponse:
|
|
family = db.query(FamilyProfile).filter(FamilyProfile.id == payload.family_profile_id).first()
|
|
if family is None:
|
|
raise HTTPException(status_code=404, detail="family_profile not found")
|
|
|
|
config = DEFAULT
|
|
if payload.relax_time_max_minutes is not None:
|
|
config = replace(config, max_total_minutes=payload.relax_time_max_minutes)
|
|
if payload.relax_calorie_pct is not None:
|
|
config = replace(config, calorie_tolerance_pct=payload.relax_calorie_pct)
|
|
if payload.relax_max_meal_cost is not None:
|
|
config = replace(config, max_meal_cost=payload.relax_max_meal_cost)
|
|
|
|
# exclude_recipe_ids accepted for forward-compat but not yet honored.
|
|
# See plan §Open items.
|
|
|
|
db.query(MealPlan).filter(
|
|
MealPlan.family_profile_id == payload.family_profile_id,
|
|
MealPlan.week_start_date == payload.week_start_date,
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
result = generate_meal_plan(
|
|
db,
|
|
family_id=payload.family_profile_id,
|
|
week_start_date=payload.week_start_date,
|
|
config=config,
|
|
)
|
|
items = (
|
|
db.query(MealPlanItem)
|
|
.filter(MealPlanItem.meal_plan_id == result.meal_plan_id)
|
|
.order_by(MealPlanItem.day_of_week)
|
|
.all()
|
|
)
|
|
return _to_response(payload.week_start_date, result.meal_plan_id, items, result)
|
|
|
|
|
|
@public_router.get("/{plan_id}", response_model=GenerationResponse)
|
|
def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse:
|
|
plan = db.query(MealPlan).filter(MealPlan.id == plan_id).first()
|
|
if plan is None:
|
|
raise HTTPException(status_code=404, detail="meal_plan not found")
|
|
items = (
|
|
db.query(MealPlanItem)
|
|
.filter(MealPlanItem.meal_plan_id == plan.id)
|
|
.order_by(MealPlanItem.day_of_week)
|
|
.all()
|
|
)
|
|
# Read-after-create: scores not stored on MealPlanItem, so we return zeros
|
|
# for score/components. The original generate response is authoritative.
|
|
return GenerationResponse(
|
|
meal_plan_id=plan.id,
|
|
week_start_date=plan.week_start_date,
|
|
items=[
|
|
GenerationItem(
|
|
recipe_id=it.recipe_id,
|
|
day_of_week=it.day_of_week,
|
|
estimated_cost=it.estimated_cost or 0,
|
|
score=0.0,
|
|
components={},
|
|
)
|
|
for it in items
|
|
],
|
|
debug=GenerationDebug(feasible_count=0, rejected_summary={}, set_score=0.0),
|
|
)
|