from __future__ import annotations from typing import Dict, List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Response import ingredients as ingredients_mod import recipes from api.deps import error_response, get_db, get_household_from_slug, get_current_user from common import Page, ProblemDetails, ApiModel, Field from api.dtos import MemberRef router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"]) # Public, stateless recipes utilities public = APIRouter(prefix="/recipes", tags=["recipes"]) class RecipeOut(ApiModel): id: int = -1 name: str link: str serves: int image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0}) ingredients: List[ingredients_mod.Ingredient] = Field( min_length=0, json_schema_extra={"minItems": 0} ) created_by_id: int created_by: Optional[MemberRef] = None hidden_by_id: Optional[int] = None hidden_by: Optional[MemberRef] = None class RecipeCreate(ApiModel): name: str link: str serves: int image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0}) ingredients: List[ingredients_mod.Ingredient] = Field( min_length=0, json_schema_extra={"minItems": 0} ) class ParseUrlIn(ApiModel): url: str @router.get("", response_model=Page[RecipeOut]) async def list_recipes( household=Depends(get_household_from_slug), q: Optional[str] = Query(default=None), cursor: Optional[str] = Query(default=None), limit: int = Query(50, ge=1, le=200), conn: aiosqlite.Connection = Depends(get_db), ): last_id = None if cursor: try: last_id = int(cursor) except ValueError: last_id = None fetch_limit = limit + 1 hid = household["id"] paged: List[recipes.Recipe] = [] if q: async for r in recipes.find_recipes_by_name_paged_scoped( conn, q, last_id, fetch_limit, hid ): paged.append(r) else: async for r in recipes.get_all_paged_scoped(conn, last_id, fetch_limit, hid): paged.append(r) # Filter by household_id once repositories are fully updated; currently placeholder until repo changes land. has_more = len(paged) > limit items = paged[:limit] if items: recipe_ids = [r.id for r in items] by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids) for r in items: r.ingredients = by_recipe.get(r.id, []) # Lookup creators' display names creator_ids = {r.created_by_id for r in items if getattr(r, "created_by_id", None) is not None} creator_lookup: Dict[int, str] = {} if creator_ids: from users.repository import get_by_ids as get_users_by_ids users = await get_users_by_ids(conn, list(creator_ids)) creator_lookup = {uid: u.display_name for uid, u in users.items()} def to_recipe_out(r: recipes.Recipe) -> RecipeOut: mref = None name = creator_lookup.get(r.created_by_id) if name is not None: mref = MemberRef(id=r.created_by_id, display_name=name) return RecipeOut( id=r.id, name=r.name, link=r.link, serves=r.serves, image_urls=r.image_urls, ingredients=r.ingredients, created_by_id=r.created_by_id, created_by=mref, ) next_cursor = str(items[-1].id) if has_more and items else None total = await ( recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid) ) outward_items = [to_recipe_out(r) for r in items] return Page(items=outward_items, nextCursor=next_cursor, prevCursor=None, total=total) @router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) async def get_recipe( recipe_id: int, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), ): r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) if not r: return error_response(None, 404, "Recipe not found") # Ensure ingredients are loaded for single-recipe fetch await recipes.load_recipe_ingredients(conn, r) # load creator display name mref = None from users.repository import get_by_id as get_user_by_id u = await get_user_by_id(conn, r.created_by_id) if u: mref = MemberRef(id=r.created_by_id, display_name=u.display_name) return RecipeOut( id=r.id, name=r.name, link=r.link, serves=r.serves, image_urls=r.image_urls, ingredients=r.ingredients, created_by_id=r.created_by_id, created_by=mref, ) @router.delete("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) async def delete_recipe( recipe_id: int, household=Depends(get_household_from_slug), user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): # Load recipe in-scope r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) if not r: return error_response(None, 404, "Recipe not found") # Hide within household via repository and set hidden_by_id using a single UPDATE from recipes.repository import hide_recipe_scoped_with_actor ok = await hide_recipe_scoped_with_actor(conn, recipe_id, household["id"], user.id) if not ok: return error_response(None, 404, "Recipe not found") # Best-effort creator lookup mref = None from users.repository import get_by_id as get_user_by_id u = await get_user_by_id(conn, r.created_by_id) if u: mref = MemberRef(id=r.created_by_id, display_name=u.display_name) # hiddenBy is the current user hidden = MemberRef(id=user.id, display_name=user.display_name) return RecipeOut( id=r.id, name=r.name, link=r.link, serves=r.serves, image_urls=r.image_urls, ingredients=r.ingredients, created_by_id=r.created_by_id, created_by=mref, hidden_by_id=user.id, hidden_by=hidden, ) @router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}}) async def create_recipe( recipe: RecipeCreate, response: Response, household=Depends(get_household_from_slug), user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): if not recipe.ingredients: return error_response(None, 400, "Recipe must have at least one ingredient") # Disallow empty ingredients with neither product nor textual content for ing in recipe.ingredients: name = (ing.name or "").strip() line = (ing.line or "").strip() has_product = getattr(ing, "product", None) is not None or ( getattr(ing, "product_id", None) is not None and getattr(ing, "product_id") >= 0 ) if not has_product and name == "" and line == "": return error_response(None, 400, "Ingredient must include a name or line or a product") # quantity must be > 0 try: q = float(getattr(ing, "quantity", 0)) except Exception: q = getattr(ing, "quantity", 0) if isinstance(q, (int, float)) and q <= 0: return error_response(None, 400, "Ingredient quantity must be greater than 0") hid = household["id"] # Build domain model and insert r = recipes.Recipe( id=-1, name=recipe.name, link=recipe.link, serves=recipe.serves, image_urls=recipe.image_urls, ingredients=list(recipe.ingredients), created_by_id=user.id, ) await recipes.insert_recipe_scoped(conn, r, hid) for ingredient in recipe.ingredients: ingredient.recipe_id = r.id if ingredient.product: ingredient.product_id = ingredient.product.id try: await ingredients_mod.insert_ingredient(conn, ingredient) except ValueError as e: return error_response(None, 400, str(e)) response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}" created = MemberRef(id=user.id, display_name=user.display_name) out = RecipeOut( id=r.id, name=r.name, link=r.link, serves=r.serves, image_urls=r.image_urls, ingredients=r.ingredients, created_by_id=r.created_by_id, created_by=created, ) return out.model_dump(by_alias=False) @router.post("/parse-from-url", response_model=RecipeCreate) async def parse_from_url( body: ParseUrlIn, household=Depends(get_household_from_slug), user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): # Build an unsaved Recipe object using NLP parsing and product matching; do not insert r = await recipes.parse_recipe(conn, user, body.url) if not r: # Parsing failed: treat as unprocessable rather than not-found return error_response(None, 422, "Unable to parse recipe from URL") # Return the same shape a client would POST to create return RecipeCreate( name=r.name, link=r.link, serves=r.serves, image_urls=r.image_urls, ingredients=r.ingredients, ) """ Note: public parse endpoint moved to /api/v1/ingredients/parse (see api/ingredients.py) """