526 lines
18 KiB
Python
526 lines
18 KiB
Python
from typing import Any, AsyncIterator, 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,
|
|
household_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,
|
|
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(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;"
|
|
)
|
|
# Household indices for scoped queries
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_shopping_list_household_id ON ShoppingList(household_id);"
|
|
)
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_shopping_item_household_id ON ShoppingListItem(household_id);"
|
|
)
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_shopping_item_household_person_ing_null_list ON ShoppingListItem(household_id, 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")
|
|
|
|
# If already requested by this person and not yet purchased in this household, return existing
|
|
where = "WHERE list_id IS NULL AND meal_id IS NULL AND ingredient_id = ? AND person_id = ? AND household_id = ?"
|
|
params = (ingredient.id, person_id, household_id)
|
|
async with conn.execute(
|
|
f"""
|
|
SELECT id, ingredient_id, list_id, person_id, meal_id, recipe_id, created_date
|
|
FROM ShoppingListItem
|
|
{where}
|
|
LIMIT 1
|
|
""",
|
|
params,
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
if row:
|
|
return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
|
|
|
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)})
|