293 lines
No EOL
11 KiB
Python
293 lines
No EOL
11 KiB
Python
from common import BaseLinkedModel
|
|
from recipes import Recipe
|
|
from meals import Meal, find_meal_by_id, mark_purchased
|
|
from ingredients import Ingredient, insert_ingredient
|
|
from persons import Person
|
|
from products import Product
|
|
|
|
from typing import AsyncIterator, List, ClassVar, Optional
|
|
|
|
from datetime import datetime
|
|
|
|
class ShoppingListItem(BaseLinkedModel):
|
|
KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'created_date']
|
|
id: int = -1
|
|
list_id: Optional[int] = None
|
|
|
|
person_id: int = -1
|
|
person: Optional[Person] = None
|
|
|
|
ingredient_id: Optional[int] = None
|
|
ingredient: Optional[Ingredient] = None
|
|
|
|
recipe_id: Optional[int] = None
|
|
recipe: Optional[Recipe] = None
|
|
|
|
meal_id: Optional[int] = None
|
|
meal: Optional[Meal] = None
|
|
|
|
created_date: datetime = datetime.now().astimezone()
|
|
|
|
|
|
from enum import Enum
|
|
|
|
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 = datetime.now().astimezone()
|
|
store_name: StoreEnum = ''
|
|
purchased_by_id: int = -1
|
|
purchased_by: Optional[Person] = None
|
|
items: List[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)
|
|
);''')
|
|
|
|
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 and not request.meal:
|
|
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:
|
|
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')
|
|
|
|
if shopping_list.purchased_by_id < 0:
|
|
raise ValueError('Shopping list must have a valid person id')
|
|
|
|
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 and item.ingredient.id < 0:
|
|
await insert_ingredient(conn, item.ingredient)
|
|
|
|
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)
|
|
ingredients = {ingredient.id for recipe in meal.recipes for ingredient in recipe.recipe.ingredients} | \
|
|
{ingredient.id for ingredient in meal.extra_ingredients}
|
|
|
|
remaining_ingredients = ingredients - purchased_ingredient_ids
|
|
if not remaining_ingredients:
|
|
await mark_purchased(conn, meal)
|
|
|
|
# If all ingredients are purchased, update the meal status
|
|
await conn.execute('''
|
|
UPDATE Meal
|
|
SET purchase_date = ?
|
|
WHERE id = ?
|
|
''', (datetime.now().isoformat(), meal.id))
|
|
|
|
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)
|
|
|
|
item = ShoppingListItem(ingredient=ingredient, person=person, meal=meal)
|
|
|
|
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: Person, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> None:
|
|
failed = True
|
|
if meal is not None:
|
|
# Ensure a record is removed
|
|
async with conn.execute('''
|
|
DELETE FROM ShoppingListItem
|
|
WHERE list_id IS NULL AND meal_id = ?
|
|
''', (meal.id,)) as cursor:
|
|
if cursor.rowcount > 0:
|
|
failed = False
|
|
|
|
elif ingredient is not None:
|
|
# Ensure a record is removed
|
|
async with conn.execute('''
|
|
DELETE FROM ShoppingListItem
|
|
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ?
|
|
''', (ingredient.id, person.id)) as cursor:
|
|
if cursor.rowcount > 0:
|
|
failed = False
|
|
|
|
if failed:
|
|
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]:
|
|
# Join Ingredient and Product to also load ingredient and product
|
|
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
|
|
product_keys = [f'product.{key}' for key in Product.KEYS]
|
|
request_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS]
|
|
person_keys = [f'person.{key}' for key in Person.KEYS]
|
|
|
|
select = f'''
|
|
SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)}
|
|
FROM ShoppingListItem
|
|
LEFT JOIN Ingredient ON ShoppingListItem.ingredient_id = Ingredient.id
|
|
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
|
LEFT JOIN Person ON ShoppingListItem.person_id = Person.id
|
|
'''
|
|
|
|
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:
|
|
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):len(Ingredient.KEYS) + len(Product.KEYS)])}
|
|
product = Product(**product_keys) if product_keys['id'] else None
|
|
|
|
ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}
|
|
ingredient = Ingredient(**ingredient_keys, product=product) if ingredient_keys['id'] else None
|
|
|
|
person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])}
|
|
person = Person(**person_keys) if person_keys['id'] else None
|
|
|
|
request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])}
|
|
request = ShoppingListItem(**request_keys, ingredient=ingredient, person=person)
|
|
|
|
if request.meal_id is not None:
|
|
request.meal = await find_meal_by_id(conn, request.meal_id)
|
|
|
|
yield request
|
|
|
|
async def load_shopping_list(conn, id: int) -> ShoppingList:
|
|
shopping_list = 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))})
|
|
''', meal_ids) as cursor:
|
|
async for row in cursor:
|
|
yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)}) |