munch-ease-backend/api/recipes.py

255 lines
7.4 KiB
Python
Raw Normal View History

2025-10-18 05:50:43 +00:00
from __future__ import annotations
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request
import ingredients
import recipes
import persons
from common import Page, ProblemDetails
from api.deps import get_db, cookie_person, error_response
2025-10-18 05:54:35 +00:00
2025-10-18 05:50:43 +00:00
router = APIRouter(prefix="/recipes", tags=["recipes"])
2025-10-18 05:54:35 +00:00
@router.get(
"/parse",
response_model=None,
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)
2025-10-18 05:54:35 +00:00
) -> recipes.Recipe | ProblemDetails:
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:
await conn.commit()
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)
if r.created_by_id is not None:
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
}
}
},
}
},
)
2025-10-18 05:50:43 +00:00
async def list_recipes(
request: Request,
2025-10-18 05:54:35 +00:00
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).",
),
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
) -> Page[recipes.Recipe]:
2025-10-18 05:54:35 +00:00
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)
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
has_more = len(paged) > limit
items = paged[:limit]
# load ingredients for items
for r in items:
r.ingredients = []
async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id):
r.ingredients.append(ing)
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)
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
@router.get(
"/{recipe_id}",
response_model=None,
operation_id="getRecipe",
summary="Get a single recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
2025-10-18 05:50:43 +00:00
async def get_recipe(
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
2025-10-18 05:54:35 +00:00
) -> recipes.Recipe | ProblemDetails:
r = await load_full_recipe(conn, recipe_id)
if not r:
return error_response(request, 404, "Recipe not found")
return r
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
@router.post(
"",
response_model=None,
operation_id="createRecipe",
summary="Create a new recipe (versioning semantics applied)",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
2025-10-18 05:50:43 +00:00
async def create_recipe(
recipe: recipes.Recipe,
request: Request,
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
2025-10-18 05:54:35 +00:00
) -> recipes.Recipe | ProblemDetails:
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)
await conn.commit()
return recipe
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
@router.delete(
"/{recipe_id}",
response_model=None,
operation_id="deleteRecipe",
summary="Soft-delete (hide) a recipe",
responses={
404: {
"model": ProblemDetails,
"description": "Recipe not found",
"content": {"application/problem+json": {}},
}
},
)
2025-10-18 05:50:43 +00:00
async def delete_recipe(
recipe_id: int,
request: Request,
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
2025-10-18 05:54:35 +00:00
) -> recipes.Recipe | ProblemDetails:
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe:
return error_response(request, 404, "Recipe not found")
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
await recipes.hide_recipe(conn, recipe_id, user)
await conn.commit()
return recipe