munch-ease-backend/api/recipes.py

259 lines
7.6 KiB
Python

from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import ingredients
import persons
import recipes
from api.deps import cookie_person, error_response, get_db
from common import Page, ProblemDetails
router = APIRouter(prefix="/recipes", tags=["recipes"])
@router.get(
"/parse",
response_model=recipes.Recipe,
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.Ingredient]:
had_links = False
result = []
for line in lines:
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
# Transaction will commit at end of request
pass
await ingredients.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.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[recipes.Recipe],
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.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=recipes.Recipe,
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=recipes.Recipe,
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.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