Recipes scoping

This commit is contained in:
jableader 2025-11-01 13:58:46 +11:00
parent 584488b9c7
commit 4470289b61
6 changed files with 306 additions and 5 deletions

112
api/recipes_v2.py Normal file
View 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

View file

@ -214,12 +214,16 @@ Impact on existing routes (exact files to refactor):
- ✅ Added initial `api/households.py` router:
- `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.
- ⏳ Implement `get_household_from_slug` in `api/deps.py`.
- Refactor `main.py`:
- ✅ Implemented `get_household_from_slug` in `api/deps.py`.
- Refactor `main.py`:
- 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`.
- ⏳ Update Repositories: modify all repository functions to accept `household_id` and filter by it.
- ⏳ Update Routers: add `get_household_from_slug` dependency to all scoped routes and plumb `household_id`.
- Mounted a scoped helper endpoint and a new recipes v2 router under this prefix.
- ✅ Recipes scoping:
- 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.
- Notes:

View file

@ -15,6 +15,7 @@ from api import (
persons as persons_router,
products as products_router,
recipes as recipes_router,
recipes_v2 as recipes_v2_router,
shopping as shopping_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]
except Exception:
pass
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new
# Routes
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)

View file

@ -8,13 +8,19 @@ from recipes.repository import (
compute_prev_cursor as compute_prev_cursor,
count_all as count_all,
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_scoped as find_recipe_by_id_scoped,
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_scoped as find_recipes_by_name_paged_scoped,
get_all as get_all,
get_all_paged as get_all_paged,
get_all_paged_scoped as get_all_paged_scoped,
hide_recipe as hide_recipe,
insert_recipe as insert_recipe,
insert_recipe_scoped as insert_recipe_scoped,
load_recipe_ingredients as load_recipe_ingredients,
row_to_recipe as row_to_recipe,
)

View file

@ -61,6 +61,19 @@ async def insert_recipe(conn, recipe: Recipe):
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):
await conn.execute(
"""
@ -95,6 +108,22 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
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 with conn.execute(
f"""
@ -117,6 +146,24 @@ async def get_all(conn) -> AsyncIterator[Recipe]:
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 for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
recipe.ingredients.append(ingredient)
@ -157,6 +204,24 @@ async def find_recipes_by_name_paged(
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(
conn, first_id: int, limit: int, name: Optional[str] = None
) -> Optional[str]:
@ -209,6 +274,19 @@ async def count_all(conn) -> int:
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:
cursor = await conn.execute(
"""
@ -220,3 +298,16 @@ async def count_by_name(conn, name: str) -> int:
)
row = await cursor.fetchone()
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

View 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