feat: MemberRef adoption for recipes.createdBy and shopping.purchasedBy; add TDD; update spec; keep suite green

This commit is contained in:
jableader 2025-11-01 17:53:07 +11:00
parent b324e1101c
commit 65d655851d
5 changed files with 42 additions and 7 deletions

View file

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

View file

@ -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 isnt 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).

View file

@ -2748,7 +2748,7 @@
"purchasedBy": { "purchasedBy": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
{ {
"type": "null" "type": "null"

View file

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

View file

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