feat: MemberRef adoption for recipes.createdBy and shopping.purchasedBy; add TDD; update spec; keep suite green
This commit is contained in:
parent
b324e1101c
commit
65d655851d
5 changed files with 42 additions and 7 deletions
|
|
@ -6,7 +6,7 @@ from enum import Enum
|
||||||
|
|
||||||
import ingredients
|
import ingredients
|
||||||
import meals
|
import meals
|
||||||
import persons
|
from api.dtos import MemberRef
|
||||||
import recipes
|
import recipes
|
||||||
import shopping
|
import shopping
|
||||||
from common import ApiModel, Field
|
from common import ApiModel, Field
|
||||||
|
|
@ -61,7 +61,7 @@ class ShoppingListOut(ApiModel):
|
||||||
# outward-only enum values: include "home" instead of an empty string
|
# outward-only enum values: include "home" instead of an empty string
|
||||||
store_name: Literal["woolworths", "coles", "home"]
|
store_name: Literal["woolworths", "coles", "home"]
|
||||||
purchased_by_id: int
|
purchased_by_id: int
|
||||||
purchased_by: persons.Person | None = None
|
purchased_by: MemberRef | None = None
|
||||||
# Make items required in the schema; callers must always send an array (possibly empty)
|
# Make items required in the schema; callers must always send an array (possibly empty)
|
||||||
items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
||||||
|
|
||||||
|
|
@ -120,11 +120,24 @@ def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut:
|
||||||
else:
|
else:
|
||||||
# Remaining enum values are 'woolworths' or 'coles'
|
# Remaining enum values are 'woolworths' or 'coles'
|
||||||
outward_store = cast(Literal["woolworths", "coles"], sl.store_name.value)
|
outward_store = cast(Literal["woolworths", "coles"], sl.store_name.value)
|
||||||
|
# Map purchased_by if present: expect ShoppingList.purchased_by to carry display_name if available
|
||||||
|
mref = None
|
||||||
|
if sl.purchased_by is not None:
|
||||||
|
try:
|
||||||
|
# sl.purchased_by may be a lightweight object with id/name
|
||||||
|
pid = getattr(sl.purchased_by, "id", None)
|
||||||
|
pname = getattr(sl.purchased_by, "name", None) or getattr(
|
||||||
|
sl.purchased_by, "display_name", None
|
||||||
|
)
|
||||||
|
if isinstance(pid, int) and pname:
|
||||||
|
mref = MemberRef(id=pid, display_name=pname)
|
||||||
|
except Exception:
|
||||||
|
mref = None
|
||||||
return ShoppingListOut(
|
return ShoppingListOut(
|
||||||
id=sl.id,
|
id=sl.id,
|
||||||
created_date=sl.created_date,
|
created_date=sl.created_date,
|
||||||
store_name=outward_store,
|
store_name=outward_store,
|
||||||
purchased_by_id=sl.purchased_by_id,
|
purchased_by_id=sl.purchased_by_id,
|
||||||
purchased_by=sl.purchased_by,
|
purchased_by=mref,
|
||||||
items=[_to_ingredient_item(i) for i in sl.items],
|
items=[_to_ingredient_item(i) for i in sl.items],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ Repositories accept `household_id` and filter by it across `meals`, `recipes`, a
|
||||||
Status summary:
|
Status summary:
|
||||||
- Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green.
|
- Implemented: JWT auth v2 with refresh cookie; household domains and membership; invitations; full household scoping across recipes/meals/shopping; OpenAPI augmentation with bearerAuth and 403; RFC7807 preserved; tests green.
|
||||||
- DTO alignment: Meals use MemberRef { id, displayName } (no Person in outward schema). MemberRef consolidated in `api/dtos.py`. Shopping DTOs/mappers consolidated in `api/shopping_models.py`.
|
- DTO alignment: Meals use MemberRef { id, displayName } (no Person in outward schema). MemberRef consolidated in `api/dtos.py`. Shopping DTOs/mappers consolidated in `api/shopping_models.py`.
|
||||||
|
- DTO alignment (v2): Meals use MemberRef; Recipes include `createdById` and `createdBy` (MemberRef); Shopping `purchasedBy` is now a MemberRef on outward lists.
|
||||||
- Security: Password hashing now prefers Argon2 for new accounts with PBKDF2 verification fallback.
|
- Security: Password hashing now prefers Argon2 for new accounts with PBKDF2 verification fallback.
|
||||||
- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
|
- New: Household members listing `GET /api/v1/households/{householdSlug}/members` returning [{ id, displayName, role }].
|
||||||
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home").
|
- Preserved: camelCase responses, `Page<T>` semantics, `Location` headers on create, shopping storeName normalization ("home").
|
||||||
|
|
@ -286,7 +287,7 @@ Remaining work (prioritized cleanup to final state):
|
||||||
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
|
- Replace internal usages with `user_id`/`MemberRef` where outward, and update repository DDL to stop referencing `Person`.
|
||||||
- Remove `api.deps.cookie_person` once no tests or code depend on it.
|
- Remove `api.deps.cookie_person` once no tests or code depend on it.
|
||||||
2. Recipes outward schema: DONE for `createdById`/`createdBy` (MemberRef). Remaining: add `hiddenBy` (MemberRef) when hide flows are wired to users.
|
2. Recipes outward schema: DONE for `createdById`/`createdBy` (MemberRef). Remaining: add `hiddenBy` (MemberRef) when hide flows are wired to users.
|
||||||
3. Shopping outward DTOs: change `ShoppingListOut.purchasedBy` from `Person` to `MemberRef` and adjust mapping code in `api/shopping_models.py`; ensure OpenAPI reflects the new shape. Consider exposing only `purchasedById` initially if membership lookup isn’t available.
|
3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`.
|
||||||
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers.
|
4. Delete or port legacy v1 test modules that are currently skipped (`tests/test_main.py`, `tests/test_v1.py`). Either migrate assertions to v2 routes or remove them so the full test run has no skips. Then remove the last vestiges of v1-only helpers.
|
||||||
5. Database polish:
|
5. Database polish:
|
||||||
- Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem).
|
- Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem).
|
||||||
|
|
|
||||||
|
|
@ -2748,7 +2748,7 @@
|
||||||
"purchasedBy": {
|
"purchasedBy": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Person"
|
"$ref": "#/components/schemas/MemberRef"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
|
|
|
||||||
|
|
@ -482,14 +482,30 @@ async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optiona
|
||||||
shopping_list: Optional[ShoppingList] = None
|
shopping_list: Optional[ShoppingList] = None
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList
|
SELECT {",".join(ShoppingList.KEYS)}, (
|
||||||
|
SELECT display_name FROM User u WHERE u.id = ShoppingList.purchased_by_id
|
||||||
|
) as purchased_by_name
|
||||||
|
FROM ShoppingList
|
||||||
WHERE id = ? AND household_id = ?
|
WHERE id = ? AND household_id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(id, household_id),
|
(id, household_id),
|
||||||
) as cursor:
|
) as cursor:
|
||||||
async for row in cursor:
|
async for row in cursor:
|
||||||
shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)})
|
base = {k: v for k, v in zip(ShoppingList.KEYS, row[: len(ShoppingList.KEYS)])}
|
||||||
|
shopping_list = ShoppingList(**base)
|
||||||
|
# Attach a lightweight purchased_by with display_name if available
|
||||||
|
try:
|
||||||
|
display_name = row[len(ShoppingList.KEYS)]
|
||||||
|
if display_name and shopping_list.purchased_by_id is not None:
|
||||||
|
# Reuse legacy Person model for internal typing until users fully replace persons
|
||||||
|
from persons.models import Person
|
||||||
|
|
||||||
|
shopping_list.purchased_by = Person(
|
||||||
|
id=int(shopping_list.purchased_by_id), name=display_name
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
if shopping_list:
|
if shopping_list:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,11 @@ class TestShoppingListByIdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
body = resp_ok.json()
|
body = resp_ok.json()
|
||||||
assert body["list"]["id"] == self.h1_list_id
|
assert body["list"]["id"] == self.h1_list_id
|
||||||
assert len(body["list"]["items"]) == 1
|
assert len(body["list"]["items"]) == 1
|
||||||
|
# purchasedBy is included as MemberRef
|
||||||
|
assert "purchasedBy" in body["list"]
|
||||||
|
pb = body["list"]["purchasedBy"]
|
||||||
|
assert isinstance(pb, dict)
|
||||||
|
assert "id" in pb and "displayName" in pb
|
||||||
|
|
||||||
# Cross-household should 404
|
# Cross-household should 404
|
||||||
resp_404 = self.client.get(
|
resp_404 = self.client.get(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue