diff --git a/api/shopping_models.py b/api/shopping_models.py index 2b615d4..b07a191 100644 --- a/api/shopping_models.py +++ b/api/shopping_models.py @@ -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], ) diff --git a/backend-spec.md b/backend-spec.md index 2a4d207..708eac6 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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` 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 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. 5. Database polish: - Add composite indices like `(household_id, id)` where pagination benefits (e.g., Recipe, Meal, ShoppingListItem). diff --git a/openapi.json b/openapi.json index 61b061c..719bab8 100644 --- a/openapi.json +++ b/openapi.json @@ -2748,7 +2748,7 @@ "purchasedBy": { "anyOf": [ { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/MemberRef" }, { "type": "null" diff --git a/shopping/repository.py b/shopping/repository.py index aa7cefa..7588b9e 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -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: diff --git a/tests/test_shopping_list_by_id_v2.py b/tests/test_shopping_list_by_id_v2.py index caeb55a..a1d3965 100644 --- a/tests/test_shopping_list_by_id_v2.py +++ b/tests/test_shopping_list_by_id_v2.py @@ -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(