Files
Meal-Planner/backend/app/api/recipes.py
T
admin 2695ba0232
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s
feat(meals): enforce three lower-calorie dinners with media
2026-09-01 10:11:36 -07:00

367 lines
12 KiB
Python

from __future__ import annotations
import re
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from rapidfuzz import fuzz, process
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import (
FamilyProfile,
Ingredient,
NeverSuggest,
Recipe,
RecipeCatalogStatus,
)
from app.schemas.recipe import (
RecipeCreate,
RecipeRead,
RecipeUpdate,
ResolveIngredientCandidate,
ResolveIngredientRequest,
ResolveIngredientResponse,
)
from app.security import require_admin
from app.services.feedback_analyzer import FeedbackAnalyzer
public_router = APIRouter(prefix="/api/recipes", tags=["recipes"])
admin_router = APIRouter(
prefix="/api/admin/recipes",
tags=["recipes-admin"],
dependencies=[Depends(require_admin)],
)
def _validate_ingredient_ids(db: Session, ingredient_refs) -> None:
ids = [ref.ingredient_id for ref in ingredient_refs]
found = (
db.query(Ingredient.id).filter(Ingredient.id.in_(ids)).all()
)
found_ids = {row[0] for row in found}
missing = [str(i) for i in ids if i not in found_ids]
if missing:
raise HTTPException(
status_code=422,
detail={"error": "unknown ingredient_ids", "missing": missing},
)
def _serialize(row: Recipe, db: Session) -> dict:
raw_ingredients = row.ingredients or []
# Resolve ingredient names from the canonical Ingredient table
ingredient_ids = [ref["ingredient_id"] for ref in raw_ingredients if ref.get("ingredient_id")]
name_map = {}
if ingredient_ids:
name_rows = db.query(Ingredient.id, Ingredient.name).filter(Ingredient.id.in_(ingredient_ids)).all()
name_map = {str(r[0]): r[1] for r in name_rows}
enriched_ingredients = []
for ref in raw_ingredients:
enriched = dict(ref)
enriched["name"] = name_map.get(str(ref.get("ingredient_id")), "unknown ingredient")
enriched_ingredients.append(enriched)
return {
"id": row.id,
"name": row.name,
"description": row.description,
"image_url": row.image_url,
"prep_time_minutes": row.prep_time_minutes or 0,
"cook_time_minutes": row.cook_time_minutes or 0,
"servings": row.servings,
"cuisine_tags": list(row.cuisine_tags or []),
"dietary_tags": list(row.dietary_tags or []),
"protein_type": row.protein_type,
"spice_level": row.spice_level,
"calories_per_serving": row.calories_per_serving,
"ingredients": enriched_ingredients,
"side_dishes": row.side_dishes or [],
"instructions": list(row.instructions or []),
"source_url": row.source_url,
"external_source": row.external_source,
"external_id": row.external_id,
"discovery_reason": row.discovery_reason,
}
@public_router.get("/recommended", response_model=List[RecipeRead])
def list_recommended_recipes(
family_profile_id: UUID = Query(...),
limit: int = Query(default=10, le=50),
db: Session = Depends(get_db),
):
from datetime import date
analyzer = FeedbackAnalyzer(lookback_weeks=4)
result = analyzer.analyze(db, family_profile_id, today=date.today())
# Collect IDs to exclude: never-suggest recipes
blocked = {
r[0]
for r in db.query(NeverSuggest.recipe_id)
.filter(
NeverSuggest.family_profile_id == family_profile_id,
NeverSuggest.recipe_id.isnot(None),
)
.all()
}
# Start from all recipes
query = db.query(Recipe).filter(Recipe.catalog_status == RecipeCatalogStatus.ACTIVE)
if blocked:
query = query.filter(~Recipe.id.in_(blocked))
# Apply positive signal filters
pos_cuisines = {s.value for s in result.positive_signals if s.type == "prefer_cuisine"}
pos_proteins = {s.value for s in result.positive_signals if s.type == "prefer_protein"}
if pos_cuisines:
query = query.filter(
or_(*[
Recipe.cuisine_tags.overlap([c.lower()])
for c in pos_cuisines
])
)
if pos_proteins:
query = query.filter(
or_(*[
Recipe.protein_type.ilike(p)
for p in pos_proteins
])
)
# Always include top-rated recipes even if they don't match signals
rows = query.limit(limit * 2).all()
ids_seen = set()
ordered = []
# Push top-rated first
top_rated = result.top_rated_recipe_names or []
if top_rated:
top_rows = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
Recipe.name.in_(top_rated),
(~Recipe.id.in_(blocked) if blocked else True),
).limit(limit).all()
for r in top_rows:
if r.id not in ids_seen:
ids_seen.add(r.id)
ordered.append(r)
# Fill with signal-matched recipes
for r in rows:
if r.id not in ids_seen and len(ordered) < limit:
ids_seen.add(r.id)
ordered.append(r)
return [_serialize(r, db) for r in ordered]
@public_router.get("", response_model=List[RecipeRead])
def list_recipes(
q: Optional[str] = Query(default=None),
cuisine: Optional[str] = Query(default=None),
protein: Optional[str] = Query(default=None),
dietary: Optional[str] = Query(default=None),
ingredient: Optional[str] = Query(default=None),
family_profile_id: Optional[UUID] = Query(default=None),
max_time: Optional[int] = Query(default=None, ge=0),
spice_max: Optional[int] = Query(default=None, ge=0, le=5),
calorie_max: Optional[int] = Query(default=None, ge=0),
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
):
query = db.query(Recipe).filter(
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
)
if q:
like = f"%{q.lower()}%"
query = query.filter(Recipe.name.ilike(like))
if cuisine:
query = query.filter(Recipe.cuisine_tags.overlap([cuisine.lower()]))
if protein:
query = query.filter(Recipe.protein_type.ilike(protein))
if dietary:
query = query.filter(Recipe.dietary_tags.overlap([dietary.lower()]))
if ingredient:
ing_ids = [
r[0] for r in db.query(Ingredient.id).filter(Ingredient.name.ilike(f"%{ingredient.lower()}%")).all()
]
if ing_ids:
query = query.filter(
or_(
*[
Recipe.ingredients.contains([{"ingredient_id": str(iid)}])
for iid in ing_ids
]
)
)
if max_time is not None:
query = query.filter(
(Recipe.prep_time_minutes + Recipe.cook_time_minutes) <= max_time
)
if spice_max is not None:
query = query.filter(Recipe.spice_level <= spice_max)
if calorie_max is not None:
query = query.filter(Recipe.calories_per_serving <= calorie_max)
if family_profile_id:
blocked = {
r[0]
for r in db.query(NeverSuggest.recipe_id)
.filter(
NeverSuggest.family_profile_id == family_profile_id,
NeverSuggest.recipe_id.isnot(None),
)
.all()
}
if blocked:
query = query.filter(~Recipe.id.in_(blocked))
rows = query.order_by(Recipe.name).limit(limit).all()
return [_serialize(r, db) for r in rows]
@public_router.get("/{recipe_id}", response_model=RecipeRead)
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
row = db.query(Recipe).filter(
Recipe.id == recipe_id,
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
return _serialize(row, db)
@admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)):
_validate_ingredient_ids(db, payload.ingredients)
row = Recipe(
name=payload.name,
description=payload.description,
image_url=payload.image_url,
prep_time_minutes=payload.prep_time_minutes,
cook_time_minutes=payload.cook_time_minutes,
servings=payload.servings,
cuisine_tags=payload.cuisine_tags,
dietary_tags=payload.dietary_tags,
protein_type=payload.protein_type,
spice_level=payload.spice_level,
calories_per_serving=payload.calories_per_serving,
ingredients=[ref.model_dump(mode="json") for ref in payload.ingredients],
instructions=payload.instructions,
source_url=payload.source_url,
is_manually_added=True,
)
db.add(row)
db.commit()
db.refresh(row)
return _serialize(row, db)
@admin_router.patch("/{recipe_id}", response_model=RecipeRead)
def update_recipe(recipe_id: UUID, payload: RecipeUpdate, db: Session = Depends(get_db)):
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
data = payload.model_dump(exclude_unset=True)
if "ingredients" in data and data["ingredients"] is not None:
_validate_ingredient_ids(db, payload.ingredients)
row.ingredients = [ref.model_dump(mode="json") for ref in payload.ingredients]
data.pop("ingredients")
for field, value in data.items():
setattr(row, field, value)
db.commit()
db.refresh(row)
return _serialize(row, db)
@admin_router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if row is None:
raise HTTPException(status_code=404, detail="recipe not found")
db.delete(row)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
_KNOWN_UNITS = {
"tsp", "tbsp", "cup", "cups", "oz", "ounce", "ounces",
"lb", "lbs", "pound", "pounds", "g", "kg", "ml", "l",
"clove", "cloves", "pinch", "dash", "ea", "each",
}
_QTY_UNIT_RE = re.compile(
r"^\s*(?P<qty>\d+(?:\.\d+)?(?:/\d+)?)\s*(?P<unit>[a-zA-Z]+)?\s+(?P<rest>.+)$"
)
def _parse_qty_unit(text: str) -> tuple[Optional[float], Optional[str], str]:
m = _QTY_UNIT_RE.match(text)
if not m:
return None, None, text.strip()
qty_raw = m.group("qty")
if "/" in qty_raw:
num, denom = qty_raw.split("/")
qty = float(num) / float(denom)
else:
qty = float(qty_raw)
unit = m.group("unit")
rest = m.group("rest").strip()
if unit and unit.lower() not in _KNOWN_UNITS:
rest = f"{unit} {rest}"
unit = None
return qty, unit.lower() if unit else None, rest
@admin_router.post("/resolve-ingredient", response_model=ResolveIngredientResponse)
def resolve_ingredient(
payload: ResolveIngredientRequest,
db: Session = Depends(get_db),
) -> ResolveIngredientResponse:
qty, unit, rest = _parse_qty_unit(payload.text)
rows = db.query(Ingredient).all()
if not rows:
return ResolveIngredientResponse(
parsed_qty=qty,
parsed_unit=unit,
parsed_text=rest,
candidates=[],
)
pool: list[tuple[str, UUID, Optional[str]]] = []
for row in rows:
pool.append((row.name, row.id, row.aisle))
for alias in row.aliases or []:
pool.append((alias, row.id, row.aisle))
scored = process.extract(
rest,
[name for name, _, _ in pool],
scorer=fuzz.WRatio,
limit=10,
)
seen: set[UUID] = set()
candidates: list[ResolveIngredientCandidate] = []
for matched_name, score, idx in scored:
_, ing_id, aisle = pool[idx]
if ing_id in seen:
continue
seen.add(ing_id)
candidates.append(
ResolveIngredientCandidate(
ingredient_id=ing_id,
name=matched_name,
score=score / 100.0,
aisle=aisle,
)
)
if len(candidates) >= 3:
break
return ResolveIngredientResponse(
parsed_qty=qty,
parsed_unit=unit,
parsed_text=rest,
candidates=candidates,
)