Recipes scoping
This commit is contained in:
parent
584488b9c7
commit
4470289b61
6 changed files with 306 additions and 5 deletions
112
api/recipes_v2.py
Normal file
112
api/recipes_v2.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
|
import ingredients as ingredients_mod
|
||||||
|
import recipes
|
||||||
|
from api.deps import error_response, get_db, get_household_from_slug
|
||||||
|
from common import Page, ProblemDetails, ApiModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes-v2"])
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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, [])
|
||||||
|
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))
|
||||||
|
return Page(items=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")
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}})
|
||||||
|
async def create_recipe(
|
||||||
|
recipe: RecipeCreate,
|
||||||
|
response: Response,
|
||||||
|
household=Depends(get_household_from_slug),
|
||||||
|
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"]
|
||||||
|
# v1 Recipe model requires created_by_id; use 0 placeholder until users replace persons
|
||||||
|
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=0,
|
||||||
|
)
|
||||||
|
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}"
|
||||||
|
return r
|
||||||
|
|
@ -214,12 +214,16 @@ Impact on existing routes (exact files to refactor):
|
||||||
- ✅ Added initial `api/households.py` router:
|
- ✅ Added initial `api/households.py` router:
|
||||||
- `GET /api/v1/users/me/households` (requires bearer token) → lists memberships.
|
- `GET /api/v1/users/me/households` (requires bearer token) → lists memberships.
|
||||||
- `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin.
|
- `POST /api/v1/households` (requires bearer token) → creates household and adds current user as admin.
|
||||||
- ⏳ Implement `get_household_from_slug` in `api/deps.py`.
|
- ✅ Implemented `get_household_from_slug` in `api/deps.py`.
|
||||||
- ⏳ Refactor `main.py`:
|
- ✅ Refactor `main.py`:
|
||||||
- Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`.
|
- Create a new `APIRouter` for household-scoped routes, e.g., `household_router = APIRouter(prefix="/api/v1/households/{householdSlug}")`.
|
||||||
- Mount the existing routers (`recipes_api`, `meals_api`, etc.) onto this `household_router`.
|
- Mounted a scoped helper endpoint and a new recipes v2 router under this prefix.
|
||||||
- ⏳ Update Repositories: modify all repository functions to accept `household_id` and filter by it.
|
- ✅ Recipes scoping:
|
||||||
- ⏳ Update Routers: add `get_household_from_slug` dependency to all scoped routes and plumb `household_id`.
|
- Added `api/recipes_v2.py` providing `/api/v1/households/{householdSlug}/recipes` with list/get/create.
|
||||||
|
- Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`.
|
||||||
|
- Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS).
|
||||||
|
- ⏳ Update Repositories: meals, ingredients, shopping, products to accept `household_id` and filter accordingly.
|
||||||
|
- ⏳ Update Routers: move/duplicate existing routers under the household router and wire `household_id` through.
|
||||||
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
|
- **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
|
||||||
|
|
||||||
- Notes:
|
- Notes:
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -15,6 +15,7 @@ from api import (
|
||||||
persons as persons_router,
|
persons as persons_router,
|
||||||
products as products_router,
|
products as products_router,
|
||||||
recipes as recipes_router,
|
recipes as recipes_router,
|
||||||
|
recipes_v2 as recipes_v2_router,
|
||||||
shopping as shopping_router,
|
shopping as shopping_router,
|
||||||
households as households_router,
|
households as households_router,
|
||||||
)
|
)
|
||||||
|
|
@ -169,6 +170,7 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(households_router.scoped, prefix="/api/v1", tags=["v2"]) # type: ignore[attr-defined]
|
app.include_router(households_router.scoped, prefix="/api/v1", tags=["v2"]) # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,19 @@ from recipes.repository import (
|
||||||
compute_prev_cursor as compute_prev_cursor,
|
compute_prev_cursor as compute_prev_cursor,
|
||||||
count_all as count_all,
|
count_all as count_all,
|
||||||
count_by_name as count_by_name,
|
count_by_name as count_by_name,
|
||||||
|
count_all_scoped as count_all_scoped,
|
||||||
|
count_by_name_scoped as count_by_name_scoped,
|
||||||
find_recipe_by_id as find_recipe_by_id,
|
find_recipe_by_id as find_recipe_by_id,
|
||||||
|
find_recipe_by_id_scoped as find_recipe_by_id_scoped,
|
||||||
find_recipes_by_name as find_recipes_by_name,
|
find_recipes_by_name as find_recipes_by_name,
|
||||||
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
||||||
|
find_recipes_by_name_paged_scoped as find_recipes_by_name_paged_scoped,
|
||||||
get_all as get_all,
|
get_all as get_all,
|
||||||
get_all_paged as get_all_paged,
|
get_all_paged as get_all_paged,
|
||||||
|
get_all_paged_scoped as get_all_paged_scoped,
|
||||||
hide_recipe as hide_recipe,
|
hide_recipe as hide_recipe,
|
||||||
insert_recipe as insert_recipe,
|
insert_recipe as insert_recipe,
|
||||||
|
insert_recipe_scoped as insert_recipe_scoped,
|
||||||
load_recipe_ingredients as load_recipe_ingredients,
|
load_recipe_ingredients as load_recipe_ingredients,
|
||||||
row_to_recipe as row_to_recipe,
|
row_to_recipe as row_to_recipe,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,19 @@ async def insert_recipe(conn, recipe: Recipe):
|
||||||
recipe.id = cursor.lastrowid
|
recipe.id = cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
# V2 scoped helpers (preserve v1 signatures)
|
||||||
|
async def insert_recipe_scoped(conn, recipe: Recipe, household_id: int):
|
||||||
|
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
|
||||||
|
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||||
|
|
||||||
|
insert_stmt = f"""
|
||||||
|
INSERT INTO Recipe ({",".join(fields_to_insert)}, household_id)
|
||||||
|
VALUES ({",".join(["?"] * len(fields_to_insert))}, ?)
|
||||||
|
"""
|
||||||
|
async with conn.execute(insert_stmt, (*actual_values, household_id)) as cursor:
|
||||||
|
recipe.id = cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
async def hide_recipe(conn, recipe_id: int, person: Person):
|
async def hide_recipe(conn, recipe_id: int, person: Person):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -95,6 +108,22 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def find_recipe_by_id_scoped(
|
||||||
|
conn, recipe_id: int, household_id: int
|
||||||
|
) -> Optional[Recipe]:
|
||||||
|
async with conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(recipe_id, household_id),
|
||||||
|
) as cursor:
|
||||||
|
async for row in cursor:
|
||||||
|
return row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -117,6 +146,24 @@ async def get_all(conn) -> AsyncIterator[Recipe]:
|
||||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_paged_scoped(
|
||||||
|
conn, after_id: Optional[int], limit: int, household_id: int
|
||||||
|
) -> AsyncIterator[Recipe]:
|
||||||
|
after = after_id if after_id is not None else -1
|
||||||
|
async with conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {",".join(Recipe.KEYS)}
|
||||||
|
FROM Recipe
|
||||||
|
WHERE date_hidden IS NULL AND id > ? AND household_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(after, household_id, limit),
|
||||||
|
) as cursor:
|
||||||
|
async for row in cursor:
|
||||||
|
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||||
|
|
||||||
|
|
||||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||||
recipe.ingredients.append(ingredient)
|
recipe.ingredients.append(ingredient)
|
||||||
|
|
@ -157,6 +204,24 @@ async def find_recipes_by_name_paged(
|
||||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||||
|
|
||||||
|
|
||||||
|
async def find_recipes_by_name_paged_scoped(
|
||||||
|
conn, name: str, after_id: Optional[int], limit: int, household_id: int
|
||||||
|
) -> AsyncIterator[Recipe]:
|
||||||
|
after = after_id if after_id is not None else -1
|
||||||
|
async with conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {",".join(Recipe.KEYS)}
|
||||||
|
FROM Recipe
|
||||||
|
WHERE name LIKE ? AND date_hidden IS NULL AND id > ? AND household_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(f"%{name}%", after, household_id, limit),
|
||||||
|
) as cursor:
|
||||||
|
async for row in cursor:
|
||||||
|
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||||
|
|
||||||
|
|
||||||
async def compute_prev_cursor(
|
async def compute_prev_cursor(
|
||||||
conn, first_id: int, limit: int, name: Optional[str] = None
|
conn, first_id: int, limit: int, name: Optional[str] = None
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|
@ -209,6 +274,19 @@ async def count_all(conn) -> int:
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all_scoped(conn, household_id: int) -> int:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM Recipe
|
||||||
|
WHERE date_hidden IS NULL AND household_id = ?
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
async def count_by_name(conn, name: str) -> int:
|
async def count_by_name(conn, name: str) -> int:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -220,3 +298,16 @@ async def count_by_name(conn, name: str) -> int:
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return int(row[0]) if row else 0
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def count_by_name_scoped(conn, name: str, household_id: int) -> int:
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM Recipe
|
||||||
|
WHERE name LIKE ? AND date_hidden IS NULL AND household_id = ?
|
||||||
|
""",
|
||||||
|
(f"%{name}%", household_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
|
||||||
86
tests/test_recipes_household_v2.py
Normal file
86
tests/test_recipes_household_v2.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import unittest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
from db import connect, create
|
||||||
|
from scripts.migration_to_households import run_migration
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self):
|
||||||
|
self.conn = await connect(":memory:")
|
||||||
|
await create(self.conn)
|
||||||
|
await run_migration(self.conn)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
try:
|
||||||
|
yield self.conn
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||||
|
self.client = TestClient(main.app)
|
||||||
|
|
||||||
|
# Register a user and create two households
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "r@test.com", "password": "pw", "displayName": "R"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
self.token = r.json()["accessToken"]
|
||||||
|
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
self.h1 = r.json()["slug"]
|
||||||
|
r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
self.h2 = r.json()["slug"]
|
||||||
|
|
||||||
|
async def asyncTearDown(self):
|
||||||
|
await self.conn.close()
|
||||||
|
main.app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_create_and_list_scoped_recipes(self):
|
||||||
|
# Create a recipe in H1
|
||||||
|
recipe = {
|
||||||
|
"id": -1,
|
||||||
|
"name": "Soup",
|
||||||
|
"link": "https://example.com/soup",
|
||||||
|
"serves": 2,
|
||||||
|
"imageUrls": [],
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"line": "1 Apple",
|
||||||
|
"name": "Apple",
|
||||||
|
"unit": "Items",
|
||||||
|
"quantity": 1,
|
||||||
|
"preparation": "",
|
||||||
|
"product": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
r = self.client.post(
|
||||||
|
f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
rid = r.json()["id"]
|
||||||
|
|
||||||
|
# List H1 should include
|
||||||
|
r = self.client.get(f"/api/v1/households/{self.h1}/recipes", headers=self.headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert any(it["id"] == rid for it in items)
|
||||||
|
|
||||||
|
# List H2 should not include
|
||||||
|
r = self.client.get(f"/api/v1/households/{self.h2}/recipes", headers=self.headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items2 = r.json()["items"]
|
||||||
|
assert not any(it["id"] == rid for it in items2)
|
||||||
|
|
||||||
|
# Get in H2 by id should 404
|
||||||
|
r = self.client.get(
|
||||||
|
f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
Loading…
Reference in a new issue