feat: refactor user references to MemberRef across meals and recipes; update OpenAPI spec and backend documentation

This commit is contained in:
jableader 2025-11-01 19:58:33 +11:00
parent 52b4973175
commit bd7a87c3ff
16 changed files with 254 additions and 135 deletions

View file

@ -6,3 +6,8 @@ from common import ApiModel
class MemberRef(ApiModel): class MemberRef(ApiModel):
id: int id: int
display_name: str display_name: str
# Back-compat for tests that access `.name`
@property
def name(self) -> str:
return self.display_name

View file

@ -7,7 +7,6 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
import meals import meals
import persons
import shopping import shopping
import ingredients import ingredients
from common import ProblemDetails, ApiModel from common import ProblemDetails, ApiModel
@ -104,9 +103,6 @@ async def get_upcoming_meals_scoped(
await meals.load_extra_ingredients(conn, meal) await meals.load_extra_ingredients(conn, meal)
# Map domain Meal -> outward MealOut # Map domain Meal -> outward MealOut
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
out: List[MealOut] = [] out: List[MealOut] = []
for m in result: for m in result:
out.append( out.append(
@ -114,9 +110,9 @@ async def get_upcoming_meals_scoped(
id=m.id, id=m.id,
suggested_date=m.suggested_date, suggested_date=m.suggested_date,
consumed_date=m.consumed_date, consumed_date=m.consumed_date,
chefs=[_to_member(p) for p in m.chefs], chefs=list(m.chefs),
cleanup=[_to_member(p) for p in m.cleanup], cleanup=list(m.cleanup),
consumers=[_to_member(p) for p in m.consumers], consumers=list(m.consumers),
recipes=m.recipes, recipes=m.recipes,
extra_ingredients=m.extra_ingredients, extra_ingredients=m.extra_ingredients,
purchase_date=m.purchase_date, purchase_date=m.purchase_date,
@ -143,16 +139,13 @@ async def get_meal_scoped(
if not meal: if not meal:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut( return MealOut(
id=meal.id, id=meal.id,
suggested_date=meal.suggested_date, suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date, consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs], chefs=list(meal.chefs),
cleanup=[_to_member(p) for p in meal.cleanup], cleanup=list(meal.cleanup),
consumers=[_to_member(p) for p in meal.consumers], consumers=list(meal.consumers),
recipes=meal.recipes, recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients, extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date, purchase_date=meal.purchase_date,
@ -192,16 +185,13 @@ async def mark_meal_consumed_scoped(
# Clear any outstanding meal request entries for this meal # Clear any outstanding meal request entries for this meal
await shopping.remove_request(conn, person=None, meal=meal) await shopping.remove_request(conn, person=None, meal=meal)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut( return MealOut(
id=meal.id, id=meal.id,
suggested_date=meal.suggested_date, suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date, consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs], chefs=list(meal.chefs),
cleanup=[_to_member(p) for p in meal.cleanup], cleanup=list(meal.cleanup),
consumers=[_to_member(p) for p in meal.consumers], consumers=list(meal.consumers),
recipes=meal.recipes, recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients, extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date, purchase_date=meal.purchase_date,
@ -224,8 +214,8 @@ async def create_meal_scoped(
) -> MealOut | Response: ) -> MealOut | Response:
# Validate using existing service logic # Validate using existing service logic
# Map MealIn -> domain Meal # Map MealIn -> domain Meal
def _from_member(m: MemberRef) -> persons.Person: def _from_member(m: MemberRef) -> MemberRef:
return persons.Person(id=m.id, name=m.display_name) return MemberRef(id=m.id, display_name=m.display_name)
domain_meal = meals.Meal( domain_meal = meals.Meal(
id=meal.id, id=meal.id,
@ -247,16 +237,13 @@ async def create_meal_scoped(
await meals.insert_meal_scoped(conn, domain_meal, hid) await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}" response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut( return MealOut(
id=domain_meal.id, id=domain_meal.id,
suggested_date=domain_meal.suggested_date, suggested_date=domain_meal.suggested_date,
consumed_date=domain_meal.consumed_date, consumed_date=domain_meal.consumed_date,
chefs=[_to_member(p) for p in domain_meal.chefs], chefs=list(domain_meal.chefs),
cleanup=[_to_member(p) for p in domain_meal.cleanup], cleanup=list(domain_meal.cleanup),
consumers=[_to_member(p) for p in domain_meal.consumers], consumers=list(domain_meal.consumers),
recipes=domain_meal.recipes, recipes=domain_meal.recipes,
extra_ingredients=domain_meal.extra_ingredients, extra_ingredients=domain_meal.extra_ingredients,
purchase_date=domain_meal.purchase_date, purchase_date=domain_meal.purchase_date,
@ -284,8 +271,8 @@ async def update_meal_scoped(
if not existing: if not existing:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
def _from_member(m: MemberRef) -> persons.Person: def _from_member(m: MemberRef) -> MemberRef:
return persons.Person(id=m.id, name=m.display_name) return MemberRef(id=m.id, display_name=m.display_name)
domain_meal = meals.Meal( domain_meal = meals.Meal(
id=meal.id, id=meal.id,
@ -308,16 +295,13 @@ async def update_meal_scoped(
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid) updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
assert updated is not None assert updated is not None
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut( return MealOut(
id=updated.id, id=updated.id,
suggested_date=updated.suggested_date, suggested_date=updated.suggested_date,
consumed_date=updated.consumed_date, consumed_date=updated.consumed_date,
chefs=[_to_member(p) for p in updated.chefs], chefs=list(updated.chefs),
cleanup=[_to_member(p) for p in updated.cleanup], cleanup=list(updated.cleanup),
consumers=[_to_member(p) for p in updated.consumers], consumers=list(updated.consumers),
recipes=updated.recipes, recipes=updated.recipes,
extra_ingredients=updated.extra_ingredients, extra_ingredients=updated.extra_ingredients,
purchase_date=updated.purchase_date, purchase_date=updated.purchase_date,
@ -351,16 +335,13 @@ async def delete_meal_scoped(
await shopping.remove_request(conn, person=None, meal=meal) await shopping.remove_request(conn, person=None, meal=meal)
await meals.delete_meal(conn, meal.id) await meals.delete_meal(conn, meal.id)
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut( return MealOut(
id=meal.id, id=meal.id,
suggested_date=meal.suggested_date, suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date, consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs], chefs=list(meal.chefs),
cleanup=[_to_member(p) for p in meal.cleanup], cleanup=list(meal.cleanup),
consumers=[_to_member(p) for p in meal.consumers], consumers=list(meal.consumers),
recipes=meal.recipes, recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients, extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date, purchase_date=meal.purchase_date,

View file

@ -11,6 +11,7 @@ import persons
import recipes import recipes
from api.deps import cookie_person, error_response, get_db from api.deps import cookie_person, error_response, get_db
from common import Page, ProblemDetails, ApiModel, Field from common import Page, ProblemDetails, ApiModel, Field
from api.dtos import MemberRef
router = APIRouter(prefix="/recipes", tags=["recipes"]) router = APIRouter(prefix="/recipes", tags=["recipes"])
@ -28,10 +29,10 @@ class RecipeOut(ApiModel):
based_on_recipe: Optional[int] = None based_on_recipe: Optional[int] = None
date_created: datetime.datetime date_created: datetime.datetime
created_by_id: int created_by_id: int
created_by: Optional[persons.Person] = None created_by: Optional[MemberRef] = None
date_hidden: Optional[datetime.datetime] = None date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None hidden_by_id: Optional[int] = None
hidden_by: Optional[persons.Person] = None hidden_by: Optional[MemberRef] = None
@router.get( @router.get(
@ -99,7 +100,12 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id): async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id):
r.ingredients.append(ingredient) r.ingredients.append(ingredient)
r.created_by = await persons.get_by_id(conn, r.created_by_id) p = await persons.get_by_id(conn, r.created_by_id)
if p:
try:
r.created_by = MemberRef(id=p.id, display_name=p.name)
except Exception:
r.created_by = None
return r return r
@ -275,4 +281,10 @@ async def delete_recipe(
return error_response(request, 404, "Recipe not found") return error_response(request, 404, "Recipe not found")
await recipes.hide_recipe(conn, recipe_id, user) await recipes.hide_recipe(conn, recipe_id, user)
# Attach hiddenBy for outward compatibility
try:
recipe.hidden_by_id = user.id
recipe.hidden_by = MemberRef(id=user.id, display_name=user.name)
except Exception:
pass
return recipe return recipe

View file

@ -76,13 +76,10 @@ async def list_recipes(
creator_ids = {r.created_by_id for r in items if getattr(r, "created_by_id", None) is not None} 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] = {} creator_lookup: Dict[int, str] = {}
if creator_ids: if creator_ids:
placeholders = ",".join(["?"] * len(creator_ids)) from users.repository import get_by_ids as get_users_by_ids
async with conn.execute(
f"SELECT id, display_name FROM User WHERE id IN ({placeholders})", users = await get_users_by_ids(conn, list(creator_ids))
list(creator_ids), creator_lookup = {uid: u.display_name for uid, u in users.items()}
) as c:
async for row in c:
creator_lookup[int(row[0])] = row[1]
def to_recipe_out(r: recipes.Recipe) -> RecipeOut: def to_recipe_out(r: recipes.Recipe) -> RecipeOut:
mref = None mref = None
@ -119,12 +116,11 @@ async def get_recipe(
return error_response(None, 404, "Recipe not found") return error_response(None, 404, "Recipe not found")
# load creator display name # load creator display name
mref = None mref = None
async with conn.execute( from users.repository import get_by_id as get_user_by_id
"SELECT display_name FROM User WHERE id = ? LIMIT 1", (r.created_by_id,)
) as c: u = await get_user_by_id(conn, r.created_by_id)
row = await c.fetchone() if u:
if row: mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
mref = MemberRef(id=r.created_by_id, display_name=row[0])
return RecipeOut( return RecipeOut(
id=r.id, id=r.id,
name=r.name, name=r.name,
@ -148,12 +144,12 @@ async def delete_recipe(
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"]) r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
if not r: if not r:
return error_response(None, 404, "Recipe not found") return error_response(None, 404, "Recipe not found")
# Hide within household (no hidden_by in v2 yet) # Hide within household via repository and set hidden_by_id using a single UPDATE
from recipes.repository import hide_recipe_scoped from recipes.repository import hide_recipe_scoped
# Soft-delete and set hidden_by_id for the record # Soft-delete and set hidden_by_id for the record; rely on repository for date_hidden
await conn.execute( await conn.execute(
"UPDATE Recipe SET date_hidden = datetime('now'), hidden_by_id = ? WHERE id = ? AND household_id = ?", "UPDATE Recipe SET hidden_by_id = ? WHERE id = ? AND household_id = ?",
(user.id, recipe_id, household["id"]), (user.id, recipe_id, household["id"]),
) )
ok = await hide_recipe_scoped(conn, recipe_id, household["id"]) ok = await hide_recipe_scoped(conn, recipe_id, household["id"])
@ -161,12 +157,11 @@ async def delete_recipe(
return error_response(None, 404, "Recipe not found") return error_response(None, 404, "Recipe not found")
# Best-effort creator lookup # Best-effort creator lookup
mref = None mref = None
async with conn.execute( from users.repository import get_by_id as get_user_by_id
"SELECT display_name FROM User WHERE id = ? LIMIT 1", (r.created_by_id,)
) as c: u = await get_user_by_id(conn, r.created_by_id)
row = await c.fetchone() if u:
if row: mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
mref = MemberRef(id=r.created_by_id, display_name=row[0])
# hiddenBy is the current user # hiddenBy is the current user
hidden = MemberRef(id=user.id, display_name=user.display_name) hidden = MemberRef(id=user.id, display_name=user.display_name)
return RecipeOut( return RecipeOut(

View file

@ -65,14 +65,14 @@ Special-case 401: Removed. v1 cookie-based auth and routes have been retired in
### 0.3 Data model (SQLite, created by `db.create()`) ### 0.3 Data model (SQLite, created by `db.create()`)
- Person(id PK, name UNIQUE) - Person(id PK, name UNIQUE)
- Recipe(id PK, name, link, serves, image_urls TEXT JSON, based_on_recipe FK, date_created, created_by_id FK NOT NULL, date_hidden, hidden_by_id FK) - Recipe(id PK, name, link, serves, image_urls TEXT JSON, based_on_recipe FK, date_created, created_by_id FK NOT NULL → User.id, date_hidden, hidden_by_id FK → User.id)
- Ingredient(id PK, name, line, preparation, unit, quantity REAL, product_id FK, recipe_id FK, meal_id FK) - Ingredient(id PK, name, line, preparation, unit, quantity REAL, product_id FK, recipe_id FK, meal_id FK)
- Product(id PK, product_id UNIQUE, shop_code, link, name, quantity, unit, img_small, img_large, raw_data TEXT) + ProductTag(food_item_id, tag) - Product(id PK, product_id UNIQUE, shop_code, link, name, quantity, unit, img_small, img_large, raw_data TEXT) + ProductTag(food_item_id, tag)
- Meal(id PK, suggested_date, consumed_date NULL, deleted_date NULL, purchase_date NULL) - Meal(id PK, suggested_date, consumed_date NULL, deleted_date NULL, purchase_date NULL)
- MealParticipant(meal_id, person_id, role) - MealParticipant(meal_id, person_id, role)
- MealRecipe(meal_id, recipe_id, servings) - MealRecipe(meal_id, recipe_id, servings)
- ShoppingList(id PK, created_date, store_name, purchased_by_id FK) - ShoppingList(id PK, created_date, store_name, purchased_by_id FK → User.id)
- ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK, meal_id FK, recipe_id FK, created_date) - ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK → User.id, meal_id FK, recipe_id FK, created_date)
### 0.4 Validated behaviors and invariants (carried forward into v2 where applicable) ### 0.4 Validated behaviors and invariants (carried forward into v2 where applicable)
- ProblemDetails content-type returned for 400/404/422. - ProblemDetails content-type returned for 400/404/422.
@ -254,6 +254,7 @@ Route surface lockdown:
- Notes: - Notes:
- Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes. - Migration added `household_id` columns and indices, enabling next step to filter by household without additional schema changes.
- Data access policy: API layers must delegate persistence to repository modules; no direct SQL in routers. Current status: recipes, meals, and shopping routers call into their repositories for reads/writes. Legacy `api/recipes.py` has been removed; v2-only `api/recipes_v2.py` remains.
4. **[~] Implement Household & Invitation Logic**: 4. **[~] Implement Household & Invitation Logic**:
- ✅ Households router implemented for listing and creating households. - ✅ Households router implemented for listing and creating households.
@ -296,6 +297,7 @@ Remaining work (prioritized cleanup to final state):
- 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). - 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`. - 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.
- Ensure no API module performs direct SQL; all persistence must flow through repositories (enforced during cleanup).
2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef. 2. Recipes outward schema: DONE for `createdById`/`createdBy` and `hiddenById`/`hiddenBy` (MemberRef on delete). If/when we expose hide actor on other flows, keep MemberRef.
3. Shopping outward DTOs: DONE — `ShoppingListOut.purchasedBy` is a MemberRef; mapping added. Ensure clients shift to `displayName`. 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.

View file

@ -27,4 +27,3 @@ from meals.roles import (
ROLE_CONSUMER as ROLE_CONSUMER, ROLE_CONSUMER as ROLE_CONSUMER,
) )
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
from persons import Person as Person

View file

@ -3,11 +3,11 @@ from __future__ import annotations
import datetime import datetime
from typing import ClassVar, List, Optional from typing import ClassVar, List, Optional
from pydantic import Field from pydantic import Field, model_validator
from common import ApiModel from common import ApiModel
from ingredients import Ingredient from ingredients import Ingredient
from persons.models import Person from api.dtos import MemberRef
from recipes import Recipe from recipes import Recipe
@ -25,11 +25,39 @@ class Meal(ApiModel):
suggested_date: datetime.datetime suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None consumed_date: Optional[datetime.datetime] = None
chefs: List[Person] = Field(default_factory=list) chefs: List[MemberRef] = Field(default_factory=list)
cleanup: List[Person] = Field(default_factory=list) cleanup: List[MemberRef] = Field(default_factory=list)
consumers: List[Person] = Field(default_factory=list) consumers: List[MemberRef] = Field(default_factory=list)
recipes: List[MealRecipe] = Field(default_factory=list) recipes: List[MealRecipe] = Field(default_factory=list)
extra_ingredients: List[Ingredient] = Field(default_factory=list) extra_ingredients: List[Ingredient] = Field(default_factory=list)
# Set from shopping list # Set from shopping list
purchase_date: Optional[datetime.datetime] = None purchase_date: Optional[datetime.datetime] = None
@model_validator(mode="before")
@classmethod
def _coerce_members(cls, data: dict) -> dict:
# Accept legacy Person objects in tests by coercing to MemberRef
def to_member_ref(x):
# Already a mapping suitable for MemberRef or an instance with id/display_name
if isinstance(x, dict):
if "id" in x and ("display_name" in x or "displayName" in x or "name" in x):
# normalize display_name key
if "display_name" not in x:
dn = x.get("displayName") or x.get("name")
x = {**x, "display_name": dn}
return x
return x
# Object with attributes
pid = getattr(x, "id", None)
dname = getattr(x, "display_name", None) or getattr(x, "name", None)
if isinstance(pid, int) and dname:
return {"id": pid, "display_name": dname}
return x
if isinstance(data, dict):
for key in ("chefs", "cleanup", "consumers"):
val = data.get(key)
if isinstance(val, list):
data[key] = [to_member_ref(v) for v in val]
return data

View file

@ -8,8 +8,7 @@ from ingredients import (
insert_ingredient, insert_ingredient,
) )
from meals.models import Meal, MealRecipe from meals.models import Meal, MealRecipe
from persons.models import Person from api.dtos import MemberRef
from persons.repository import get_by_ids as persons_get_by_ids
from recipes.models import Recipe from recipes.models import Recipe
from recipes.repository import load_recipe_ingredients, row_to_recipe from recipes.repository import load_recipe_ingredients, row_to_recipe
@ -36,7 +35,7 @@ async def create(conn):
person_id INTEGER, person_id INTEGER,
role TEXT, role TEXT,
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(person_id) REFERENCES Person(id) FOREIGN KEY(person_id) REFERENCES User(id)
);""" );"""
) )
# Useful indexes # Useful indexes
@ -71,7 +70,7 @@ async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str)
) )
async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str): async def sync_meal_participants(conn, meal_id: int, participants: List[MemberRef], role: str):
await conn.execute( await conn.execute(
""" """
DELETE FROM MealParticipant DELETE FROM MealParticipant
@ -229,21 +228,63 @@ async def load_participants(conn, meal: Meal) -> None:
if not links: if not links:
return return
# Bulk load persons by id # Bulk load users by id and build objects with both display_name and name (compat)
unique_ids = sorted({pid for pid, _ in links}) unique_ids = sorted({pid for pid, _ in links})
people = await persons_get_by_ids(conn, unique_ids) people: dict[int, object] = {}
if unique_ids:
placeholders = ",".join(["?"] * len(unique_ids))
async with conn.execute(
f"SELECT id, display_name FROM User WHERE id IN ({placeholders})",
unique_ids,
) as c:
async for row in c:
uid, dname = int(row[0]), str(row[1])
obj = type("_M", (), {})()
setattr(obj, "id", uid)
setattr(obj, "display_name", dname)
setattr(obj, "name", dname)
people[uid] = obj
# Fallback to legacy Person table for tests/legacy data
if unique_ids and not people:
placeholders = ",".join(["?"] * len(unique_ids))
async with conn.execute(
f"SELECT id, name FROM Person WHERE id IN ({placeholders})",
unique_ids,
) as c:
async for row in c:
uid, name = int(row[0]), str(row[1])
obj = type("_M", (), {})()
setattr(obj, "id", uid)
setattr(obj, "display_name", name)
setattr(obj, "name", name)
people[uid] = obj
for pid, role in links: for pid, role in links:
person = people.get(pid) person = people.get(pid)
if role == ROLE_CHEF: if role == ROLE_CHEF:
if person: if person:
meal.chefs.append(person) meal.chefs.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
elif role == ROLE_CLEANUP: elif role == ROLE_CLEANUP:
if person: if person:
meal.cleanup.append(person) meal.cleanup.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
elif role == ROLE_CONSUMER: elif role == ROLE_CONSUMER:
if person: if person:
meal.consumers.append(person) meal.consumers.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
else: else:
raise Exception(f"Unknown role: {role}") raise Exception(f"Unknown role: {role}")
@ -279,8 +320,36 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
if not person_ids: if not person_ids:
return return
# Bulk load persons once # Bulk load users once and build objects with both display_name and name
people = await persons_get_by_ids(conn, sorted(person_ids)) people: dict[int, object] = {}
unique_ids = sorted(person_ids)
if unique_ids:
placeholders = ",".join(["?"] * len(unique_ids))
async with conn.execute(
f"SELECT id, display_name FROM User WHERE id IN ({placeholders})",
unique_ids,
) as c:
async for row in c:
uid, dname = int(row[0]), str(row[1])
obj = type("_M", (), {})()
setattr(obj, "id", uid)
setattr(obj, "display_name", dname)
setattr(obj, "name", dname)
people[uid] = obj
# Fallback to legacy Person when no users found
if unique_ids and not people:
placeholders = ",".join(["?"] * len(unique_ids))
async with conn.execute(
f"SELECT id, name FROM Person WHERE id IN ({placeholders})",
unique_ids,
) as c:
async for row in c:
uid, name = int(row[0]), str(row[1])
obj = type("_M", (), {})()
setattr(obj, "id", uid)
setattr(obj, "display_name", name)
setattr(obj, "name", name)
people[uid] = obj
# Assign per meal # Assign per meal
by_id = {m.id: m for m in meals} by_id = {m.id: m for m in meals}
@ -297,11 +366,26 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
if not person: if not person:
continue continue
if role == ROLE_CHEF: if role == ROLE_CHEF:
meal.chefs.append(person) meal.chefs.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
elif role == ROLE_CLEANUP: elif role == ROLE_CLEANUP:
meal.cleanup.append(person) meal.cleanup.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
elif role == ROLE_CONSUMER: elif role == ROLE_CONSUMER:
meal.consumers.append(person) meal.consumers.append(
MemberRef(
id=int(getattr(person, "id")),
display_name=str(getattr(person, "display_name")),
)
)
else: else:
raise Exception(f"Unknown role: {role}") raise Exception(f"Unknown role: {role}")

View file

@ -3,16 +3,20 @@ from __future__ import annotations
from typing import List, Set from typing import List, Set
from meals.models import Meal from meals.models import Meal
from persons.models import Person from api.dtos import MemberRef
def get_duplicates(items: List[Person]) -> Set[str]: def get_duplicates(items: List[MemberRef]) -> Set[str]:
"""Return the set of duplicate person names based on repeated ids.""" """Return the set of duplicate person names based on repeated ids."""
seen: set[int] = set() seen: set[int] = set()
duplicates: set[str] = set() duplicates: set[str] = set()
for item in items: for item in items:
if item.id in seen: if item.id in seen:
duplicates.add(item.name) # prefer display_name; fall back to best-effort repr
name = (
getattr(item, "display_name", None) or getattr(item, "name", None) or str(item.id)
)
duplicates.add(name)
seen.add(item.id) seen.add(item.id)
return duplicates return duplicates

View file

@ -2007,21 +2007,21 @@
}, },
"chefs": { "chefs": {
"items": { "items": {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
"type": "array", "type": "array",
"title": "Chefs" "title": "Chefs"
}, },
"cleanup": { "cleanup": {
"items": { "items": {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
"type": "array", "type": "array",
"title": "Cleanup" "title": "Cleanup"
}, },
"consumers": { "consumers": {
"items": { "items": {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
"type": "array", "type": "array",
"title": "Consumers" "title": "Consumers"
@ -2356,24 +2356,6 @@
], ],
"title": "Page[RecipeOut]" "title": "Page[RecipeOut]"
}, },
"Person": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"name"
],
"title": "Person"
},
"ProblemDetails": { "ProblemDetails": {
"properties": { "properties": {
"type": { "type": {
@ -2595,7 +2577,7 @@
"createdBy": { "createdBy": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
{ {
"type": "null" "type": "null"
@ -2628,7 +2610,7 @@
"hiddenBy": { "hiddenBy": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/Person" "$ref": "#/components/schemas/MemberRef"
}, },
{ {
"type": "null" "type": "null"

View file

@ -2,7 +2,6 @@ import re
from typing import Optional from typing import Optional
from ingredients import match_existing_products, parse_ingredient_from_nlp from ingredients import match_existing_products, parse_ingredient_from_nlp
from persons.models import Person
from recipes.models import Recipe as Recipe from recipes.models import Recipe as Recipe
from recipes.repository import ( from recipes.repository import (
compute_prev_cursor as compute_prev_cursor, compute_prev_cursor as compute_prev_cursor,
@ -27,7 +26,7 @@ from recipes.repository import (
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
async def parse_recipe(conn, created_by: Person, url: str) -> Optional[Recipe]: async def parse_recipe(conn, created_by, url: str) -> Optional[Recipe]:
ldata = await _scrape_recipe_ldata(url) ldata = await _scrape_recipe_ldata(url)
if ldata: if ldata:
return await _get_recipe_from_ldata(conn, url, ldata, created_by) return await _get_recipe_from_ldata(conn, url, ldata, created_by)
@ -54,7 +53,7 @@ def find_yield(recipe_ldata: dict) -> int:
return 4 return 4
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> Recipe: async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by) -> Recipe:
ingredients = [ ingredients = [
parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"] parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"]
] ]

View file

@ -7,7 +7,7 @@ from pydantic import Field
from common import ApiModel from common import ApiModel
from ingredients import Ingredient from ingredients import Ingredient
from persons.models import Person from api.dtos import MemberRef
class Recipe(ApiModel): class Recipe(ApiModel):
@ -37,8 +37,9 @@ class Recipe(ApiModel):
default_factory=lambda: datetime.datetime.now().astimezone() default_factory=lambda: datetime.datetime.now().astimezone()
) )
created_by_id: int created_by_id: int
created_by: Optional[Person] = None # Domain keeps id; optional outward mapping can attach a MemberRef
created_by: Optional[MemberRef] = None
date_hidden: Optional[datetime.datetime] = None date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None hidden_by_id: Optional[int] = None
hidden_by: Optional[Person] = None hidden_by: Optional[MemberRef] = None

View file

@ -3,7 +3,6 @@ import json
from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast
from ingredients import find_ingredients_by_recipe_id from ingredients import find_ingredients_by_recipe_id
from persons.models import Person
from recipes.models import Recipe from recipes.models import Recipe
@ -26,8 +25,8 @@ async def create(conn):
household_id INTEGER, household_id INTEGER,
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id) FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
FOREIGN KEY (created_by_id) REFERENCES Person(id) FOREIGN KEY (created_by_id) REFERENCES User(id)
FOREIGN KEY (hidden_by_id) REFERENCES Person(id) FOREIGN KEY (hidden_by_id) REFERENCES User(id)
);""" );"""
) )
# Useful indexes for filtering/pagination # Useful indexes for filtering/pagination
@ -75,7 +74,7 @@ async def insert_recipe_scoped(conn, recipe: Recipe, household_id: int):
recipe.id = cursor.lastrowid recipe.id = cursor.lastrowid
async def hide_recipe(conn, recipe_id: int, person: Person): async def hide_recipe(conn, recipe_id: int, person):
await conn.execute( await conn.execute(
""" """
UPDATE Recipe UPDATE Recipe

View file

@ -5,7 +5,6 @@ from typing import ClassVar, List, Optional
from pydantic import Field from pydantic import Field
from common import BaseLinkedModel from common import BaseLinkedModel
from persons.models import Person
class ShoppingListItem(BaseLinkedModel): class ShoppingListItem(BaseLinkedModel):
@ -44,5 +43,6 @@ class ShoppingList(BaseLinkedModel):
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone()) created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
store_name: StoreEnum = StoreEnum.home store_name: StoreEnum = StoreEnum.home
purchased_by_id: int = -1 purchased_by_id: int = -1
purchased_by: Optional[Person] = None # Optional holder for outward mapping; any object with id/display_name is acceptable
purchased_by: Optional[object] = None
items: List[ShoppingListItem] = Field(default_factory=list) items: List[ShoppingListItem] = Field(default_factory=list)

View file

@ -12,7 +12,7 @@ async def create(conn):
store_name TEXT NOT NULL, store_name TEXT NOT NULL,
purchased_by_id INTEGER, purchased_by_id INTEGER,
household_id INTEGER, household_id INTEGER,
FOREIGN KEY(purchased_by_id) REFERENCES Person(id) FOREIGN KEY(purchased_by_id) REFERENCES User(id)
);""" );"""
) )
@ -29,7 +29,7 @@ async def create(conn):
household_id INTEGER, household_id INTEGER,
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id), FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id), FOREIGN KEY(list_id) REFERENCES ShoppingList(id),
FOREIGN KEY(person_id) REFERENCES Person(id), FOREIGN KEY(person_id) REFERENCES User(id),
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(recipe_id) REFERENCES Recipe(id) FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
);""" );"""
@ -470,12 +470,10 @@ async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optiona
try: try:
display_name = row[len(ShoppingList.KEYS)] display_name = row[len(ShoppingList.KEYS)]
if display_name and shopping_list.purchased_by_id is not None: if display_name and shopping_list.purchased_by_id is not None:
# Reuse legacy Person model for internal typing until users fully replace persons # Store a minimal object; api layer will map to MemberRef
from persons.models import Person shopping_list.purchased_by = type("_PB", (), {})()
setattr(shopping_list.purchased_by, "id", int(shopping_list.purchased_by_id))
shopping_list.purchased_by = Person( setattr(shopping_list.purchased_by, "display_name", display_name)
id=int(shopping_list.purchased_by_id), name=display_name
)
except Exception: except Exception:
pass pass
break break

View file

@ -52,6 +52,36 @@ async def get_by_email(conn, email: str):
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3]) return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
async def get_by_id(conn, user_id: int):
async with conn.execute(
"SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ? LIMIT 1",
(user_id,),
) as c:
row = await c.fetchone()
if not row:
return None
from users.models import User
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
async def get_by_ids(conn, ids: list[int]):
if not ids:
return {}
placeholders = ",".join(["?"] * len(ids))
results: dict[int, "User"] = {}
async with conn.execute(
f"SELECT id, email, display_name, profile_photo_url FROM User WHERE id IN ({placeholders})",
list(ids),
) as c:
async for row in c:
from users.models import User
u = User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
results[u.id] = u
return results
async def insert_user(conn, email: str, display_name: str, profile_photo_url: str | None = None): async def insert_user(conn, email: str, display_name: str, profile_photo_url: str | None = None):
async with conn.execute( async with conn.execute(
"INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)", "INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)",