feat(v2): complete household scoping and meals write flows; refresh OpenAPI

This commit is contained in:
jableader 2025-11-01 15:51:23 +11:00
parent 86e0ea3111
commit ce75603582
6 changed files with 393 additions and 11 deletions

View file

@ -122,3 +122,84 @@ async def mark_meal_consumed_scoped(
await shopping.remove_request(conn, person=None, meal=meal) await shopping.remove_request(conn, person=None, meal=meal)
return 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

View file

@ -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. 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/*`. 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. - 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. - 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`. - ✅ Created `households/` package with `models.py` and `repository.py`.
- ✅ 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.
@ -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 `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`. - 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). - 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/upcoming` filtering by `household_id`.
- `/api/v1/households/{householdSlug}/meals/{id}` returns 404 across households. - `/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. - 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: - Added `api/shopping_v2.py` with:
- GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate). - 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. - 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). - 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. - 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. - 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. - Tests: `tests/test_meals_write_v2.py` verifies create/update/delete flows under household scope; all v2 scoping tests PASS.
- ⏳ 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. Achieved.
- **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:
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes. - 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. - ⏳ 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.) - **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`: - ✅ Augmentation updated in `api/openapi.py`:
- Adds `bearerAuth` security scheme while keeping `cookieAuth` for v1. - Adds `bearerAuth` security scheme while keeping `cookieAuth` for v1.
- Marks household routes and `/users/me/*` with bearer security and adds `403` Problem response. - Marks household routes and `/users/me/*` with bearer security and adds `403` Problem response.
- Preserves RFC7807 Problem responses and shopping storeName outward enum normalization. - Preserves RFC7807 Problem responses and shopping storeName outward enum normalization.
- ✅ New tests `tests/test_openapi_security.py` verify presence of bearerAuth and 403s. - ✅ 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. - ✅ Export script writes updated `openapi.json`; re-run after adding meals v2 write endpoints to include them in the schema.
6. **[ ] Refactor and Test**: 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. - 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.

View file

@ -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 as find_upcoming_meals_by_date_range,
find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped, find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped,
insert_meal as insert_meal, insert_meal as insert_meal,
insert_meal_scoped as insert_meal_scoped,
insert_meal_participant as insert_meal_participant, insert_meal_participant as insert_meal_participant,
insert_meal_recipe as insert_meal_recipe, insert_meal_recipe as insert_meal_recipe,
load_extra_ingredients as load_extra_ingredients, load_extra_ingredients as load_extra_ingredients,

View file

@ -124,6 +124,28 @@ async def insert_meal(conn, meal: Meal):
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) 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 def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
async with conn.execute( async with conn.execute(
f""" f"""

View file

@ -2050,6 +2050,141 @@
"bearerAuth": [] "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": { "/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": { "/api/v1/households/{householdSlug}/shopping/current": {
"get": { "get": {
"tags": [ "tags": [

View file

@ -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