feat(shopping-v2): add scoped meal request/unrequest; tests green; openapi updated

This commit is contained in:
jableader 2025-11-01 15:40:45 +11:00
parent 59c08ecb84
commit 807f3efaaa
5 changed files with 334 additions and 0 deletions

View file

@ -6,6 +6,7 @@ import aiosqlite
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
import shopping 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.deps import error_response, get_db, get_household_from_slug, get_current_user
from api.shopping import ( from api.shopping import (
CurrentShoppingList, CurrentShoppingList,
@ -13,6 +14,7 @@ from api.shopping import (
_to_ingredient_item, _to_ingredient_item,
_to_meal_item, _to_meal_item,
_to_shopping_list_out, _to_shopping_list_out,
RequestedMealItem,
PurchaseListIn, PurchaseListIn,
) )
@ -145,3 +147,53 @@ async def purchase_ingredients_scoped(
recipes_lookup=recipes_lookup, recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_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()

View file

@ -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": { "/healthz": {
"get": { "get": {
"summary": "Healthz", "summary": "Healthz",

View file

@ -10,6 +10,8 @@ from shopping.repository import (
get_purchased_ingredients as _get_purchased_ingredients, get_purchased_ingredients as _get_purchased_ingredients,
get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped, get_purchased_ingredients_scoped as _get_purchased_ingredients_scoped,
is_requested as is_requested, 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 as load_shopping_list,
load_shopping_list_scoped as load_shopping_list_scoped, load_shopping_list_scoped as load_shopping_list_scoped,
purchase as purchase, purchase as purchase,

View file

@ -292,6 +292,18 @@ async def is_requested(conn, meal) -> bool:
return row[0] > 0 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( async def request(
conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None
) -> ShoppingListItem: ) -> ShoppingListItem:
@ -331,6 +343,31 @@ async def request(
return item 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( async def remove_request(
conn, conn,
person: Optional[Any] = None, person: Optional[Any] = None,
@ -360,6 +397,17 @@ async def remove_request(
raise ValueError("Must specify either a meal or an ingredient to remove") 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]: async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]

View file

@ -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"])