diff --git a/api/recipes.py b/api/recipes.py index 29d65c7..4586c72 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -1,22 +1,19 @@ from __future__ import annotations -import datetime -from typing import List, Optional +from typing import Dict, List, Optional import aiosqlite -from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi import APIRouter, Depends, Query, Response import ingredients as ingredients_mod -import persons import recipes -from api.deps import cookie_person, error_response, get_db +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="/recipes", tags=["recipes"]) +router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"]) -# Outward DTO with required arrays in the schema class RecipeOut(ApiModel): id: int = -1 name: str @@ -26,265 +23,192 @@ class RecipeOut(ApiModel): ingredients: List[ingredients_mod.Ingredient] = Field( min_length=0, json_schema_extra={"minItems": 0} ) - based_on_recipe: Optional[int] = None - date_created: datetime.datetime created_by_id: int created_by: Optional[MemberRef] = None - date_hidden: Optional[datetime.datetime] = None hidden_by_id: Optional[int] = None hidden_by: Optional[MemberRef] = None -@router.get( - "/parse", - response_model=RecipeOut, - 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 +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} + ) -@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_mod.Ingredient]: - had_links = False - result: List[ingredients_mod.Ingredient] = [] - for line in lines: - ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line) - if ingredient: - result.append(ingredient) - had_links = True - continue - - ingredient = ingredients_mod.parse_ingredient_from_nlp(line) - if ingredient: - result.append(ingredient) - continue - - if had_links: - # Transaction will commit at end of request - pass - - await ingredients_mod.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_mod.find_ingredients_by_recipe_id(conn, id): - r.ingredients.append(ingredient) - - p = await persons.get_by_id(conn, r.created_by_id) - if p: - try: - r.created_by = MemberRef(id=p.id, display_name=p.name) - except Exception: - r.created_by = None - - return r - - -@router.get( - "", - operation_id="listRecipes", - response_model=Page[RecipeOut], - 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, - } - } - }, - } - }, -) +@router.get("", response_model=Page[RecipeOut]) 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).", - ), + 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), -) -> Page[recipes.Recipe]: +): 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(conn, q, last_id, fetch_limit): + 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(conn, last_id, fetch_limit): + 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] - # 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_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 - # 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) + 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, - operation_id="getRecipe", - summary="Get a single recipe", - responses={ - 404: { - "model": ProblemDetails, - "description": "Recipe not found", - "content": {"application/problem+json": {}}, - } - }, -) +@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) 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=RecipeOut, - 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, + recipe_id: int, + household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db), - user: persons.Person = Depends(cookie_person), -) -> recipes.Recipe | Response: +): + r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) + if not r: + 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, + ) + + +@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(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) + 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) for ingredient in recipe.ingredients: - ingredient.recipe_id = recipe.id + ingredient.recipe_id = r.id if ingredient.product: ingredient.product_id = ingredient.product.id await ingredients_mod.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) - # Attach hiddenBy for outward compatibility - try: - recipe.hidden_by_id = user.id - recipe.hidden_by = MemberRef(id=user.id, display_name=user.name) - except Exception: - pass - return recipe + 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, + ) diff --git a/api/recipes_v2.py b/api/recipes_v2.py deleted file mode 100644 index 884f749..0000000 --- a/api/recipes_v2.py +++ /dev/null @@ -1,219 +0,0 @@ -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"]) - - -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} - ) - - -@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") - # 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 - - # Soft-delete and set hidden_by_id for the record; rely on repository for date_hidden - await conn.execute( - "UPDATE Recipe SET hidden_by_id = ? WHERE id = ? AND household_id = ?", - (user.id, recipe_id, household["id"]), - ) - ok = await hide_recipe_scoped(conn, recipe_id, household["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") - hid = household["id"] - # Set creator to the current user - 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 - await ingredients_mod.insert_ingredient(conn, ingredient) - 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, - ) diff --git a/main.py b/main.py index d7c29c3..3f75d93 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from api import ( auth as auth_router, - recipes_v2 as recipes_router, + recipes as recipes_router, meals as meals_router, shopping as shopping_router, households as households_router, diff --git a/recipes/repository.py b/recipes/repository.py index f65c374..807d139 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -101,6 +101,24 @@ async def hide_recipe_scoped(conn, recipe_id: int, household_id: int) -> bool: return cur.rowcount > 0 +async def hide_recipe_scoped_with_actor( + conn, recipe_id: int, household_id: int, user_id: int +) -> bool: + """Soft-delete a recipe within a household and record the hiding user. + + Returns True if updated, False if not found or out-of-scope. + """ + async with conn.execute( + """ + UPDATE Recipe + SET date_hidden = ?, hidden_by_id = ? + WHERE id = ? AND household_id = ? + """, + (datetime.datetime.now().astimezone().isoformat(), user_id, recipe_id, household_id), + ) as cur: + return cur.rowcount > 0 + + def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe: d: dict[str, Any] = {k: v for k, v in col_tuples} img_raw = (