diff --git a/api/recipes_v2.py b/api/recipes_v2.py index 3566723..26fb68b 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -1,14 +1,15 @@ from __future__ import annotations -from typing import List, Optional +from typing import Dict, List, Optional import aiosqlite from fastapi import APIRouter, Depends, Query, Response import ingredients as ingredients_mod import recipes -from api.deps import error_response, get_db, get_household_from_slug +from api.deps import error_response, get_db, get_household_from_slug, get_current_user from common import Page, ProblemDetails, ApiModel, Field +from api.dtos import MemberRef router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"]) @@ -22,6 +23,8 @@ class RecipeOut(ApiModel): ingredients: List[ingredients_mod.Ingredient] = Field( min_length=0, json_schema_extra={"minItems": 0} ) + created_by_id: int + created_by: Optional[MemberRef] = None class RecipeCreate(ApiModel): @@ -67,11 +70,40 @@ async def list_recipes( by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids) for r in items: r.ingredients = by_recipe.get(r.id, []) + # Lookup creators' display names + creator_ids = {r.created_by_id for r in items if getattr(r, "created_by_id", None) is not None} + creator_lookup: Dict[int, str] = {} + if creator_ids: + placeholders = ",".join(["?"] * len(creator_ids)) + async with conn.execute( + f"SELECT id, display_name FROM User WHERE id IN ({placeholders})", + list(creator_ids), + ) as c: + async for row in c: + creator_lookup[int(row[0])] = row[1] + + def to_recipe_out(r: recipes.Recipe) -> RecipeOut: + mref = None + name = creator_lookup.get(r.created_by_id) + if name is not None: + mref = MemberRef(id=r.created_by_id, display_name=name) + return RecipeOut( + id=r.id, + name=r.name, + link=r.link, + serves=r.serves, + image_urls=r.image_urls, + ingredients=r.ingredients, + created_by_id=r.created_by_id, + created_by=mref, + ) + next_cursor = str(items[-1].id) if has_more and items else None total = await ( recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid) ) - return Page(items=items, nextCursor=next_cursor, prevCursor=None, total=total) + outward_items = [to_recipe_out(r) for r in items] + return Page(items=outward_items, nextCursor=next_cursor, prevCursor=None, total=total) @router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) @@ -83,7 +115,24 @@ async def get_recipe( r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) if not r: return error_response(None, 404, "Recipe not found") - return r + # load creator display name + mref = None + async with conn.execute( + "SELECT display_name FROM User WHERE id = ? LIMIT 1", (r.created_by_id,) + ) as c: + row = await c.fetchone() + if row: + mref = MemberRef(id=r.created_by_id, display_name=row[0]) + return RecipeOut( + id=r.id, + name=r.name, + link=r.link, + serves=r.serves, + image_urls=r.image_urls, + ingredients=r.ingredients, + created_by_id=r.created_by_id, + created_by=mref, + ) @router.delete("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}}) @@ -102,7 +151,24 @@ async def delete_recipe( ok = await hide_recipe_scoped(conn, recipe_id, household["id"]) if not ok: return error_response(None, 404, "Recipe not found") - return r + # Best-effort creator lookup + mref = None + async with conn.execute( + "SELECT display_name FROM User WHERE id = ? LIMIT 1", (r.created_by_id,) + ) as c: + row = await c.fetchone() + if row: + mref = MemberRef(id=r.created_by_id, display_name=row[0]) + return RecipeOut( + id=r.id, + name=r.name, + link=r.link, + serves=r.serves, + image_urls=r.image_urls, + ingredients=r.ingredients, + created_by_id=r.created_by_id, + created_by=mref, + ) @router.post("", response_model=RecipeOut, responses={400: {"model": ProblemDetails}}) @@ -110,12 +176,13 @@ async def create_recipe( recipe: RecipeCreate, response: Response, household=Depends(get_household_from_slug), + user=Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db), ): if not recipe.ingredients: return error_response(None, 400, "Recipe must have at least one ingredient") hid = household["id"] - # v1 Recipe model requires created_by_id; use 0 placeholder until users replace persons + # Set creator to the current user r = recipes.Recipe( id=-1, name=recipe.name, @@ -123,7 +190,7 @@ async def create_recipe( serves=recipe.serves, image_urls=recipe.image_urls, ingredients=list(recipe.ingredients), - created_by_id=0, + created_by_id=user.id, ) await recipes.insert_recipe_scoped(conn, r, hid) for ingredient in recipe.ingredients: @@ -132,4 +199,14 @@ async def create_recipe( ingredient.product_id = ingredient.product.id await ingredients_mod.insert_ingredient(conn, ingredient) response.headers["Location"] = f"/api/v1/households/{household['slug']}/recipes/{r.id}" - return r + created = MemberRef(id=user.id, display_name=user.display_name) + return RecipeOut( + id=r.id, + name=r.name, + link=r.link, + serves=r.serves, + image_urls=r.image_urls, + ingredients=r.ingredients, + created_by_id=r.created_by_id, + created_by=created, + ) diff --git a/backend-spec.md b/backend-spec.md index 671d3a5..2a4d207 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -278,14 +278,14 @@ Status summary: - 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"). - Codebase cleanup: v2 routers are inlined as canonical modules; legacy `*_v2.py` files removed. v1 cookie auth and routers are not mounted. - - Current v2 recipes shape: `createdBy`/`hiddenBy` are currently omitted from outward Recipe responses; they will be reintroduced (as MemberRef) once persons are fully retired. + - Current v2 recipes shape: `createdById` and `createdBy` (MemberRef) are included in outward Recipe responses. `hiddenBy` will be added when hide flows are wired to users. Remaining work (prioritized cleanup to final state): 1. Remove the `persons/` package and all code references across domains (recipes, meals, shopping). Replace `Person` with `users`/HouseholdMember everywhere: - Code hotspots today: `recipes/models.py` (imports Person), `recipes/repository.py` (FKs, hide_recipe signature), `meals/models.py` (participants as List[Person]), `api/shopping_models.py` and `shopping/models.py` (ShoppingList.purchased_by typed as Person), and `shopping/repository.py` (FKs to 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. -2. Recipes outward schema: add `createdBy`/`hiddenBy` back as `MemberRef`-style DTOs (user/member) so the API exposes user info without `Person`. Populate from the authenticated user and (when hiding) the acting member; keep fields optional. +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. 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: diff --git a/openapi.json b/openapi.json index c953f89..61b061c 100644 --- a/openapi.json +++ b/openapi.json @@ -2615,6 +2615,20 @@ "type": "array", "minItems": 0, "title": "Ingredients" + }, + "createdById": { + "type": "integer", + "title": "Createdbyid" + }, + "createdBy": { + "anyOf": [ + { + "$ref": "#/components/schemas/MemberRef" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -2623,7 +2637,8 @@ "link", "serves", "imageUrls", - "ingredients" + "ingredients", + "createdById" ], "title": "RecipeOut" }, diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py index 01172e7..38c1797 100644 --- a/tests/test_recipes_household_v2.py +++ b/tests/test_recipes_household_v2.py @@ -66,12 +66,21 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase): ) assert r.status_code == 200, r.text rid = r.json()["id"] + # createdBy fields present and match current user + created_by = r.json().get("createdBy") + assert created_by is not None + assert isinstance(created_by.get("id"), int) + assert created_by.get("displayName") == "R" + assert r.json().get("createdById") == created_by["id"] # List H1 should include r = self.client.get(f"/api/v1/households/{self.h1}/recipes", headers=self.headers) assert r.status_code == 200 items = r.json()["items"] assert any(it["id"] == rid for it in items) + # list items include createdBy + found = next(it for it in items if it["id"] == rid) + assert "createdBy" in found and "createdById" in found # List H2 should not include r = self.client.get(f"/api/v1/households/{self.h2}/recipes", headers=self.headers) @@ -108,6 +117,8 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase): ) assert r.status_code == 200, r.text rid = r.json()["id"] + # createdBy present + assert "createdBy" in r.json() and "createdById" in r.json() # Delete it via v2 scoped route r2 = self.client.delete(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers)