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:
|
||||
- `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:
|
||||
|
|
|
|||
2
main.py
2
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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