from typing import Any, AsyncIterator, Dict, List, Optional from shopping.models import ShoppingList, ShoppingListItem async def create(conn): await conn.execute( """ CREATE TABLE IF NOT EXISTS ShoppingList ( id INTEGER PRIMARY KEY, created_date DATETIME NOT NULL, store_name TEXT NOT NULL, purchased_by_id INTEGER, FOREIGN KEY(purchased_by_id) REFERENCES Person(id) );""" ) await conn.execute( """ CREATE TABLE IF NOT EXISTS ShoppingListItem ( id INTEGER PRIMARY KEY, ingredient_id INTEGER, list_id INTEGER, person_id INTEGER, meal_id INTEGER, recipe_id INTEGER, created_date DATETIME NOT NULL, FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id), FOREIGN KEY(list_id) REFERENCES ShoppingList(id), FOREIGN KEY(person_id) REFERENCES Person(id), FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(recipe_id) REFERENCES Recipe(id) );""" ) # Useful indexes for queries await conn.execute( "CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);" ) await conn.execute( "CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);" ) await conn.execute( "CREATE INDEX IF NOT EXISTS idx_shopping_item_person_null_list ON ShoppingListItem(person_id, ingredient_id) WHERE list_id IS NULL;" ) def validate_request(request: ShoppingListItem) -> None: if request.person_id < 0: raise ValueError("Requests must have a person") # A request must have either an ingredient or a meal, but not both if not request.ingredient_id and not request.meal_id: raise ValueError("Request must have either an ingredient or a meal") async def purchase(conn, shopping_list: ShoppingList) -> None: if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0: raise ValueError("Shopping list must have a person id") if shopping_list.items is None or len(shopping_list.items) == 0: raise ValueError("Shopping list must have items") from datetime import datetime shopping_list.created_date = datetime.now().astimezone() async with conn.execute( """ INSERT INTO ShoppingList (created_date, store_name, purchased_by_id) VALUES (?, ?, ?) """, ( shopping_list.created_date.isoformat(), shopping_list.store_name, shopping_list.purchased_by_id, ), ) as cursor: shopping_list.id = cursor.lastrowid for item in shopping_list.items: item.list_id = shopping_list.id validate_request(item) if item.ingredient_id is None or item.ingredient_id < 0: raise ValueError("Ingredient request must have a valid ingredient id") isMeal = item.meal_id is not None and item.meal_id >= 0 isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0 if not isMeal and not isPersonRequest: raise ValueError("Ingredient request must have either a meal or a person id") if isPersonRequest: # Update existing request from its null id, or throw async with conn.execute( """ UPDATE ShoppingListItem SET list_id = ? WHERE ingredient_id = ? AND list_id IS NULL AND person_id = ? AND meal_id IS NULL AND recipe_id IS NULL """, (shopping_list.id, item.ingredient_id, item.person_id), ) as cursor: if cursor.rowcount == 0: raise ValueError( "Ingredient request must have a valid person id and ingredient id" ) elif isMeal: # Insert new request for meal if item.meal_id is None or item.meal_id < 0: raise ValueError("Meal request must have a valid meal id") async with conn.execute( """ INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date) VALUES (?, ?, ?, ?, ?, ?) """, ( item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.recipe_id, item.created_date.isoformat(), ), ) as cursor: item.id = cursor.lastrowid meal_ids = list( { item.meal_id for item in shopping_list.items if item.meal_id is not None and item.meal_id >= 0 } ) await update_purchased_meals(conn, meal_ids) async def get_outstanding_requests_scoped(conn, household_id: int) -> List[ShoppingListItem]: # outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed # and is not part of a shopping list that has been purchased. # The subquery for `active_meal_ids` finds meals that are not consumed and not part of a purchased list. # The main query then selects items linked to these active meals OR items with no meal link at all. rows = await conn.execute_fetchall( """ WITH active_meal_ids AS ( SELECT m.id FROM meals m LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id WHERE m.household_id = :household_id AND m.consumed_date IS NULL AND (sl.id IS NULL OR sl.purchased_by_id IS NULL) GROUP BY m.id ) SELECT sli.id, sli.meal_id, sli.ingredient_id, sli.quantity, sli.unit, sli.added_by_id FROM shopping_list_items sli WHERE sli.household_id = :household_id AND sli.purchased_at IS NULL AND (sli.meal_id IN (SELECT id FROM active_meal_ids) OR sli.meal_id IS NULL); """, {"household_id": household_id}, ) return [_to_shopping_list_item(r) for r in rows] async def update_purchased_meals(conn, meal_ids: List[int]) -> None: if not meal_ids: return purchased_ingredient_ids = { item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids) } from meals.repository import find_meal_by_id, mark_purchased for meal_id in meal_ids: meal = await find_meal_by_id(conn, meal_id) if not meal: continue ingredients = { ingredient.id for mr in meal.recipes for ingredient in (mr.recipe.ingredients if mr.recipe else []) } | {ingredient.id for ingredient in meal.extra_ingredients} remaining_ingredients = ingredients - purchased_ingredient_ids if not remaining_ingredients: await mark_purchased(conn, meal) await remove_request(conn, person=None, meal=meal) async def is_requested(conn, meal) -> bool: if meal.id < 0: return False async with conn.execute( """ SELECT COUNT(*) FROM ShoppingListItem WHERE meal_id = ? AND list_id IS NULL """, (meal.id,), ) as cursor: row = await cursor.fetchone() return row[0] > 0 async def is_requested_scoped(conn, meal_id: int, household_id: int) -> bool: async with conn.execute( """ SELECT COUNT(*) FROM ShoppingListItem WHERE meal_id = ? AND list_id IS NULL AND household_id = ? """, (meal_id, household_id), ) as cursor: row = await cursor.fetchone() return row[0] > 0 async def request( conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None ) -> ShoppingListItem: from ingredients.repository import insert_ingredient if ingredient is not None and meal is not None: raise ValueError("Cannot request both an ingredient and a meal") if ingredient is None and meal is None: raise ValueError("Must specify either an ingredient or a meal to request") if meal is not None and meal.id < 0: raise ValueError("Meal must have a valid id") if ingredient is not None and ingredient.id < 0: await insert_ingredient(conn, ingredient) ingredient_id = ingredient.id if ingredient else None meal_id = meal.id if meal else None item = ShoppingListItem(ingredient_id=ingredient_id, person_id=person.id, meal_id=meal_id) validate_request(item) if meal is not None and await is_requested(conn, meal): raise ValueError("Meal is already requested") async with conn.execute( """ INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date) VALUES (?, ?, ?, ?) """, (item.ingredient_id, item.person_id, item.meal_id, item.created_date.isoformat()), ) as cursor: item.id = cursor.lastrowid return item async def request_meal_scoped( conn, meal: Any, household_id: int, person_id: Optional[int] = None ) -> ShoppingListItem: if meal is None or getattr(meal, "id", -1) < 0: raise ValueError("Meal must have a valid id") if await is_requested_scoped(conn, meal.id, household_id): raise ValueError("Meal is already requested") # Use 0 for outward personId to satisfy schema without binding to v1 persons pid = person_id if person_id is not None else 0 item = ShoppingListItem(ingredient_id=None, person_id=pid, meal_id=meal.id) async with conn.execute( """ INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id) VALUES (?, ?, ?, ?, ?) """, (None, pid, meal.id, item.created_date.isoformat(), household_id), ) as cursor: item.id = cursor.lastrowid return item async def request_ingredient_scoped( conn, ingredient: Any, household_id: int, person_id: int ) -> ShoppingListItem: if ingredient is None or getattr(ingredient, "id", -1) < 0: raise ValueError("Ingredient must have a valid id") # TODO: Check if ingredient is already requested by this person item = ShoppingListItem(ingredient_id=ingredient.id, person_id=person_id, meal_id=None) async with conn.execute( """ INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date, household_id) VALUES (?, ?, ?, ?, ?) """, (ingredient.id, person_id, None, item.created_date.isoformat(), household_id), ) as cursor: item.id = cursor.lastrowid return item async def remove_request( conn, person: Optional[Any] = None, meal: Optional[Any] = None, ingredient: Optional[Any] = None, ) -> bool: if meal is not None: async with conn.execute( """ DELETE FROM ShoppingListItem WHERE list_id IS NULL AND meal_id = ? """, (meal.id,), ) as cursor: return cursor.rowcount > 0 elif ingredient is not None: async with conn.execute( """ DELETE FROM ShoppingListItem WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ? """, (ingredient.id, person.id if person else -1), ) as cursor: return cursor.rowcount > 0 raise ValueError("Must specify either a meal or an ingredient to remove") async def remove_meal_request_scoped(conn, meal_id: int, household_id: int) -> bool: async with conn.execute( """ DELETE FROM ShoppingListItem WHERE list_id IS NULL AND meal_id = ? AND household_id = ? """, (meal_id, household_id), ) as cursor: return cursor.rowcount > 0 async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]: request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] select = f""" SELECT {",".join(request_cols)} FROM ShoppingListItem """ where: str params: tuple[Any, ...] where, params = (" WHERE list_id IS NULL", ()) if list_id is not None: where, params = " WHERE list_id = ?", (list_id,) cursor = await conn.execute(select + where, params) async for row in cursor: request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)} request = ShoppingListItem(**request_map) yield request async def find_items_by_list_id_scoped( conn, list_id: Optional[int], household_id: int ) -> AsyncIterator[ShoppingListItem]: request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] select = f""" SELECT {",".join(request_cols)} FROM ShoppingListItem """ where: str params: tuple[Any, ...] where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,)) if list_id is not None: where, params = ( " WHERE list_id = ? AND household_id = ?", (list_id, household_id), ) cursor = await conn.execute(select + where, params) async for row in cursor: request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)} request = ShoppingListItem(**request_map) yield request async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]: shopping_list: Optional[ShoppingList] = None async with conn.execute( f""" SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList WHERE id = ? LIMIT 1 """, (id,), ) as cursor: async for row in cursor: shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)}) break if shopping_list: async for item in find_items_by_list_id(conn, shopping_list.id): shopping_list.items.append(item) return shopping_list async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optional[ShoppingList]: shopping_list: Optional[ShoppingList] = None async with conn.execute( f""" SELECT {",".join(ShoppingList.KEYS)}, ( SELECT display_name FROM User u WHERE u.id = ShoppingList.purchased_by_id ) as purchased_by_name FROM ShoppingList WHERE id = ? AND household_id = ? LIMIT 1 """, (id, household_id), ) as cursor: async for row in cursor: base = {k: v for k, v in zip(ShoppingList.KEYS, row[: len(ShoppingList.KEYS)])} shopping_list = ShoppingList(**base) # Attach a lightweight purchased_by with display_name if available 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 ) except Exception: pass break if shopping_list: async for item in find_items_by_list_id_scoped(conn, shopping_list.id, household_id): shopping_list.items.append(item) return shopping_list async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]: if not meal_ids: return async with conn.execute( f""" SELECT {",".join(ShoppingListItem.KEYS)} FROM ShoppingListItem WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL """, meal_ids, ) as cursor: async for row in cursor: yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) async def get_purchased_ingredients_scoped( conn, meal_ids: List[int], household_id: int ) -> AsyncIterator[ShoppingListItem]: if not meal_ids: return async with conn.execute( f""" SELECT {",".join(ShoppingListItem.KEYS)} FROM ShoppingListItem WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL AND household_id = ? """, (*meal_ids, household_id), ) as cursor: async for row in cursor: yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) def _to_shopping_list_item(row: Any) -> ShoppingListItem: # Row is a tuple in the order of columns selected; map via KEYS return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})