2025-10-18 03:26:42 +00:00
from datetime import datetime
from enum import Enum
from typing import Any , AsyncIterator , ClassVar , List , Optional
from pydantic import Field
2025-07-28 23:23:09 +00:00
from common import BaseLinkedModel
2024-05-17 09:09:03 +00:00
from ingredients import Ingredient , insert_ingredient
2025-10-18 03:26:42 +00:00
from meals import Meal , find_meal_by_id , mark_purchased
2024-05-17 09:09:03 +00:00
from persons import Person
2025-07-28 23:23:09 +00:00
class ShoppingListItem ( BaseLinkedModel ) :
2025-10-18 03:26:42 +00:00
KEYS : ClassVar [ List [ str ] ] = [
" id " ,
" ingredient_id " ,
" list_id " ,
" person_id " ,
" meal_id " ,
" recipe_id " ,
" created_date " ,
]
2024-05-20 10:09:57 +00:00
id : int = - 1
2024-10-13 08:19:57 +00:00
list_id : Optional [ int ] = None
2025-10-18 03:26:42 +00:00
2025-07-28 23:23:09 +00:00
person_id : int = - 1
2025-10-18 03:26:42 +00:00
2024-05-18 07:05:01 +00:00
ingredient_id : Optional [ int ] = None
2024-05-17 09:09:03 +00:00
2025-07-28 23:23:09 +00:00
recipe_id : Optional [ int ] = None
2024-05-17 09:09:03 +00:00
meal_id : Optional [ int ] = None
2025-10-18 03:26:42 +00:00
created_date : datetime = Field ( default_factory = lambda : datetime . now ( ) . astimezone ( ) )
2024-05-17 09:09:03 +00:00
2024-10-13 08:19:57 +00:00
class StoreEnum ( str , Enum ) :
2025-10-18 03:26:42 +00:00
woolworths = " woolworths "
coles = " coles "
home = " "
2024-05-17 09:09:03 +00:00
2025-07-28 23:23:09 +00:00
class ShoppingList ( BaseLinkedModel ) :
2025-10-18 03:26:42 +00:00
KEYS : ClassVar [ List [ str ] ] = [ " id " , " created_date " , " store_name " ]
2024-05-20 10:09:57 +00:00
id : int = - 1
2025-10-18 03:26:42 +00:00
created_date : datetime = Field ( default_factory = lambda : datetime . now ( ) . astimezone ( ) )
store_name : StoreEnum = StoreEnum . home
2025-07-28 23:23:09 +00:00
purchased_by_id : int = - 1
purchased_by : Optional [ Person ] = None
2025-10-18 03:26:42 +00:00
items : List [ ShoppingListItem ] = Field ( default_factory = list )
2024-05-17 09:09:03 +00:00
async def create ( conn ) :
2025-10-18 03:26:42 +00:00
await conn . execute (
"""
2024-05-17 09:09:03 +00:00
CREATE TABLE IF NOT EXISTS ShoppingList (
id INTEGER PRIMARY KEY ,
2024-10-14 06:36:56 +00:00
created_date DATETIME NOT NULL ,
store_name TEXT NOT NULL ,
2025-07-28 23:23:09 +00:00
purchased_by_id INTEGER ,
FOREIGN KEY ( purchased_by_id ) REFERENCES Person ( id )
2025-10-18 03:26:42 +00:00
) ; """
)
await conn . execute (
"""
2025-07-28 23:23:09 +00:00
CREATE TABLE IF NOT EXISTS ShoppingListItem (
2024-05-17 09:09:03 +00:00
id INTEGER PRIMARY KEY ,
ingredient_id INTEGER ,
list_id INTEGER ,
person_id INTEGER ,
meal_id INTEGER ,
2025-07-28 23:23:09 +00:00
recipe_id INTEGER ,
2024-10-14 06:36:56 +00:00
created_date DATETIME NOT NULL ,
2024-05-17 09:09:03 +00:00
FOREIGN KEY ( ingredient_id ) REFERENCES Ingredient ( id ) ,
2025-07-28 23:23:09 +00:00
FOREIGN KEY ( list_id ) REFERENCES ShoppingList ( id ) ,
2024-05-17 09:09:03 +00:00
FOREIGN KEY ( person_id ) REFERENCES Person ( id ) ,
FOREIGN KEY ( meal_id ) REFERENCES Meal ( id ) ,
2025-07-28 23:23:09 +00:00
FOREIGN KEY ( recipe_id ) REFERENCES Recipe ( id )
2025-10-18 03:26:42 +00:00
) ; """
)
2025-10-19 02:12:28 +00:00
# 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; " )
2025-10-18 03:26:42 +00:00
2024-05-17 09:09:03 +00:00
2025-07-28 23:23:09 +00:00
def validate_request ( request : ShoppingListItem ) - > None :
if request . person_id < 0 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Requests must have a person " )
2024-05-20 10:09:57 +00:00
# A request must have either an ingredient or a meal, but not both
2025-07-30 12:05:29 +00:00
if not request . ingredient_id and not request . meal_id :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Request must have either an ingredient or a meal " )
2025-07-28 23:23:09 +00:00
async def purchase ( conn , shopping_list : ShoppingList ) - > None :
2025-07-29 07:15:38 +00:00
if shopping_list . purchased_by_id is None or shopping_list . purchased_by_id < 0 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Shopping list must have a person id " )
2025-07-28 23:23:09 +00:00
if shopping_list . items is None or len ( shopping_list . items ) == 0 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Shopping list must have items " )
2024-10-14 05:59:05 +00:00
shopping_list . created_date = datetime . now ( ) . astimezone ( )
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
INSERT INTO ShoppingList ( created_date , store_name , purchased_by_id )
VALUES ( ? , ? , ? )
2025-10-18 03:26:42 +00:00
""" ,
(
shopping_list . created_date . isoformat ( ) ,
shopping_list . store_name ,
shopping_list . purchased_by_id ,
) ,
) as cursor :
2024-05-17 09:09:03 +00:00
shopping_list . id = cursor . lastrowid
2025-07-28 23:23:09 +00:00
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 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Ingredient request must have a valid ingredient id " )
2025-07-28 23:23:09 +00:00
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 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Ingredient request must have either a meal or a person id " )
2025-07-28 23:23:09 +00:00
if isPersonRequest :
# Update existing request from its null id, or throw
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
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
2025-10-18 03:26:42 +00:00
""" ,
( shopping_list . id , item . ingredient_id , item . person_id ) ,
) as cursor :
2025-07-28 23:23:09 +00:00
if cursor . rowcount == 0 :
2025-10-18 03:26:42 +00:00
raise ValueError (
" Ingredient request must have a valid person id and ingredient id "
)
2025-07-28 23:23:09 +00:00
elif isMeal :
# Insert new request for meal
if item . meal_id is None or item . meal_id < 0 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Meal request must have a valid meal id " )
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
INSERT INTO ShoppingListItem ( ingredient_id , list_id , person_id , meal_id , recipe_id , created_date )
VALUES ( ? , ? , ? , ? , ? , ? )
2025-10-18 03:26:42 +00:00
""" ,
(
item . ingredient_id ,
shopping_list . id ,
item . person_id ,
item . meal_id ,
item . recipe_id ,
item . created_date . isoformat ( ) ,
) ,
) as cursor :
2025-07-28 23:23:09 +00:00
item . id = cursor . lastrowid
2025-10-18 03:26:42 +00:00
meal_ids = list (
{
item . meal_id
for item in shopping_list . items
if item . meal_id is not None and item . meal_id > = 0
}
)
2025-07-28 23:23:09 +00:00
await update_purchased_meals ( conn , meal_ids )
2025-10-18 03:26:42 +00:00
2025-07-28 23:23:09 +00:00
async def update_purchased_meals ( conn , meal_ids : List [ int ] ) - > None :
if not meal_ids :
return
2025-10-18 03:26:42 +00:00
purchased_ingredient_ids = {
item . ingredient_id async for item in get_purchased_ingredients ( conn , meal_ids )
}
2025-07-28 23:23:09 +00:00
for meal_id in meal_ids :
meal = await find_meal_by_id ( conn , meal_id )
2025-10-18 03:26:42 +00:00
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 }
2025-07-28 23:23:09 +00:00
remaining_ingredients = ingredients - purchased_ingredient_ids
if not remaining_ingredients :
await mark_purchased ( conn , meal )
2025-10-18 03:26:42 +00:00
await remove_request ( conn , person = None , meal = meal )
2025-07-28 23:23:09 +00:00
async def is_requested ( conn , meal : Meal ) - > bool :
if meal . id < 0 :
return False
2024-05-20 10:09:57 +00:00
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
SELECT COUNT ( * ) FROM ShoppingListItem
WHERE meal_id = ? AND list_id IS NULL
2025-10-18 03:26:42 +00:00
""" ,
( meal . id , ) ,
) as cursor :
2025-07-28 23:23:09 +00:00
row = await cursor . fetchone ( )
return row [ 0 ] > 0
2024-05-20 10:09:57 +00:00
2025-10-18 03:26:42 +00:00
async def request (
conn , person : Person , ingredient : Optional [ Ingredient ] = None , meal : Optional [ Meal ] = None
) - > ShoppingListItem :
2025-07-28 23:23:09 +00:00
if ingredient is not None and meal is not None :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Cannot request both an ingredient and a meal " )
2024-05-17 09:09:03 +00:00
2025-07-28 23:23:09 +00:00
if ingredient is None and meal is None :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Must specify either an ingredient or a meal to request " )
2024-05-20 10:09:57 +00:00
2025-07-28 23:23:09 +00:00
if meal is not None and meal . id < 0 :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Meal must have a valid id " )
2024-05-20 10:09:57 +00:00
2025-07-28 23:23:09 +00:00
if ingredient is not None and ingredient . id < 0 :
await insert_ingredient ( conn , ingredient )
2024-05-17 09:09:03 +00:00
2025-07-30 12:05:29 +00:00
ingredient_id = ingredient . id if ingredient else None
meal_id = meal . id if meal else None
2025-10-18 03:26:42 +00:00
item = ShoppingListItem ( ingredient_id = ingredient_id , person_id = person . id , meal_id = meal_id )
2025-07-28 23:23:09 +00:00
validate_request ( item )
if meal is not None and await is_requested ( conn , meal ) :
2025-10-18 03:26:42 +00:00
raise ValueError ( " Meal is already requested " )
2025-07-28 23:23:09 +00:00
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
INSERT INTO ShoppingListItem ( ingredient_id , person_id , meal_id , created_date )
VALUES ( ? , ? , ? , ? )
2025-10-18 03:26:42 +00:00
""" ,
( item . ingredient_id , item . person_id , item . meal_id , item . created_date . isoformat ( ) ) ,
) as cursor :
2025-07-28 23:23:09 +00:00
item . id = cursor . lastrowid
return item
2024-05-20 10:09:57 +00:00
2025-10-18 03:26:42 +00:00
async def remove_request (
conn ,
person : Optional [ Person ] = None ,
meal : Optional [ Meal ] = None ,
ingredient : Optional [ Ingredient ] = None ,
) - > bool :
2025-07-28 23:23:09 +00:00
if meal is not None :
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND meal_id = ?
2025-10-18 03:26:42 +00:00
""" ,
( meal . id , ) ,
) as cursor :
2025-07-28 23:23:09 +00:00
return cursor . rowcount > 0
elif ingredient is not None :
2025-10-18 03:26:42 +00:00
async with conn . execute (
"""
2025-07-28 23:23:09 +00:00
DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ?
2025-10-18 03:26:42 +00:00
""" ,
( ingredient . id , person . id if person else - 1 ) ,
) as cursor :
2025-07-28 23:23:09 +00:00
return cursor . rowcount > 0
2025-10-18 03:26:42 +00:00
raise ValueError ( " Must specify either a meal or an ingredient to remove " )
2025-07-28 23:23:09 +00:00
async def find_items_by_list_id ( conn , list_id : Optional [ int ] ) - > AsyncIterator [ ShoppingListItem ] :
2025-10-18 03:26:42 +00:00
request_cols = [ f " shoppinglistitem. { key } " for key in ShoppingListItem . KEYS ]
2024-05-17 09:09:03 +00:00
2025-10-18 03:26:42 +00:00
select = f """
SELECT { ' , ' . join ( request_cols ) }
2025-07-28 23:23:09 +00:00
FROM ShoppingListItem
2025-10-18 03:26:42 +00:00
"""
where : str
params : tuple [ Any , . . . ]
where , params = ( " WHERE list_id IS NULL " , ( ) )
2024-10-13 08:19:57 +00:00
if list_id is not None :
2025-10-18 03:26:42 +00:00
where , params = " WHERE list_id = ? " , ( list_id , )
2024-10-13 08:19:57 +00:00
cursor = await conn . execute ( select + where , params )
2025-10-18 03:26:42 +00:00
2024-05-17 09:09:03 +00:00
async for row in cursor :
2025-10-18 03:26:42 +00:00
request_map = { k : v for k , v in zip ( ShoppingListItem . KEYS , row ) }
request = ShoppingListItem ( * * request_map )
2024-05-17 09:09:03 +00:00
yield request
2025-10-18 03:26:42 +00:00
async def load_shopping_list ( conn , id : int ) - > Optional [ ShoppingList ] :
shopping_list : Optional [ ShoppingList ] = None
async with conn . execute (
f """
2024-05-17 09:09:03 +00:00
SELECT { ' , ' . join ( ShoppingList . KEYS ) } FROM ShoppingList
WHERE id = ?
LIMIT 1
2025-10-18 03:26:42 +00:00
""" ,
( id , ) ,
) as cursor :
2024-05-17 09:09:03 +00:00
async for row in cursor :
2025-10-18 03:26:42 +00:00
shopping_list = ShoppingList ( * * { k : v for k , v in zip ( ShoppingList . KEYS , row ) } )
2024-05-17 09:09:03 +00:00
break
if shopping_list :
2025-07-28 23:23:09 +00:00
async for item in find_items_by_list_id ( conn , shopping_list . id ) :
shopping_list . items . append ( item )
2024-05-17 09:09:03 +00:00
return shopping_list
2025-10-18 03:26:42 +00:00
2025-07-28 23:23:09 +00:00
async def get_purchased_ingredients ( conn , meal_ids : List [ int ] ) - > AsyncIterator [ ShoppingListItem ] :
if not meal_ids :
return
2025-10-18 03:26:42 +00:00
async with conn . execute (
f """
2025-07-28 23:23:09 +00:00
SELECT { ' , ' . join ( ShoppingListItem . KEYS ) }
FROM ShoppingListItem
2025-07-30 12:05:29 +00:00
WHERE meal_id IN ( { ' , ' . join ( [ ' ? ' ] * len ( meal_ids ) ) } ) AND list_id IS NOT NULL
2025-10-18 03:26:42 +00:00
""" ,
meal_ids ,
) as cursor :
2024-05-19 03:43:06 +00:00
async for row in cursor :
2025-10-18 03:26:42 +00:00
yield ShoppingListItem ( * * { k : v for k , v in zip ( ShoppingListItem . KEYS , row ) } )