from __future__ import annotations import datetime from typing import List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Request, Response import ingredients as ingredients_mod import persons import recipes from api.deps import cookie_person, error_response, get_db from common import Page, ProblemDetails, ApiModel, Field router = APIRouter(prefix="/recipes", tags=["recipes"]) # Outward DTO with required arrays in the schema 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} ) based_on_recipe: Optional[int] = None date_created: datetime.datetime created_by_id: int created_by: Optional[persons.Person] = None date_hidden: Optional[datetime.datetime] = None hidden_by_id: Optional[int] = None hidden_by: Optional[persons.Person] = None @router.get( "/parse", response_model=RecipeOut, operation_id="parseRecipe", summary="Parse a recipe from a URL", responses={ 400: { "model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}, } }, ) async def parse_recipe_handler( url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), ) -> recipes.Recipe | Response: parsed = await recipes.parse_recipe(conn, person, url) if not parsed: return error_response(request, 400, "Recipe not found") return parsed @router.get( "/ingredients/parse", operation_id="parseIngredients", summary="Parse raw ingredient lines", ) async def parse_ingredients( lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"), conn: aiosqlite.Connection = Depends(get_db), ) -> List[ingredients_mod.Ingredient]: had_links = False result: List[ingredients_mod.Ingredient] = [] for line in lines: ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line) if ingredient: result.append(ingredient) had_links = True continue ingredient = ingredients_mod.parse_ingredient_from_nlp(line) if ingredient: result.append(ingredient) continue if had_links: # Transaction will commit at end of request pass await ingredients_mod.match_existing_products(conn, result) return result async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]: r = await recipes.find_recipe_by_id(conn, id) if not r: return None r.ingredients = [] async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id): r.ingredients.append(ingredient) r.created_by = await persons.get_by_id(conn, r.created_by_id) return r @router.get( "", operation_id="listRecipes", response_model=Page[RecipeOut], summary="List recipes (paginated)", responses={ 200: { "description": "A page of recipes", "content": { "application/json": { "example": { "items": [ { "id": 1, "name": "Example Recipe", "link": "https://example.com/recipes/1", "serves": 4, "imageUrls": [], "ingredients": [], } ], "nextCursor": "2", "prevCursor": "0", "total": 1, } } }, } }, ) async def list_recipes( request: Request, q: Optional[str] = Query( default=None, description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", ), cursor: Optional[str] = Query( default=None, description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", ), limit: int = Query( 50, ge=1, le=200, description="Maximum number of items to return (1-200).", ), conn: aiosqlite.Connection = Depends(get_db), ) -> Page[recipes.Recipe]: last_id = None if cursor: try: last_id = int(cursor) except ValueError: last_id = None fetch_limit = limit + 1 paged: List[recipes.Recipe] = [] if q: async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit): paged.append(r) else: async for r in recipes.get_all_paged(conn, last_id, fetch_limit): paged.append(r) has_more = len(paged) > limit items = paged[:limit] # Batch-load ingredients for the page to avoid N+1 queries 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, []) next_cursor = str(items[-1].id) if has_more and items else None # Compute prevCursor via DB helper prev_cursor: Optional[str] = None if items: first_id = items[0].id prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q) total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn)) return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total) @router.get( "/{recipe_id}", response_model=RecipeOut, operation_id="getRecipe", summary="Get a single recipe", responses={ 404: { "model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}, } }, ) async def get_recipe( recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) ) -> recipes.Recipe | Response: r = await load_full_recipe(conn, recipe_id) if not r: return error_response(request, 404, "Recipe not found") return r @router.post( "", response_model=RecipeOut, operation_id="createRecipe", summary="Create a new recipe (versioning semantics applied)", responses={ 400: { "model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}, } }, ) async def create_recipe( recipe: recipes.Recipe, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> recipes.Recipe | Response: if not recipe.ingredients: return error_response(request, 400, "Recipe must have at least one ingredient") if recipe.id >= 0: await recipes.hide_recipe(conn, recipe.id, user) recipe.based_on_recipe = recipe.id recipe.id = 0 recipe.created_by_id = user.id await recipes.insert_recipe(conn, recipe) for ingredient in recipe.ingredients: ingredient.recipe_id = recipe.id if ingredient.product: ingredient.product_id = ingredient.product.id await ingredients_mod.insert_ingredient(conn, ingredient) # Transaction will commit at end of request # Set Location to the new resource response.headers["Location"] = f"/api/v1/recipes/{recipe.id}" return recipe @router.delete( "/{recipe_id}", response_model=recipes.Recipe, operation_id="deleteRecipe", summary="Soft-delete (hide) a recipe", responses={ 404: { "model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}, } }, ) async def delete_recipe( recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), ) -> recipes.Recipe | Response: recipe = await recipes.find_recipe_by_id(conn, recipe_id) if not recipe: return error_response(request, 404, "Recipe not found") await recipes.hide_recipe(conn, recipe_id, user) return recipe