munch-ease-backend/api/recipes.py

215 lines
7.2 KiB
Python
Raw Normal View History

2025-10-18 05:50:43 +00:00
from __future__ import annotations
2025-11-01 09:23:03 +00:00
from typing import Dict, List, Optional
2025-10-18 05:50:43 +00:00
import aiosqlite
2025-11-01 09:23:03 +00:00
from fastapi import APIRouter, Depends, Query, Response
2025-10-18 05:50:43 +00:00
2025-11-01 01:21:12 +00:00
import ingredients as ingredients_mod
Squashed commit of the following: commit fcd005b8624023547f28b7b28e59e6099bcfc7d4 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 20:24:07 2025 +1100 Openapi tightening commit f93bd8f641d561052c7bd075bae321b4ff3b676d Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 19:03:52 2025 +1100 Removed refactor strategy doc commit 0c5a61092f522be0c47cbbe86917c8a7e4e2d339 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 17:48:33 2025 +1100 mypy & ruff checks commit 23d66d6b18984127e17c73c3063f6120385935e9 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 16:49:35 2025 +1100 Final removal of db.py files commit f454aed1ca9783cc558cc203f29a7fe31b62a975 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:42:31 2025 +1100 Finalise restructure, remove db.py files commit 7187f6dd89489521538791c6bdebb426514beb99 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:34:54 2025 +1100 commit 6fea227ae20d32b8eb1e7a4885006a620fcc7bb1 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:32:53 2025 +1100 commit 27415e7e02d89195ad514cb017a9dbbf84d7a5e4 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:31:10 2025 +1100 commit b773428033d855f9ad82005602e049c1a2e3c585 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:28:58 2025 +1100 commit 116592c95278d995f4c516e87f2cea43cf5b7735 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:25:21 2025 +1100 commit 03ec565faea088971968ee2f9bb83e2de16b21f3 Author: jableader <jacobdunk@gmail.com> Date: Sun Oct 19 15:21:29 2025 +1100 Plan
2025-10-19 09:24:23 +00:00
import recipes
2025-11-01 09:23:03 +00:00
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
2025-11-01 01:21:12 +00:00
from common import Page, ProblemDetails, ApiModel, Field
from api.dtos import MemberRef
2025-10-18 05:54:35 +00:00
2025-11-01 09:23:03 +00:00
router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"])
2025-10-18 05:50:43 +00:00
2025-11-01 01:21:12 +00:00
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
2025-11-01 01:21:12 +00:00
hidden_by_id: Optional[int] = None
hidden_by: Optional[MemberRef] = None
2025-11-01 01:21:12 +00:00
2025-11-01 09:23:03 +00:00
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}
)
2025-10-18 05:54:35 +00:00
2025-11-01 09:23:03 +00:00
@router.get("", response_model=Page[RecipeOut])
2025-10-18 05:50:43 +00:00
async def list_recipes(
2025-11-01 09:23:03 +00:00
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),
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
2025-11-01 09:23:03 +00:00
):
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
2025-11-01 09:23:03 +00:00
hid = household["id"]
2025-10-18 05:54:35 +00:00
paged: List[recipes.Recipe] = []
if q:
2025-11-01 09:23:03 +00:00
async for r in recipes.find_recipes_by_name_paged_scoped(
conn, q, last_id, fetch_limit, hid
):
2025-10-18 05:54:35 +00:00
paged.append(r)
else:
2025-11-01 09:23:03 +00:00
async for r in recipes.get_all_paged_scoped(conn, last_id, fetch_limit, hid):
2025-10-18 05:54:35 +00:00
paged.append(r)
2025-11-01 09:23:03 +00:00
# Filter by household_id once repositories are fully updated; currently placeholder until repo changes land.
2025-10-18 05:54:35 +00:00
has_more = len(paged) > limit
items = paged[:limit]
if items:
recipe_ids = [r.id for r in items]
2025-11-01 01:21:12 +00:00
by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids)
for r in items:
r.ingredients = by_recipe.get(r.id, [])
2025-11-01 09:23:03 +00:00
# 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,
)
2025-10-18 05:54:35 +00:00
next_cursor = str(items[-1].id) if has_more and items else None
2025-11-01 09:23:03 +00:00
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)
2025-10-18 05:50:43 +00:00
2025-10-18 05:54:35 +00:00
2025-11-01 09:23:03 +00:00
@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
2025-10-18 05:50:43 +00:00
async def get_recipe(
2025-11-01 09:23:03 +00:00
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"])
2025-10-18 05:54:35 +00:00
if not r:
2025-11-01 09:23:03 +00:00
return error_response(None, 404, "Recipe not found")
# 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,
)
2025-10-18 05:54:35 +00:00
2025-11-01 09:23:03 +00:00
@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,
)
2025-10-18 05:50:43 +00:00
2025-11-01 09:23:03 +00:00
@router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}})
2025-10-18 05:50:43 +00:00
async def create_recipe(
2025-11-01 09:23:03 +00:00
recipe: RecipeCreate,
2025-10-18 07:21:04 +00:00
response: Response,
2025-11-01 09:23:03 +00:00
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
2025-10-18 05:50:43 +00:00
conn: aiosqlite.Connection = Depends(get_db),
2025-11-01 09:23:03 +00:00
):
2025-10-18 05:54:35 +00:00
if not recipe.ingredients:
2025-11-01 09:23:03 +00:00
return error_response(None, 400, "Recipe must have at least one ingredient")
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)
2025-10-18 05:54:35 +00:00
for ingredient in recipe.ingredients:
2025-11-01 09:23:03 +00:00
ingredient.recipe_id = r.id
2025-10-18 05:54:35 +00:00
if ingredient.product:
ingredient.product_id = ingredient.product.id
2025-11-01 01:21:12 +00:00
await ingredients_mod.insert_ingredient(conn, ingredient)
2025-11-01 09:23:03 +00:00
response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}"
created = 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=created,
)