Removed v2 recipe file
This commit is contained in:
parent
bd7a87c3ff
commit
8ee5642690
4 changed files with 173 additions and 450 deletions
382
api/recipes.py
382
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": {}},
|
||||
}
|
||||
},
|
||||
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}
|
||||
)
|
||||
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(
|
||||
"/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)
|
||||
|
||||
|
||||
@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": {}},
|
||||
}
|
||||
},
|
||||
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, 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": {}},
|
||||
}
|
||||
},
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
2
main.py
2
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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue