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.orm import Session from app.database import get_db from app.models import Ingredient, Recipe from app.schemas.recipe import ( RecipeCreate, RecipeRead, RecipeUpdate, ResolveIngredientCandidate, ResolveIngredientRequest, ResolveIngredientResponse, ) from app.security import require_admin 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) -> dict: 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": row.ingredients or [], "instructions": list(row.instructions or []), "source_url": row.source_url, } @public_router.get("", response_model=List[RecipeRead]) def list_recipes( q: Optional[str] = Query(default=None), limit: int = Query(default=100, le=500), db: Session = Depends(get_db), ): query = db.query(Recipe) if q: like = f"%{q.lower()}%" query = query.filter(Recipe.name.ilike(like)) rows = query.order_by(Recipe.name).limit(limit).all() return [_serialize(r) 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).first() if row is None: raise HTTPException(status_code=404, detail="recipe not found") return _serialize(row) @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) @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) @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\d+(?:\.\d+)?(?:/\d+)?)\s*(?P[a-zA-Z]+)?\s+(?P.+)$" ) 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, )