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 meals
import persons
from api.dtos import MemberRef
import recipes
import shopping
from common import ApiModel, Field
@ -61,7 +61,7 @@ class ShoppingListOut(ApiModel):
# outward-only enum values: include "home" instead of an empty string
store_name: Literal["woolworths", "coles", "home"]
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)
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:
# Remaining enum values are 'woolworths' or 'coles'
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(
id=sl.id,
created_date=sl.created_date,
store_name=outward_store,
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],
)

View file

@ -274,6 +274,7 @@ Repositories accept `household_id` and filter by it across `meals`, `recipes`, a
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.
- 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.
- 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").
@ -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`.
- 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.
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.
5. Database polish:
- Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem).

View file

@ -2748,7 +2748,7 @@
"purchasedBy": {
"anyOf": [
{
"$ref": "#/components/schemas/Person"
"$ref": "#/components/schemas/MemberRef"
},
{
"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
async with conn.execute(
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 = ?
LIMIT 1
""",
(id, household_id),
) as 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
if shopping_list:

View file

@ -107,6 +107,11 @@ class TestShoppingListByIdV2(unittest.IsolatedAsyncioTestCase):
body = resp_ok.json()
assert body["list"]["id"] == self.h1_list_id
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
resp_404 = self.client.get(