feat(shopping-v2): add scoped meal request/unrequest; tests green; openapi updated
This commit is contained in:
parent
59c08ecb84
commit
807f3efaaa
5 changed files with 334 additions and 0 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
121
openapi.json
121
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": {
|
"/healthz": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Healthz",
|
"summary": "Healthz",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
111
tests/test_shopping_request_meal_v2.py
Normal file
111
tests/test_shopping_request_meal_v2.py
Normal 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"])
|
||||||
Loading…
Reference in a new issue