diff --git a/api/shopping_v2.py b/api/shopping_v2.py index 156cd5f..fe6a6ba 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -6,6 +6,7 @@ import aiosqlite from fastapi import APIRouter, Depends, Request, Response import shopping +from common import ApiModel as _ApiModel from api.deps import error_response, get_db, get_household_from_slug, get_current_user from api.shopping import ( CurrentShoppingList, @@ -13,6 +14,7 @@ from api.shopping import ( _to_ingredient_item, _to_meal_item, _to_shopping_list_out, + RequestedMealItem, PurchaseListIn, ) @@ -145,3 +147,53 @@ async def purchase_ingredients_scoped( recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup, ) + + +class MealIdWrapper(_ApiModel): + meal_id: int + + +class Ok(_ApiModel): + ok: bool = True + + +@router.post( + "/current/meals/me", + response_model=RequestedMealItem, + operation_id="requestMealV2", + summary="Request a meal for shopping (scoped)", +) +async def request_meal_scoped( + r: MealIdWrapper, + request: Request, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +): + hid = household["id"] + from meals.repository import find_meal_by_id_scoped + + meal = await find_meal_by_id_scoped(conn, r.meal_id, hid) + if not meal: + return error_response(request, 404, "Meal not found") + + try: + item = await shopping.request_meal_scoped(conn, meal, hid) + except ValueError as e: + return error_response(request, 400, str(e)) + return _to_meal_item(item) + + +@router.delete( + "/current/meals/{meal_id}", + response_model=Ok, + operation_id="unrequestMealV2", + summary="Remove a meal request (scoped)", +) +async def unrequest_meal_scoped( + meal_id: int, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +): + hid = household["id"] + await shopping.remove_meal_request_scoped(conn, meal_id, hid) + return Ok() diff --git a/openapi.json b/openapi.json index 2dfd8f1..2170aff 100644 --- a/openapi.json +++ b/openapi.json @@ -2307,6 +2307,127 @@ ] } }, + "/api/v1/households/{householdSlug}/shopping/current/meals/me": { + "post": { + "tags": [ + "v2", + "shopping-v2" + ], + "summary": "Request a meal for shopping (scoped)", + "operationId": "requestMealV2", + "parameters": [ + { + "name": "householdSlug", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Householdslug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MealIdWrapper" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestedMealItem" + } + } + } + }, + "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/meals/{meal_id}": { + "delete": { + "tags": [ + "v2", + "shopping-v2" + ], + "summary": "Remove a meal request (scoped)", + "operationId": "unrequestMealV2", + "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/Ok" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Problem403" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/healthz": { "get": { "summary": "Healthz", diff --git a/shopping/__init__.py b/shopping/__init__.py index 84c882d..81b86d8 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -10,6 +10,8 @@ from shopping.repository import ( get_purchased_ingredients as _get_purchased_ingredients, get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped, is_requested as is_requested, + request_meal_scoped as request_meal_scoped, + remove_meal_request_scoped as remove_meal_request_scoped, load_shopping_list as load_shopping_list, load_shopping_list_scoped as load_shopping_list_scoped, purchase as purchase, diff --git a/shopping/repository.py b/shopping/repository.py index 72cafc5..aa7cefa 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -292,6 +292,18 @@ async def is_requested(conn, meal) -> bool: return row[0] > 0 +async def is_requested_scoped(conn, meal_id: int, household_id: int) -> bool: + async with conn.execute( + """ + SELECT COUNT(*) FROM ShoppingListItem + WHERE meal_id = ? AND list_id IS NULL AND household_id = ? + """, + (meal_id, household_id), + ) as cursor: + row = await cursor.fetchone() + return row[0] > 0 + + async def request( conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None ) -> ShoppingListItem: @@ -331,6 +343,31 @@ async def request( return item +async def request_meal_scoped( + conn, meal: Any, household_id: int, person_id: Optional[int] = None +) -> ShoppingListItem: + if meal is None or getattr(meal, "id", -1) < 0: + raise ValueError("Meal must have a valid id") + if await is_requested_scoped(conn, meal.id, household_id): + raise ValueError("Meal is already requested") + + # Use 0 for outward personId to satisfy schema without binding to v1 persons + pid = person_id if person_id is not None else 0 + + item = ShoppingListItem(ingredient_id=None, person_id=pid, meal_id=meal.id) + + async with conn.execute( + """ + INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id) + VALUES (?, ?, ?, ?, ?) + """, + (None, pid, meal.id, item.created_date.isoformat(), household_id), + ) as cursor: + item.id = cursor.lastrowid + + return item + + async def remove_request( conn, person: Optional[Any] = None, @@ -360,6 +397,17 @@ async def remove_request( raise ValueError("Must specify either a meal or an ingredient to remove") +async def remove_meal_request_scoped(conn, meal_id: int, household_id: int) -> bool: + async with conn.execute( + """ + DELETE FROM ShoppingListItem + WHERE list_id IS NULL AND meal_id = ? AND household_id = ? + """, + (meal_id, household_id), + ) as cursor: + return cursor.rowcount > 0 + + async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]: request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] diff --git a/tests/test_shopping_request_meal_v2.py b/tests/test_shopping_request_meal_v2.py new file mode 100644 index 0000000..4bc3e9b --- /dev/null +++ b/tests/test_shopping_request_meal_v2.py @@ -0,0 +1,111 @@ +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 TestShoppingRequestMealV2(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 user (v2) and create two households + r = self.client.post( + "/api/v1/auth/register", + json={"email": "req@test.com", "password": "pw", "displayName": "Req"}, + ) + 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": "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"] + + # Resolve household ids + async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h1,)) as c: + row = await c.fetchone() + assert row is not None + self.h1_id = int(row[0]) + async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h2,)) as c: + row = await c.fetchone() + assert row is not None + self.h2_id = int(row[0]) + + # Seed one meal per household + now = datetime.datetime.now().astimezone().isoformat() + await self.conn.execute( + "INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)", (now, self.h1_id) + ) + async with self.conn.execute("SELECT last_insert_rowid()") as c: + row = await c.fetchone() + assert row is not None + self.meal_h1 = int(row[0]) + + await self.conn.execute( + "INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)", (now, self.h2_id) + ) + async with self.conn.execute("SELECT last_insert_rowid()") as c: + row = await c.fetchone() + assert row is not None + self.meal_h2 = int(row[0]) + await self.conn.commit() + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_request_and_unrequest_meal_scoped(self): + # Request H1 meal + body = {"mealId": self.meal_h1} + r = self.client.post( + f"/api/v1/households/{self.h1}/shopping/current/meals/me", + headers=self.headers, + json=body, + ) + assert r.status_code == 200, r.text + item = r.json() + assert item["mealId"] == self.meal_h1 + + # Visible in H1 current, not in H2 + r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers) + assert r1.status_code == 200, r1.text + cur1 = r1.json() + assert any(m["mealId"] == self.meal_h1 for m in cur1["requestedMeals"]) + + r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers) + assert r2.status_code == 200, r2.text + cur2 = r2.json() + assert not any(m.get("mealId") == self.meal_h1 for m in cur2["requestedMeals"]) + + # Unrequest the meal in H1 + rd = self.client.delete( + f"/api/v1/households/{self.h1}/shopping/current/meals/{self.meal_h1}", + headers=self.headers, + ) + assert rd.status_code == 200, rd.text + assert rd.json().get("ok") is True + + # No longer visible in H1 + r1b = self.client.get( + f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers + ) + assert r1b.status_code == 200, r1b.text + cur1b = r1b.json() + assert not any(m.get("mealId") == self.meal_h1 for m in cur1b["requestedMeals"])