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
|
2025-10-18 05:54:35 +00:00
|
|
|
from common import Page, ProblemDetails, ApiModel
|
|
|
|
|
from pydantic import Field
|
2025-10-18 06:14:03 +00:00
|
|
|
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
|
|
|
class ProductUrl(ApiModel):
|
|
|
|
|
url: str
|
|
|
|
|
tags: List[str] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None
|
|
|
|
|
) -> 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(
|
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),
|
|
|
|
|
request: Request | None = None,
|
|
|
|
|
) -> 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, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
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,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
user: persons.Person = Depends(cookie_person),
|
|
|
|
|
request: Request | None = None,
|
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,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
user: persons.Person = Depends(cookie_person),
|
|
|
|
|
request: Request | None = None,
|
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
|