diff --git a/api/dtos.py b/api/dtos.py index 28f75b0..1773db1 100644 --- a/api/dtos.py +++ b/api/dtos.py @@ -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 diff --git a/api/meals.py b/api/meals.py index 07cee20..72372d0 100644 --- a/api/meals.py +++ b/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, diff --git a/api/recipes.py b/api/recipes.py index c9237bf..29d65c7 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -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 diff --git a/api/recipes_v2.py b/api/recipes_v2.py index b32182f..884f749 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -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( diff --git a/backend-spec.md b/backend-spec.md index 5826044..d3aa7b4 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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. diff --git a/meals/__init__.py b/meals/__init__.py index 1940b2c..77c94d1 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -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 diff --git a/meals/models.py b/meals/models.py index 875371c..3ef3f5f 100644 --- a/meals/models.py +++ b/meals/models.py @@ -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 diff --git a/meals/repository.py b/meals/repository.py index 60aac2c..20be394 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -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}") diff --git a/meals/service.py b/meals/service.py index 8236505..02c9624 100644 --- a/meals/service.py +++ b/meals/service.py @@ -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 diff --git a/openapi.json b/openapi.json index dffb188..3bbf195 100644 --- a/openapi.json +++ b/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" diff --git a/recipes/__init__.py b/recipes/__init__.py index 18edfa2..9a59703 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -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"] ] diff --git a/recipes/models.py b/recipes/models.py index 51eb420..c777b1c 100644 --- a/recipes/models.py +++ b/recipes/models.py @@ -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 diff --git a/recipes/repository.py b/recipes/repository.py index 2a82589..f65c374 100644 --- a/recipes/repository.py +++ b/recipes/repository.py @@ -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 diff --git a/shopping/models.py b/shopping/models.py index 7e20640..ba32742 100644 --- a/shopping/models.py +++ b/shopping/models.py @@ -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) diff --git a/shopping/repository.py b/shopping/repository.py index 4c8ab90..1a1be24 100644 --- a/shopping/repository.py +++ b/shopping/repository.py @@ -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 diff --git a/users/repository.py b/users/repository.py index 9c4296f..2b1f9ce 100644 --- a/users/repository.py +++ b/users/repository.py @@ -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 (?, ?, ?)",