From 4470289b61218cb737d8d6a1319641432cb63bdd Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 13:58:46 +1100 Subject: [PATCH] Recipes scoping --- api/recipes_v2.py | 112 +++++++++++++++++++++++++++++ backend-spec.md | 14 ++-- main.py | 2 + recipes/__init__.py | 6 ++ recipes/repository.py | 91 +++++++++++++++++++++++ tests/test_recipes_household_v2.py | 86 ++++++++++++++++++++++ 6 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 api/recipes_v2.py create mode 100644 tests/test_recipes_household_v2.py diff --git a/api/recipes_v2.py b/api/recipes_v2.py new file mode 100644 index 0000000..d0902b7 --- /dev/null +++ b/api/recipes_v2.py @@ -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 diff --git a/backend-spec.md b/backend-spec.md index fc803c4..c407ecc 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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: diff --git a/main.py b/main.py index 6494843..81142a4 100644 --- a/main.py +++ b/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) diff --git a/recipes/__init__.py b/recipes/__init__.py index f51aa60..18edfa2 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -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, ) diff --git a/recipes/repository.py b/recipes/repository.py index d517f2f..aa1bd11 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -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 diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py new file mode 100644 index 0000000..7ad7f76 --- /dev/null +++ b/tests/test_recipes_household_v2.py @@ -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