munch-ease-backend/shopping/db.py
jableader 255ebd4613 Squashed commit of the following:
commit 1c707260a4059cbdebb3e2720d3899b8773a1c37
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 22:03:48 2025 +1000

    Sync tests

commit 5a71077121921a2cea9f3be9c1d1f7770691520a
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:57:00 2025 +1000

    minor refactorings

commit 0bafec1b40bf5cf6f1554f6e5ec6e8f4e043d1cc
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:39:52 2025 +1000

    Remove ids from shopping list

commit c1ee30d1b591b241dfa152d8a0fa9af1f6aaaed1
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:04:48 2025 +1000

    Added more cases to shopping tests

commit ad3d598bdb574b1aeaabc354bb825ee428aaaa41
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:26:36 2025 +1000

    Added 404 response when list not found

commit 336538dc47e12736f7f57d2c80252a700dc1e83b
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:15:52 2025 +1000

    Tests for main

commit 238c0f89b2671348091a162ee519e5f1bf2cd66c
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:07:12 2025 +1000

    Meals tests

commit b92f2432ee13f5384b6f1a9b8efe76bec9dbd920
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:57:45 2025 +1000

    Shopping tests

commit b3c7ae20e6207cf0f9f04bdec5cb6145b04638d9
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:40:19 2025 +1000

    product tests passing
2025-07-30 22:05:29 +10:00

258 lines
No EOL
9.7 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', '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 = 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_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)
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)
await remove_request(conn, 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: 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)) 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_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS]
select = f'''
SELECT {','.join(request_keys)}
FROM ShoppingListItem
'''
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_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row)}
request = ShoppingListItem(**request_keys)
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))}) 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)})