Public Access
feat: POST /api/admin/meal-plans/generate + regenerate + get endpoints
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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),
|
||||
)
|
||||
@@ -33,6 +33,7 @@ from app.api import profile, meals, shopping_list, pantry, admin, auth
|
||||
from app.api import ingredients as ingredients_api
|
||||
from app.api import recipes as recipes_api
|
||||
from app.api import never_suggest as never_suggest_api
|
||||
from app.api import meal_plans as meal_plans_api
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
@@ -47,3 +48,5 @@ app.include_router(recipes_api.public_router)
|
||||
app.include_router(recipes_api.admin_router)
|
||||
app.include_router(never_suggest_api.public_router)
|
||||
app.include_router(never_suggest_api.admin_router)
|
||||
app.include_router(meal_plans_api.admin_router)
|
||||
app.include_router(meal_plans_api.public_router)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
family_profile_id: UUID
|
||||
week_start_date: date
|
||||
|
||||
|
||||
class RegenerateRequest(BaseModel):
|
||||
family_profile_id: UUID
|
||||
week_start_date: date
|
||||
exclude_recipe_ids: List[UUID] = Field(default_factory=list)
|
||||
relax_time_max_minutes: Optional[int] = Field(default=None, ge=0)
|
||||
relax_calorie_pct: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
relax_max_meal_cost: Optional[float] = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class GenerationItem(BaseModel):
|
||||
recipe_id: UUID
|
||||
day_of_week: int
|
||||
estimated_cost: Decimal
|
||||
score: float
|
||||
components: Dict[str, float]
|
||||
|
||||
|
||||
class GenerationDebug(BaseModel):
|
||||
feasible_count: int
|
||||
rejected_summary: Dict[str, int]
|
||||
set_score: float
|
||||
|
||||
|
||||
class GenerationResponse(BaseModel):
|
||||
meal_plan_id: UUID
|
||||
week_start_date: date
|
||||
items: List[GenerationItem]
|
||||
debug: GenerationDebug
|
||||
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.requires_postgres
|
||||
|
||||
|
||||
def _admin() -> dict:
|
||||
return {"Authorization": "Bearer test-admin-token"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def family_with_groceries(db_session):
|
||||
from app.models import FamilyProfile, GroceryItem, Ingredient
|
||||
|
||||
family = FamilyProfile(
|
||||
id=uuid4(),
|
||||
name="API Smoke Family",
|
||||
household_size=4,
|
||||
adult_count=2,
|
||||
child_count=2,
|
||||
calorie_target=500, # per-serving target; recipes are 360-640 range
|
||||
)
|
||||
db_session.add(family)
|
||||
db_session.commit()
|
||||
|
||||
for ing in db_session.query(Ingredient).limit(20).all():
|
||||
db_session.add(
|
||||
GroceryItem(
|
||||
id=uuid4(),
|
||||
name=ing.name,
|
||||
source="lucky_california",
|
||||
external_id=f"ext-api-{ing.id}",
|
||||
current_price=Decimal("3.99"),
|
||||
regular_price=Decimal("4.99"),
|
||||
is_on_sale=True,
|
||||
scraped_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
from app.services.matcher import run_match_job
|
||||
|
||||
run_match_job(db_session)
|
||||
return family
|
||||
|
||||
|
||||
def test_generate_endpoint_returns_meal_plan(client, family_with_groceries):
|
||||
body = {
|
||||
"family_profile_id": str(family_with_groceries.id),
|
||||
"week_start_date": "2026-05-11",
|
||||
}
|
||||
r = client.post("/api/admin/meal-plans/generate", json=body, headers=_admin())
|
||||
assert r.status_code == 201, r.text
|
||||
data = r.json()
|
||||
assert "meal_plan_id" in data
|
||||
assert 1 <= len(data["items"]) <= 3
|
||||
assert "debug" in data
|
||||
assert data["debug"]["feasible_count"] >= 1
|
||||
|
||||
|
||||
def test_generate_endpoint_requires_admin_token(client, family_with_groceries):
|
||||
body = {
|
||||
"family_profile_id": str(family_with_groceries.id),
|
||||
"week_start_date": "2026-05-18",
|
||||
}
|
||||
r = client.post("/api/admin/meal-plans/generate", json=body)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_generate_endpoint_returns_404_for_unknown_family(client):
|
||||
body = {
|
||||
"family_profile_id": str(uuid4()),
|
||||
"week_start_date": "2026-05-11",
|
||||
}
|
||||
r = client.post("/api/admin/meal-plans/generate", json=body, headers=_admin())
|
||||
assert r.status_code == 404
|
||||
Reference in New Issue
Block a user