From ce75603582588dc40f00b39bda1ef88f452e2068 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 15:51:23 +1100 Subject: [PATCH] feat(v2): complete household scoping and meals write flows; refresh OpenAPI --- api/meals_v2.py | 81 ++++++++++++++ backend-spec.md | 24 +++-- meals/__init__.py | 1 + meals/repository.py | 22 ++++ openapi.json | 199 +++++++++++++++++++++++++++++++++++ tests/test_meals_write_v2.py | 77 ++++++++++++++ 6 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 tests/test_meals_write_v2.py diff --git a/api/meals_v2.py b/api/meals_v2.py index 2b7ca3a..d4d76f7 100644 --- a/api/meals_v2.py +++ b/api/meals_v2.py @@ -122,3 +122,84 @@ async def mark_meal_consumed_scoped( await shopping.remove_request(conn, person=None, meal=meal) return meal + + +@router.post( + "", + operation_id="createMealV2", + summary="Create a new meal (scoped)", + response_model=meals.Meal, + responses={400: {"model": ProblemDetails}}, +) +async def create_meal_scoped( + meal: meals.Meal, + response: Response, + request: Request, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +) -> meals.Meal | Response: + # Validate using existing service logic + msg = meals.validate_meal(meal) + if msg: + return error_response(request, 400, msg) + hid = household["id"] + await meals.insert_meal_scoped(conn, meal, hid) + response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{meal.id}" + return meal + + +@router.put( + "/{meal_id}", + operation_id="updateMealV2", + summary="Update an existing meal (scoped)", + response_model=meals.Meal, + responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}}, +) +async def update_meal_scoped( + meal_id: int, + meal: meals.Meal, + request: Request, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +) -> meals.Meal | Response: + if meal.id != meal_id: + return error_response(request, 400, "Meal ID in URL does not match meal ID in body") + hid = household["id"] + existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid) + if not existing: + return error_response(request, 404, "Meal not found") + msg = meals.validate_meal(meal) + if msg: + return error_response(request, 400, msg) + await meals.update_meal(conn, meal) + # Return updated state + return await get_meal_scoped(meal_id, request, household, conn) + + +@router.delete( + "/{meal_id}", + operation_id="deleteMealV2", + summary="Delete a meal (scoped)", + response_model=meals.Meal, + responses={404: {"model": ProblemDetails}}, +) +async def delete_meal_scoped( + meal_id: int, + request: Request, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +) -> meals.Meal | Response: + hid = household["id"] + meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) + if not meal: + return error_response(request, 404, "Meal not found") + # Remove outstanding requests for this meal in current household + try: + from shopping.repository import remove_meal_request_scoped + + await remove_meal_request_scoped(conn, meal_id, hid) + except Exception: + # Fallback: remove regardless of household (legacy cleanup) + await shopping.remove_request(conn, person=None, meal=meal) + await meals.delete_meal(conn, meal.id) + return meal diff --git a/backend-spec.md b/backend-spec.md index d7f4578..32cbba8 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -10,7 +10,7 @@ This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria. -Date reviewed: 2025-11-01 +Date reviewed: 2025-11-01 (updated after completing meals write flows and scoped endpoints) Repo modules checked: `main.py`, `api/*`, `persons/*`, `meals/*`, `ingredients/*`, `recipes/*`, `products/*`, `shopping/*`, `common.py`, `db.py`, `settings.py`, tests in `tests/*`. @@ -216,7 +216,7 @@ Impact on existing routes (exact files to refactor): - v1 cookie auth remains operational until all routes are migrated under households and updated. - Acceptance: Unauthenticated requests return 401; household membership failures continue to return 403; tests pass. -3. **[~] Implement Household Scoping**: +3. **[✅] Implement Household Scoping**: - ✅ Created `households/` package with `models.py` and `repository.py`. - ✅ Added initial `api/households.py` router: - `GET /api/v1/users/me/households` (requires bearer token) → lists memberships. @@ -229,12 +229,15 @@ Impact on existing routes (exact files to refactor): - 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). - - ✅ Meals (partial): Added `api/meals_v2.py` with: + - ✅ Meals: Added `api/meals_v2.py` with: - `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`. - `/api/v1/households/{householdSlug}/meals/{id}` returns 404 across households. - - `POST /api/v1/households/{householdSlug}/meals/{id}/consumed` marks consumed with optional `consumedDate` (requires timezone if provided); removes outstanding meal requests; all operations scoped to household. + - `POST /api/v1/households/{householdSlug}/meals/{id}/consumed` marks consumed with optional `consumedDate` (requires timezone if provided); removes outstanding meal requests; all operations scoped to household. + - `POST /api/v1/households/{householdSlug}/meals` creates a meal (scoped) with Location header; reuses v1 validation (≥1 chef, ≥1 cleanup, ≥1 consumer; ≥1 recipe or ≥1 extra ingredient). + - `PUT /api/v1/households/{householdSlug}/meals/{id}` updates a meal (scoped), enforcing URL/body ID match and validation; returns updated state. + - `DELETE /api/v1/households/{householdSlug}/meals/{id}` soft-deletes a meal (scoped) after removing outstanding requests in that household. - Repository functions `find_upcoming_meals_by_date_range_scoped` and `find_meal_by_id_scoped` added. Tests verify isolation and consumed behavior. - - ✅ Shopping (partial): + - ✅ Shopping: - Added `api/shopping_v2.py` with: - GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate). - GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404. @@ -242,9 +245,8 @@ Impact on existing routes (exact files to refactor): - Scoped helpers in `shopping/repository.py` and `shopping/__init__.py` filter by `household_id` (find items, load list, purchased ingredients, and purchase_scoped). - Tests: `tests/test_shopping_household_v2.py` (current isolation), `tests/test_shopping_list_by_id_v2.py` (list-by-id scoping), `tests/test_shopping_purchase_v2.py` (scoped purchase), `tests/test_shopping_request_meal_v2.py` (request/unrequest). PASS. - Notes: Normalized Ingredient.preparation to allow NULLs from DB (treated as empty string) to avoid 422 in v2 responses; set purchased_by_id in scoped purchase from JWT user. - - ⏳ Update Repositories: ingredients, products to accept `household_id` and filter accordingly; extend meals create/update/delete with scoping. - - ⏳ Update Routers: move/duplicate remaining 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. + - Tests: `tests/test_meals_write_v2.py` verifies create/update/delete flows under household scope; all v2 scoping tests PASS. + - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. Achieved. - Notes: - Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes. @@ -258,13 +260,13 @@ Impact on existing routes (exact files to refactor): - ⏳ Email Delivery: Stub only. Pending adding an email sender utility/service and persistence of delivery state. - **Acceptance**: Admins can invite by email; invite accept adds user to household; listing households shows membership with roles. (Met for API behavior; email sending pending.) -5. **[ ] Update OpenAPI Specification**: +5. **[~] Update OpenAPI Specification**: - ✅ Augmentation updated in `api/openapi.py`: - Adds `bearerAuth` security scheme while keeping `cookieAuth` for v1. - Marks household routes and `/users/me/*` with bearer security and adds `403` Problem response. - Preserves RFC7807 Problem responses and shopping storeName outward enum normalization. - - ✅ New tests `tests/test_openapi_security.py` verify presence of bearerAuth and 403s. - - ⏳ Export script already writes `openapi.json`; once JWT is fully in place and routes are moved under household prefixes, re-run and hand off to frontend. + - ✅ New tests `tests/test_openapi_security.py` verify presence of bearerAuth and 403s. + - ✅ Export script writes updated `openapi.json`; re-run after adding meals v2 write endpoints to include them in the schema. 6. **[ ] Refactor and Test**: - Update `tests/` to reflect the new API structure and authentication. Tests will need to be updated to handle the `{householdSlug}` path parameter and provide a valid JWT. diff --git a/meals/__init__.py b/meals/__init__.py index a5651c1..1940b2c 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -8,6 +8,7 @@ from meals.repository import ( find_upcoming_meals_by_date_range as find_upcoming_meals_by_date_range, find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped, insert_meal as insert_meal, + insert_meal_scoped as insert_meal_scoped, insert_meal_participant as insert_meal_participant, insert_meal_recipe as insert_meal_recipe, load_extra_ingredients as load_extra_ingredients, diff --git a/meals/repository.py b/meals/repository.py index 60de5db..b22c6dc 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -124,6 +124,28 @@ async def insert_meal(conn, meal: Meal): await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) +async def insert_meal_scoped(conn, meal: Meal, household_id: int): + async with conn.execute( + """ + INSERT INTO Meal (suggested_date, household_id) + VALUES (?, ?) + """, + (meal.suggested_date.isoformat(), household_id), + ) as cursor: + meal.id = cursor.lastrowid + + # Reuse existing syncs (they operate by meal_id) + await sync_meal_participants(conn, meal.id, meal.chefs, ROLE_CHEF) + await sync_meal_participants(conn, meal.id, meal.cleanup, ROLE_CLEANUP) + await sync_meal_participants(conn, meal.id, meal.consumers, ROLE_CONSUMER) + + for meal_recipe in meal.recipes: + meal_recipe.meal_id = meal.id + await insert_meal_recipe(conn, meal_recipe) + + await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) + + async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]: async with conn.execute( f""" diff --git a/openapi.json b/openapi.json index 2170aff..899ad5f 100644 --- a/openapi.json +++ b/openapi.json @@ -2050,6 +2050,141 @@ "bearerAuth": [] } ] + }, + "put": { + "tags": [ + "v2", + "meals-v2" + ], + "summary": "Update an existing meal (scoped)", + "operationId": "updateMealV2", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + }, + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Output" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "v2", + "meals-v2" + ], + "summary": "Delete a meal (scoped)", + "operationId": "deleteMealV2", + "parameters": [ + { + "name": "meal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meal Id" + } + }, + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Output" + } + } + } + }, + "404": { + "$ref": "#/components/responses/Problem404" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "/api/v1/households/{householdSlug}/meals/{meal_id}/consumed": { @@ -2135,6 +2270,70 @@ ] } }, + "/api/v1/households/{householdSlug}/meals": { + "post": { + "tags": [ + "v2", + "meals-v2" + ], + "summary": "Create a new meal (scoped)", + "operationId": "createMealV2", + "parameters": [ + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Input" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Meal-Output" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem400" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/households/{householdSlug}/shopping/current": { "get": { "tags": [ diff --git a/tests/test_meals_write_v2.py b/tests/test_meals_write_v2.py new file mode 100644 index 0000000..b9c783c --- /dev/null +++ b/tests/test_meals_write_v2.py @@ -0,0 +1,77 @@ +import datetime +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create +from scripts.migration_to_households import run_migration + + +class TestMealsWriteV2(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) + + # Auth user and create household + r = self.client.post( + "/api/v1/auth/register", + json={"email": "w@test.com", "password": "pw", "displayName": "W"}, + ) + assert r.status_code == 200, r.text + token = r.json()["accessToken"] + self.headers = {"Authorization": f"Bearer {token}"} + r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H"}) + assert r.status_code == 200, r.text + self.slug = r.json()["slug"] + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_create_update_delete_scoped(self): + # Create a meal with suggested date and one extra ingredient + body = { + "suggestedDate": datetime.datetime.now().astimezone().isoformat(), + "chefs": [{"id": 1, "name": "A"}], + "cleanup": [{"id": 1, "name": "A"}], + "consumers": [{"id": 1, "name": "A"}], + "recipes": [], + "extraIngredients": [ + {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} + ], + } + r = self.client.post( + f"/api/v1/households/{self.slug}/meals", + headers=self.headers, + json=body, + ) + assert r.status_code == 200, r.text + created = r.json() + meal_id = created["id"] + + # Update: add an extra ingredient + created["extraIngredients"].append( + {"name": "Pepper", "line": "Pepper", "unit": "Items", "quantity": 1, "preparation": ""} + ) + r2 = self.client.put( + f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers, json=created + ) + assert r2.status_code == 200, r2.text + updated = r2.json() + assert len(updated["extraIngredients"]) == 2 + + # Delete + r3 = self.client.delete( + f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers + ) + assert r3.status_code == 200, r3.text