diff --git a/backend/app/api/meal_plans.py b/backend/app/api/meal_plans.py new file mode 100644 index 0000000..ad6d735 --- /dev/null +++ b/backend/app/api/meal_plans.py @@ -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), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 94b51c5..4d5b23b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/meal_plan_generation.py b/backend/app/schemas/meal_plan_generation.py new file mode 100644 index 0000000..94c21da --- /dev/null +++ b/backend/app/schemas/meal_plan_generation.py @@ -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 diff --git a/backend/tests/test_meal_plan_generate_api.py b/backend/tests/test_meal_plan_generate_api.py new file mode 100644 index 0000000..80738a2 --- /dev/null +++ b/backend/tests/test_meal_plan_generate_api.py @@ -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