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):
|
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
|
||||||
|
|
|
||||||
63
api/meals.py
63
api/meals.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
28
openapi.json
28
openapi.json
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (?, ?, ?)",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue