268 lines
9.2 KiB
Python
268 lines
9.2 KiB
Python
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:
|
|
return error_response(None, 404, "Recipe data not found at 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)
|
|
"""
|