munch-ease-backend/shopping/db.py

338 lines
10 KiB
Python

from datetime import datetime
from enum import Enum
from typing import Any, AsyncIterator, ClassVar, List, Optional
from pydantic import Field
from common import BaseLinkedModel
from ingredients import Ingredient, insert_ingredient
from meals import Meal, find_meal_by_id, mark_purchased
from persons import Person
class ShoppingListItem(BaseLinkedModel):
KEYS: ClassVar[List[str]] = [
"id",
"ingredient_id",
"list_id",
"person_id",
"meal_id",
"recipe_id",
"created_date",
]
id: int = -1
list_id: Optional[int] = None
person_id: int = -1
ingredient_id: Optional[int] = None
recipe_id: Optional[int] = None
meal_id: Optional[int] = None
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
class StoreEnum(str, Enum):
woolworths = "woolworths"
coles = "coles"
home = ""
class ShoppingList(BaseLinkedModel):
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
id: int = -1
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
items: List[ShoppingListItem] = Field(default_factory=list)
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)
);"""
)
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")
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 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)
}
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: 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 request(
conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None
) -> ShoppingListItem:
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 remove_request(
conn,
person: Optional[Person] = None,
meal: Optional[Meal] = None,
ingredient: Optional[Ingredient] = 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 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 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 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)})