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):
id: int
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
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,

View file

@ -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

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_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(

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()`)
- 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.

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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"

View file

@ -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"]
]

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

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])
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 (?, ?, ?)",