feat: refactor user references to MemberRef across meals and recipes; update OpenAPI spec and backend documentation
This commit is contained in:
parent
52b4973175
commit
bd7a87c3ff
16 changed files with 254 additions and 135 deletions
|
|
@ -6,3 +6,8 @@ from common import ApiModel
|
|||
class MemberRef(ApiModel):
|
||||
id: int
|
||||
display_name: str
|
||||
|
||||
# Back-compat for tests that access `.name`
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.display_name
|
||||
|
|
|
|||
63
api/meals.py
63
api/meals.py
|
|
@ -7,7 +7,6 @@ import aiosqlite
|
|||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
import meals
|
||||
import persons
|
||||
import shopping
|
||||
import ingredients
|
||||
from common import ProblemDetails, ApiModel
|
||||
|
|
@ -104,9 +103,6 @@ async def get_upcoming_meals_scoped(
|
|||
await meals.load_extra_ingredients(conn, meal)
|
||||
|
||||
# Map domain Meal -> outward MealOut
|
||||
def _to_member(p: persons.Person) -> MemberRef:
|
||||
return MemberRef(id=p.id, display_name=p.name)
|
||||
|
||||
out: List[MealOut] = []
|
||||
for m in result:
|
||||
out.append(
|
||||
|
|
@ -114,9 +110,9 @@ async def get_upcoming_meals_scoped(
|
|||
id=m.id,
|
||||
suggested_date=m.suggested_date,
|
||||
consumed_date=m.consumed_date,
|
||||
chefs=[_to_member(p) for p in m.chefs],
|
||||
cleanup=[_to_member(p) for p in m.cleanup],
|
||||
consumers=[_to_member(p) for p in m.consumers],
|
||||
chefs=list(m.chefs),
|
||||
cleanup=list(m.cleanup),
|
||||
consumers=list(m.consumers),
|
||||
recipes=m.recipes,
|
||||
extra_ingredients=m.extra_ingredients,
|
||||
purchase_date=m.purchase_date,
|
||||
|
|
@ -143,16 +139,13 @@ async def get_meal_scoped(
|
|||
if not meal:
|
||||
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(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=[_to_member(p) for p in meal.chefs],
|
||||
cleanup=[_to_member(p) for p in meal.cleanup],
|
||||
consumers=[_to_member(p) for p in meal.consumers],
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
|
|
@ -192,16 +185,13 @@ async def mark_meal_consumed_scoped(
|
|||
# Clear any outstanding meal request entries for this 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(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=[_to_member(p) for p in meal.chefs],
|
||||
cleanup=[_to_member(p) for p in meal.cleanup],
|
||||
consumers=[_to_member(p) for p in meal.consumers],
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
|
|
@ -224,8 +214,8 @@ async def create_meal_scoped(
|
|||
) -> MealOut | Response:
|
||||
# Validate using existing service logic
|
||||
# Map MealIn -> domain Meal
|
||||
def _from_member(m: MemberRef) -> persons.Person:
|
||||
return persons.Person(id=m.id, name=m.display_name)
|
||||
def _from_member(m: MemberRef) -> MemberRef:
|
||||
return MemberRef(id=m.id, display_name=m.display_name)
|
||||
|
||||
domain_meal = meals.Meal(
|
||||
id=meal.id,
|
||||
|
|
@ -247,16 +237,13 @@ async def create_meal_scoped(
|
|||
await meals.insert_meal_scoped(conn, domain_meal, hid)
|
||||
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(
|
||||
id=domain_meal.id,
|
||||
suggested_date=domain_meal.suggested_date,
|
||||
consumed_date=domain_meal.consumed_date,
|
||||
chefs=[_to_member(p) for p in domain_meal.chefs],
|
||||
cleanup=[_to_member(p) for p in domain_meal.cleanup],
|
||||
consumers=[_to_member(p) for p in domain_meal.consumers],
|
||||
chefs=list(domain_meal.chefs),
|
||||
cleanup=list(domain_meal.cleanup),
|
||||
consumers=list(domain_meal.consumers),
|
||||
recipes=domain_meal.recipes,
|
||||
extra_ingredients=domain_meal.extra_ingredients,
|
||||
purchase_date=domain_meal.purchase_date,
|
||||
|
|
@ -284,8 +271,8 @@ async def update_meal_scoped(
|
|||
if not existing:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
def _from_member(m: MemberRef) -> persons.Person:
|
||||
return persons.Person(id=m.id, name=m.display_name)
|
||||
def _from_member(m: MemberRef) -> MemberRef:
|
||||
return MemberRef(id=m.id, display_name=m.display_name)
|
||||
|
||||
domain_meal = meals.Meal(
|
||||
id=meal.id,
|
||||
|
|
@ -308,16 +295,13 @@ async def update_meal_scoped(
|
|||
updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
|
||||
assert updated is not None
|
||||
|
||||
def _to_member(p: persons.Person) -> MemberRef:
|
||||
return MemberRef(id=p.id, display_name=p.name)
|
||||
|
||||
return MealOut(
|
||||
id=updated.id,
|
||||
suggested_date=updated.suggested_date,
|
||||
consumed_date=updated.consumed_date,
|
||||
chefs=[_to_member(p) for p in updated.chefs],
|
||||
cleanup=[_to_member(p) for p in updated.cleanup],
|
||||
consumers=[_to_member(p) for p in updated.consumers],
|
||||
chefs=list(updated.chefs),
|
||||
cleanup=list(updated.cleanup),
|
||||
consumers=list(updated.consumers),
|
||||
recipes=updated.recipes,
|
||||
extra_ingredients=updated.extra_ingredients,
|
||||
purchase_date=updated.purchase_date,
|
||||
|
|
@ -351,16 +335,13 @@ async def delete_meal_scoped(
|
|||
await shopping.remove_request(conn, person=None, meal=meal)
|
||||
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(
|
||||
id=meal.id,
|
||||
suggested_date=meal.suggested_date,
|
||||
consumed_date=meal.consumed_date,
|
||||
chefs=[_to_member(p) for p in meal.chefs],
|
||||
cleanup=[_to_member(p) for p in meal.cleanup],
|
||||
consumers=[_to_member(p) for p in meal.consumers],
|
||||
chefs=list(meal.chefs),
|
||||
cleanup=list(meal.cleanup),
|
||||
consumers=list(meal.consumers),
|
||||
recipes=meal.recipes,
|
||||
extra_ingredients=meal.extra_ingredients,
|
||||
purchase_date=meal.purchase_date,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import persons
|
|||
import recipes
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import Page, ProblemDetails, ApiModel, Field
|
||||
from api.dtos import MemberRef
|
||||
|
||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
|
||||
|
|
@ -28,10 +29,10 @@ class RecipeOut(ApiModel):
|
|||
based_on_recipe: Optional[int] = None
|
||||
date_created: datetime.datetime
|
||||
created_by_id: int
|
||||
created_by: Optional[persons.Person] = None
|
||||
created_by: Optional[MemberRef] = None
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[persons.Person] = None
|
||||
hidden_by: Optional[MemberRef] = None
|
||||
|
||||
|
||||
@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):
|
||||
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
|
||||
|
||||
|
|
@ -275,4 +281,10 @@ async def delete_recipe(
|
|||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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_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]
|
||||
from users.repository import get_by_ids as get_users_by_ids
|
||||
|
||||
users = await get_users_by_ids(conn, list(creator_ids))
|
||||
creator_lookup = {uid: u.display_name for uid, u in users.items()}
|
||||
|
||||
def to_recipe_out(r: recipes.Recipe) -> RecipeOut:
|
||||
mref = None
|
||||
|
|
@ -119,12 +116,11 @@ async def get_recipe(
|
|||
return error_response(None, 404, "Recipe not found")
|
||||
# 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])
|
||||
from users.repository import get_by_id as get_user_by_id
|
||||
|
||||
u = await get_user_by_id(conn, r.created_by_id)
|
||||
if u:
|
||||
mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
|
||||
return RecipeOut(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
|
|
@ -148,12 +144,12 @@ async def delete_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")
|
||||
# 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
|
||||
|
||||
# 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(
|
||||
"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"]),
|
||||
)
|
||||
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")
|
||||
# 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])
|
||||
from users.repository import get_by_id as get_user_by_id
|
||||
|
||||
u = await get_user_by_id(conn, r.created_by_id)
|
||||
if u:
|
||||
mref = MemberRef(id=r.created_by_id, display_name=u.display_name)
|
||||
# hiddenBy is the current user
|
||||
hidden = MemberRef(id=user.id, display_name=user.display_name)
|
||||
return RecipeOut(
|
||||
|
|
|
|||
|
|
@ -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()`)
|
||||
- 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)
|
||||
- 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)
|
||||
- MealParticipant(meal_id, person_id, role)
|
||||
- MealRecipe(meal_id, recipe_id, servings)
|
||||
- ShoppingList(id PK, created_date, store_name, purchased_by_id FK)
|
||||
- ShoppingListItem(id PK, ingredient_id FK, list_id FK NULL for requests, person_id FK, meal_id FK, recipe_id FK, created_date)
|
||||
- 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 → User.id, meal_id FK, recipe_id FK, created_date)
|
||||
|
||||
### 0.4 Validated behaviors and invariants (carried forward into v2 where applicable)
|
||||
- ProblemDetails content-type returned for 400/404/422.
|
||||
|
|
@ -254,6 +254,7 @@ Route surface lockdown:
|
|||
|
||||
- Notes:
|
||||
- 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**:
|
||||
- ✅ 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).
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -27,4 +27,3 @@ from meals.roles import (
|
|||
ROLE_CONSUMER as ROLE_CONSUMER,
|
||||
)
|
||||
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
||||
from persons import Person as Person
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ from __future__ import annotations
|
|||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
from api.dtos import MemberRef
|
||||
from recipes import Recipe
|
||||
|
||||
|
||||
|
|
@ -25,11 +25,39 @@ class Meal(ApiModel):
|
|||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[Person] = Field(default_factory=list)
|
||||
cleanup: List[Person] = Field(default_factory=list)
|
||||
consumers: List[Person] = Field(default_factory=list)
|
||||
chefs: List[MemberRef] = Field(default_factory=list)
|
||||
cleanup: List[MemberRef] = Field(default_factory=list)
|
||||
consumers: List[MemberRef] = Field(default_factory=list)
|
||||
recipes: List[MealRecipe] = Field(default_factory=list)
|
||||
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
|
||||
# Set from shopping list
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ from ingredients import (
|
|||
insert_ingredient,
|
||||
)
|
||||
from meals.models import Meal, MealRecipe
|
||||
from persons.models import Person
|
||||
from persons.repository import get_by_ids as persons_get_by_ids
|
||||
from api.dtos import MemberRef
|
||||
from recipes.models import Recipe
|
||||
from recipes.repository import load_recipe_ingredients, row_to_recipe
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ async def create(conn):
|
|||
person_id INTEGER,
|
||||
role TEXT,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id)
|
||||
FOREIGN KEY(person_id) REFERENCES User(id)
|
||||
);"""
|
||||
)
|
||||
# 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(
|
||||
"""
|
||||
DELETE FROM MealParticipant
|
||||
|
|
@ -229,21 +228,63 @@ async def load_participants(conn, meal: Meal) -> None:
|
|||
if not links:
|
||||
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})
|
||||
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:
|
||||
person = people.get(pid)
|
||||
if role == ROLE_CHEF:
|
||||
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:
|
||||
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:
|
||||
if person:
|
||||
meal.consumers.append(person)
|
||||
meal.consumers.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
|
@ -279,8 +320,36 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
|
|||
if not person_ids:
|
||||
return
|
||||
|
||||
# Bulk load persons once
|
||||
people = await persons_get_by_ids(conn, sorted(person_ids))
|
||||
# Bulk load users once and build objects with both display_name and name
|
||||
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
|
||||
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:
|
||||
continue
|
||||
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:
|
||||
meal.cleanup.append(person)
|
||||
meal.cleanup.append(
|
||||
MemberRef(
|
||||
id=int(getattr(person, "id")),
|
||||
display_name=str(getattr(person, "display_name")),
|
||||
)
|
||||
)
|
||||
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:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,20 @@ from __future__ import annotations
|
|||
from typing import List, Set
|
||||
|
||||
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."""
|
||||
seen: set[int] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item in items:
|
||||
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)
|
||||
return duplicates
|
||||
|
||||
|
|
|
|||
28
openapi.json
28
openapi.json
|
|
@ -2007,21 +2007,21 @@
|
|||
},
|
||||
"chefs": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Person"
|
||||
"$ref": "#/components/schemas/MemberRef"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Chefs"
|
||||
},
|
||||
"cleanup": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Person"
|
||||
"$ref": "#/components/schemas/MemberRef"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Cleanup"
|
||||
},
|
||||
"consumers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Person"
|
||||
"$ref": "#/components/schemas/MemberRef"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Consumers"
|
||||
|
|
@ -2356,24 +2356,6 @@
|
|||
],
|
||||
"title": "Page[RecipeOut]"
|
||||
},
|
||||
"Person": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"title": "Id",
|
||||
"default": -1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "Person"
|
||||
},
|
||||
"ProblemDetails": {
|
||||
"properties": {
|
||||
"type": {
|
||||
|
|
@ -2595,7 +2577,7 @@
|
|||
"createdBy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Person"
|
||||
"$ref": "#/components/schemas/MemberRef"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
|
@ -2628,7 +2610,7 @@
|
|||
"hiddenBy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Person"
|
||||
"$ref": "#/components/schemas/MemberRef"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import re
|
|||
from typing import Optional
|
||||
|
||||
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe as Recipe
|
||||
from recipes.repository import (
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
if ldata:
|
||||
return await _get_recipe_from_ldata(conn, url, ldata, created_by)
|
||||
|
|
@ -54,7 +53,7 @@ def find_yield(recipe_ldata: dict) -> int:
|
|||
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 = [
|
||||
parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pydantic import Field
|
|||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
from api.dtos import MemberRef
|
||||
|
||||
|
||||
class Recipe(ApiModel):
|
||||
|
|
@ -37,8 +37,9 @@ class Recipe(ApiModel):
|
|||
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
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
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[Person] = None
|
||||
hidden_by: Optional[MemberRef] = None
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import json
|
|||
from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
from ingredients import find_ingredients_by_recipe_id
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe
|
||||
|
||||
|
||||
|
|
@ -26,8 +25,8 @@ async def create(conn):
|
|||
household_id INTEGER,
|
||||
|
||||
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES User(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES User(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for filtering/pagination
|
||||
|
|
@ -75,7 +74,7 @@ async def insert_recipe_scoped(conn, recipe: Recipe, household_id: int):
|
|||
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(
|
||||
"""
|
||||
UPDATE Recipe
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from typing import ClassVar, List, Optional
|
|||
from pydantic import Field
|
||||
|
||||
from common import BaseLinkedModel
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class ShoppingListItem(BaseLinkedModel):
|
||||
|
|
@ -44,5 +43,6 @@ class ShoppingList(BaseLinkedModel):
|
|||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
store_name: StoreEnum = StoreEnum.home
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ async def create(conn):
|
|||
store_name TEXT NOT NULL,
|
||||
purchased_by_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,
|
||||
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(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(recipe_id) REFERENCES Recipe(id)
|
||||
);"""
|
||||
|
|
@ -470,12 +470,10 @@ async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optiona
|
|||
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
|
||||
)
|
||||
# Store a minimal object; api layer will map to MemberRef
|
||||
shopping_list.purchased_by = type("_PB", (), {})()
|
||||
setattr(shopping_list.purchased_by, "id", int(shopping_list.purchased_by_id))
|
||||
setattr(shopping_list.purchased_by, "display_name", display_name)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
||||
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 with conn.execute(
|
||||
"INSERT INTO User (email, display_name, profile_photo_url) VALUES (?, ?, ?)",
|
||||
|
|
|
|||
Loading…
Reference in a new issue