Removed v2 recipe file

This commit is contained in:
jableader 2025-11-01 20:23:03 +11:00
parent bd7a87c3ff
commit 8ee5642690
4 changed files with 173 additions and 450 deletions

View file

@ -1,22 +1,19 @@
from __future__ import annotations from __future__ import annotations
import datetime from typing import Dict, List, Optional
from typing import List, Optional
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Response
import ingredients as ingredients_mod import ingredients as ingredients_mod
import persons
import recipes 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 common import Page, ProblemDetails, ApiModel, Field
from api.dtos import MemberRef 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): class RecipeOut(ApiModel):
id: int = -1 id: int = -1
name: str name: str
@ -26,265 +23,192 @@ class RecipeOut(ApiModel):
ingredients: List[ingredients_mod.Ingredient] = Field( ingredients: List[ingredients_mod.Ingredient] = Field(
min_length=0, json_schema_extra={"minItems": 0} min_length=0, json_schema_extra={"minItems": 0}
) )
based_on_recipe: Optional[int] = None
date_created: datetime.datetime
created_by_id: int created_by_id: int
created_by: Optional[MemberRef] = None created_by: Optional[MemberRef] = None
date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None hidden_by_id: Optional[int] = None
hidden_by: Optional[MemberRef] = None hidden_by: Optional[MemberRef] = None
@router.get( class RecipeCreate(ApiModel):
"/parse", name: str
response_model=RecipeOut, link: str
operation_id="parseRecipe", serves: int
summary="Parse a recipe from a URL", image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0})
responses={ ingredients: List[ingredients_mod.Ingredient] = Field(
400: { min_length=0, json_schema_extra={"minItems": 0}
"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
@router.get( @router.get("", response_model=Page[RecipeOut])
"/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,
}
}
},
}
},
)
async def list_recipes( async def list_recipes(
request: Request, household=Depends(get_household_from_slug),
q: Optional[str] = Query( q: Optional[str] = Query(default=None),
default=None, cursor: Optional[str] = Query(default=None),
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", limit: int = Query(50, ge=1, le=200),
),
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).",
),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> Page[recipes.Recipe]: ):
last_id = None last_id = None
if cursor: if cursor:
try: try:
last_id = int(cursor) last_id = int(cursor)
except ValueError: except ValueError:
last_id = None last_id = None
fetch_limit = limit + 1 fetch_limit = limit + 1
hid = household["id"]
paged: List[recipes.Recipe] = [] paged: List[recipes.Recipe] = []
if q: 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) paged.append(r)
else: 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) paged.append(r)
# Filter by household_id once repositories are fully updated; currently placeholder until repo changes land.
has_more = len(paged) > limit has_more = len(paged) > limit
items = paged[:limit] items = paged[:limit]
# Batch-load ingredients for the page to avoid N+1 queries
if items: if items:
recipe_ids = [r.id for r in items] recipe_ids = [r.id for r in items]
by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids) by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids)
for r in items: for r in items:
r.ingredients = by_recipe.get(r.id, []) 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 next_cursor = str(items[-1].id) if has_more and items else None
# Compute prevCursor via DB helper total = await (
prev_cursor: Optional[str] = None recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid)
if items: )
first_id = items[0].id outward_items = [to_recipe_out(r) for r in items]
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q) return Page(items=outward_items, nextCursor=next_cursor, prevCursor=None, total=total)
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)
@router.get( @router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
"/{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": {}},
}
},
)
async def get_recipe( async def get_recipe(
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db) recipe_id: int,
) -> recipes.Recipe | Response: household=Depends(get_household_from_slug),
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,
conn: aiosqlite.Connection = Depends(get_db), 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: if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient") return error_response(None, 400, "Recipe must have at least one ingredient")
hid = household["id"]
if recipe.id >= 0: # Build domain model and insert
await recipes.hide_recipe(conn, recipe.id, user) r = recipes.Recipe(
recipe.based_on_recipe = recipe.id id=-1,
recipe.id = 0 name=recipe.name,
link=recipe.link,
recipe.created_by_id = user.id serves=recipe.serves,
await recipes.insert_recipe(conn, recipe) 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: for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id ingredient.recipe_id = r.id
if ingredient.product: if ingredient.product:
ingredient.product_id = ingredient.product.id ingredient.product_id = ingredient.product.id
await ingredients_mod.insert_ingredient(conn, ingredient) await ingredients_mod.insert_ingredient(conn, ingredient)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}"
# Transaction will commit at end of request created = MemberRef(id=user.id, display_name=user.display_name)
# Set Location to the new resource return RecipeOut(
response.headers["Location"] = f"/api/v1/recipes/{recipe.id}" id=r.id,
return recipe name=r.name,
link=r.link,
serves=r.serves,
@router.delete( image_urls=r.image_urls,
"/{recipe_id}", ingredients=r.ingredients,
response_model=recipes.Recipe, created_by_id=r.created_by_id,
operation_id="deleteRecipe", created_by=created,
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

View file

@ -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,
)

View file

@ -10,7 +10,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from api import ( from api import (
auth as auth_router, auth as auth_router,
recipes_v2 as recipes_router, recipes as recipes_router,
meals as meals_router, meals as meals_router,
shopping as shopping_router, shopping as shopping_router,
households as households_router, households as households_router,

View file

@ -101,6 +101,24 @@ async def hide_recipe_scoped(conn, recipe_id: int, household_id: int) -> bool:
return cur.rowcount > 0 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: def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
d: dict[str, Any] = {k: v for k, v in col_tuples} d: dict[str, Any] = {k: v for k, v in col_tuples}
img_raw = ( img_raw = (