Allowed for null products, move ingredient spreading server-side (#1)

Reviewed-on: #1
Co-authored-by: jableader <jacobdunk@gmail.com>
Co-committed-by: jableader <jacobdunk@gmail.com>
This commit is contained in:
jableader 2025-07-28 23:23:09 +00:00 committed by jacob
parent b17bb1d687
commit 1706809458
7 changed files with 319 additions and 227 deletions

23
common.py Normal file
View file

@ -0,0 +1,23 @@
from pydantic import BaseModel, Field, model_validator
from typing import Optional, Any
class BaseLinkedModel(BaseModel):
model_config = dict(arbitrary_types_allowed=True)
@model_validator(mode="before")
@classmethod
def auto_populate_ids(cls, data: dict[str, Any]) -> dict[str, Any]:
if isinstance(data, dict):
for key, value in data.copy().items():
if not key.endswith("_id") and hasattr(value, "id") and value is not None:
id_key = key + "_id"
if id_key in data:
# If the id_key already exists, ensure it matches the value's id
if data[id_key] != value.id:
raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}")
else:
# If the id_key does not exist, set it to the value's id
data[id_key] = value.id
return data

2
db.py
View file

@ -1,6 +1,6 @@
import aiosqlite import aiosqlite
async def connect(path = './data/your_database.db') -> aiosqlite.Connection: async def connect(path = './data/doof.sqlite') -> aiosqlite.Connection:
return await aiosqlite.connect(path) return await aiosqlite.connect(path)
async def create(conn: aiosqlite.Connection): async def create(conn: aiosqlite.Connection):

View file

@ -55,7 +55,7 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
if unit is None: if unit is None:
unit = units.ITEMS.name unit = units.ITEMS.name
return Ingredient(id=0, return Ingredient(id=-1,
line=ingredient.sentence, line=ingredient.sentence,
name=name, name=name,
quantity=quantity, quantity=quantity,

View file

@ -38,7 +38,7 @@ async def insert_ingredient(conn, ingredient: Ingredient):
ingredient.product_id = ingredient.product.id ingredient.product_id = ingredient.product.id
if ingredient.product_id < 0: if ingredient.product_id < 0:
raise ValueError('Product must be inserted before ingredient') ingredient.product_id = None
async with conn.execute(''' async with conn.execute('''
INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id) INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)

111
main.py
View file

@ -3,7 +3,7 @@ import products, recipes, db, meals, persons, ingredients, shopping
import datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Annotated, Optional, Union from typing import Dict, List, Annotated, Optional, Union
from fastapi import FastAPI, Depends, Query, Cookie from fastapi import FastAPI, Depends, Query, Cookie
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
@ -109,10 +109,6 @@ async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depen
if not recipe.ingredients: if not recipe.ingredients:
return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'}) return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'})
for ingredient in recipe.ingredients:
if not ingredient.product:
return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'})
if recipe.id >= 0: if recipe.id >= 0:
await recipes.hide_recipe(conn, recipe.id, user) await recipes.hide_recipe(conn, recipe.id, user)
recipe.based_on_recipe = recipe.id recipe.based_on_recipe = recipe.id
@ -122,7 +118,9 @@ async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depen
await recipes.insert_recipe(conn, recipe) await recipes.insert_recipe(conn, recipe)
for ingredient in recipe.ingredients: for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id ingredient.recipe_id = recipe.id
ingredient.product_id = ingredient.product.id if ingredient.product:
ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient) await ingredients.insert_ingredient(conn, ingredient)
await conn.commit() await conn.commit()
@ -236,82 +234,111 @@ async def mark_consumed(meal_id: int, consumed_date: Optional[datetime.datetime]
return JSONResponse(status_code=404, content={'message': 'Meal not found'}) return JSONResponse(status_code=404, content={'message': 'Meal not found'})
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
await shopping.unrequest_meal(conn, meal) await shopping.remove_request(conn, person, meal=meal)
await conn.commit() await conn.commit()
return meal return meal
@app.delete("/api/meals/{meal_id}") @app.delete("/api/meals/{meal_id}")
async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal: async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal:
meal = await meals.find_meal_by_id(conn, meal_id) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: if not meal:
return JSONResponse(status_code=404, content={'message': 'Meal not found'}) return JSONResponse(status_code=404, content={'message': 'Meal not found'})
await shopping.remove_request(conn, person, meal=meal)
await meals.delete_meal(conn, meal.id) await meals.delete_meal(conn, meal.id)
await shopping.unrequest_meal(conn, meal)
await conn.commit() await conn.commit()
return meal return meal
class CurrentShoppingList(BaseModel): class CurrentShoppingList(BaseModel):
requests: List[shopping.ShoppingListRequest] outstanding_items: List[shopping.ShoppingListItem]
overlapping_previous_shops: List[shopping.ShoppingList] requested_meals: List[shopping.ShoppingListItem]
purchased_items: List[shopping.ShoppingListItem] = []
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
meals_lookup: Dict[int, meals.Meal] = {}
shopping_list_lookup: Dict[int, shopping.ShoppingList] = {}
recipes_lookup: Dict[int, recipes.Recipe] = {}
@app.get("/api/shopping/current") @app.get("/api/shopping/current")
async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList: async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList:
current_requests = [r async for r in shopping.get_current_requests(conn)] outstanding_requests, purchased_requests, meal_requests = await shopping.get_outstanding_requests(conn)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
requested_meals = [r.meal_id for r in current_requests if r.meal_id] shopping_list_lookup = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids }
upcoming_meals = [m.id async for m in meals.find_upcoming_meals_by_date_range(conn, datetime.datetime.now().astimezone(), datetime.datetime.now().astimezone() + datetime.timedelta(days=14))]
overlapping = {} # Reduce the data structure to items and lookups
for meal_id in set(requested_meals + upcoming_meals): items = meal_requests + outstanding_requests + purchased_requests + [item for sl in shopping_list_lookup.values() for item in sl.items]
async for r in shopping.get_shopping_list_with_meal(conn, meal_id): meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(items)
overlapping[r.id] = r
return CurrentShoppingList(requests=current_requests, overlapping_previous_shops=list(overlapping.values())) return CurrentShoppingList(
outstanding_items=outstanding_requests,
requested_meals=meal_requests,
purchased_items=purchased_requests,
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup
)
class PurchasedShoppingList(BaseModel):
list: shopping.ShoppingList
meals_lookup: Dict[int, meals.Meal] = {}
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
recipes_lookup: Dict[int, recipes.Recipe] = {}
@app.get("/api/shopping/{list_id}") @app.get("/api/shopping/{list_id}")
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList:
return await shopping.load_shopping_list(conn, list_id) shopping_list = await shopping.load_shopping_list(conn, list_id)
meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(shopping_list.items)
class ShoppingListPurchase(shopping.ShoppingList): return PurchasedShoppingList(list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup)
completed_requests: List[shopping.ShoppingListRequest] = []
@app.post("/api/shopping/") @app.post("/api/shopping/")
async def purchase_ingredients(lst: ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
await shopping.insert_shopping_list(conn, lst) shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name)
for request in lst.completed_requests:
if request.meal_id:
meal = await meals.find_meal_by_id(conn, request.meal_id)
if meal:
await meals.mark_purchased(conn, meal)
await shopping.remove_request(conn, request)
await shopping.purchase(conn, shopping_list)
await conn.commit() await conn.commit()
return lst
result = PurchasedShoppingList(list=shopping_list)
shopping.remove_references(shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup)
return result
@app.get("/api/shopping/current/me/ingredients") @app.get("/api/shopping/current/me/ingredients")
async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]: async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
return [r async for r in shopping.get_current_requests(conn) if r.ingredient and r.person_id == person.id] return [r.ingredient async for r in shopping.get_persons_requests(conn, person.id)]
@app.post("/api/shopping/current/me/ingredients") @app.post("/api/shopping/current/me/ingredients")
async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]: async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
result = [r async for r in shopping.sync_persons_requested_ingredients(conn, person, requests) if r.ingredient] def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
return a.id == b.id or a.line == b.line
my_shopping_list = [r.ingredient async for r in shopping.get_persons_requests(conn, person.id) if r.ingredient is not None]
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
for r in to_remove:
await shopping.remove_request(conn, person, ingredient=r)
for r in to_add:
if r.id < 0:
await ingredients.insert_ingredient(conn, r)
await shopping.request(conn, person, ingredient=r)
await conn.commit() await conn.commit()
return result return await get_my_shopping_list(conn, person)
class MealIdWrapper(BaseModel): class MealIdWrapper(BaseModel):
meal_id: int meal_id: int
@app.post("/api/shopping/current/meals/me") @app.post("/api/shopping/current/meals/me")
async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListRequest: async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem:
meal = await meals.find_meal_by_id(conn, r.meal_id) meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal: if not meal:
return JSONResponse(status_code=404, content={'message': 'Meal not found'}) return JSONResponse(status_code=404, content={'message': 'Meal not found'})
response = await shopping.request_meal(conn, person, meal) response = await shopping.request(conn, person, meal=meal)
await conn.commit() await conn.commit()
return response return response
@ -321,7 +348,7 @@ async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db
if not meal: if not meal:
return JSONResponse(status_code=404, content={'message': 'Meal not found'}) return JSONResponse(status_code=404, content={'message': 'Meal not found'})
await shopping.unrequest_meal(conn, meal) await shopping.remove_request(conn, person, meal=meal)
await conn.commit() await conn.commit()
return {} return {}

View file

@ -1,2 +1,53 @@
from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, sync_persons_requested_ingredients, load_shopping_list, get_current_requests, request_meal, unrequest_meal, insert_shopping_list, get_shopping_list_with_meal, remove_request from typing import Any, AsyncIterator, Dict, Iterator, List, Tuple
from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request
from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients
def remove_references(items: List[ShoppingListItem], meals_lookup = None, recipes_lookup = None, ingredients_lookup = None) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]:
meals_lookup = meals_lookup or {}
recipes_lookup = recipes_lookup or {}
ingredients_lookup = ingredients_lookup or {}
for item in items:
if item.meal and not item.meal.id in meals_lookup:
meals_lookup[item.meal.id] = item.meal
item.meal = None
if item.ingredient and not item.ingredient.id in ingredients_lookup:
ingredients_lookup[item.ingredient.id] = item.ingredient
item.ingredient = None
if item.recipe and not item.recipe.id in recipes_lookup:
recipes_lookup[item.recipe.id] = item.recipe
item.recipe = None
return meals_lookup, recipes_lookup, ingredients_lookup
async def get_persons_requests(conn, person_id: int) -> AsyncIterator[ShoppingListItem]:
async for item in _find_items_by_list_id(conn, None):
if item.person_id == person_id and item.ingredient_id is not None:
yield item
def flatten_items(items: Iterator[ShoppingListItem]) -> Iterator[ShoppingListItem]:
for item in items:
if item.meal:
for mealRecipe in item.meal.recipes:
for ingredient in mealRecipe.recipe.ingredients:
yield ShoppingListItem(ingredient=ingredient, meal=item.meal, recipe=mealRecipe.recipe, person_id=item.person_id, created_date=item.created_date)
for ingredient in item.meal.extra_ingredients:
yield ShoppingListItem(ingredient=ingredient, meal=item.meal, person_id=item.person_id, created_date=item.created_date)
else:
yield item
async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem], List[ShoppingListItem]]:
current_requests = [r async for r in _find_items_by_list_id(conn, None)]
meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0 and r.meal is not None]
meals = {r.meal_id: r.meal for r in meal_requests}
purchased_ingredients = {r.ingredient_id async for r in _get_purchased_ingredients(conn, list(meals.keys()))}
flattened = flatten_items(current_requests)
outstanding_items = [r for r in flattened if r.ingredient_id not in purchased_ingredients]
purchased_items = [r for r in flattened if r.ingredient_id in purchased_ingredients]
return outstanding_items, purchased_items, meal_requests

View file

@ -1,38 +1,33 @@
from meals import Meal, find_meal_by_id 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 ingredients import Ingredient, insert_ingredient
from persons import Person from persons import Person
from products import Product from products import Product
from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Optional from typing import AsyncIterator, List, ClassVar, Optional
from datetime import datetime from datetime import datetime
class ShoppingListRequest(BaseModel): class ShoppingListItem(BaseLinkedModel):
KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'created_date'] KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'created_date']
id: int = -1 id: int = -1
list_id: Optional[int] = None list_id: Optional[int] = None
person_id: int = -1
person: Optional[Person] = None
ingredient_id: Optional[int] = None ingredient_id: Optional[int] = None
ingredient: Optional[Ingredient] = None ingredient: Optional[Ingredient] = None
person_id: Optional[int] = None recipe_id: Optional[int] = None
person: Optional[Person] = None recipe: Optional[Recipe] = None
meal_id: Optional[int] = None meal_id: Optional[int] = None
meal: Optional[Meal] = None meal: Optional[Meal] = None
created_date: datetime = datetime.now().astimezone() created_date: datetime = datetime.now().astimezone()
class ShoppingListResult(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit' ]
id: int = -1
list_id: int
product_id: int
product: Optional[Product] = None
quantity: float
unit: str
from enum import Enum from enum import Enum
@ -41,14 +36,14 @@ class StoreEnum(str, Enum):
coles = 'coles' coles = 'coles'
home = '' home = ''
class ShoppingList(BaseModel): class ShoppingList(BaseLinkedModel):
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name'] KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name']
id: int = -1 id: int = -1
created_date: datetime = datetime.now().astimezone() created_date: datetime = datetime.now().astimezone()
store_name: StoreEnum = '' store_name: StoreEnum = ''
purchased_by_id: int = -1
requests: List[ShoppingListRequest] = [] purchased_by: Optional[Person] = None
results: List[ShoppingListResult] = [] items: List[ShoppingListItem] = []
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute('''
@ -56,123 +51,186 @@ async def create(conn):
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
created_date DATETIME NOT NULL, created_date DATETIME NOT NULL,
store_name TEXT NOT NULL, store_name TEXT NOT NULL,
purchased_by_id INTEGER,
FOREIGN KEY(purchased_by_id) REFERENCES Person(id)
);''') );''')
await conn.execute(''' await conn.execute('''
CREATE TABLE IF NOT EXISTS ShoppingListRequest ( CREATE TABLE IF NOT EXISTS ShoppingListItem (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ingredient_id INTEGER, ingredient_id INTEGER,
list_id INTEGER, list_id INTEGER,
person_id INTEGER, person_id INTEGER,
meal_id INTEGER, meal_id INTEGER,
recipe_id INTEGER,
created_date DATETIME NOT NULL, created_date DATETIME NOT NULL,
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id), FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id),
FOREIGN KEY(person_id) REFERENCES Person(id), FOREIGN KEY(person_id) REFERENCES Person(id),
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id) FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
);''') );''')
await conn.execute(''' def validate_request(request: ShoppingListItem) -> None:
CREATE TABLE IF NOT EXISTS ShoppingListResult ( if request.person_id < 0:
id INTEGER PRIMARY KEY, raise ValueError('Requests must have a person')
product_id INTEGER,
list_id INTEGER,
quantity REAL,
unit TEXT,
FOREIGN KEY(product_id) REFERENCES Product(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id)
);''')
def validate_request(request: ShoppingListRequest) -> None:
# A request must always have a list id
if request.list_id < 0:
raise ValueError('Request must have a list id')
# A request must have either an ingredient or a meal, but not both # A request must have either an ingredient or a meal, but not both
if not request.ingredient and not request.meal: if not request.ingredient and not request.meal:
raise ValueError('Request must have either an ingredient or a meal') raise ValueError('Request must have either an ingredient or a meal')
if request.ingredient and request.meal: async def purchase(conn, shopping_list: ShoppingList) -> None:
raise ValueError('Request cannot have both an ingredient and a meal') if shopping_list.purchased_by_id is None:
raise ValueError('Shopping list must have a person id')
# If an ingredient is provided, it must have a person if shopping_list.items is None or len(shopping_list.items) == 0:
if request.ingredient and not request.person: raise ValueError('Shopping list must have items')
raise ValueError('Ingredient requests must have a person')
if shopping_list.purchased_by_id < 0:
raise ValueError('Shopping list must have a valid person id')
async def insert_shopping_list(conn, shopping_list: ShoppingList):
shopping_list.created_date = datetime.now().astimezone() shopping_list.created_date = datetime.now().astimezone()
async with conn.execute(''' async with conn.execute('''
INSERT INTO ShoppingList (created_date, store_name) INSERT INTO ShoppingList (created_date, store_name, purchased_by_id)
VALUES (?, ?) VALUES (?, ?, ?)
''', (shopping_list.created_date.isoformat(), shopping_list.store_name,)) as cursor: ''', (shopping_list.created_date.isoformat(), shopping_list.store_name, shopping_list.purchased_by_id)) as cursor:
shopping_list.id = cursor.lastrowid shopping_list.id = cursor.lastrowid
for request in shopping_list.requests: for item in shopping_list.items:
request.list_id = shopping_list.id
validate_request(request)
if request.ingredient and request.ingredient.id < 0:
await insert_ingredient(conn, request.ingredient)
if request.ingredient:
request.ingredient_id = request.ingredient.id
if request.meal:
request.meal_id = request.meal.id
async with conn.execute('''
INSERT INTO ShoppingListRequest (ingredient_id, list_id, person_id, meal_id, created_date)
VALUES (?, ?, ?, ?, ?)
''', (request.ingredient_id, shopping_list.id, request.person_id, request.meal_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
for item in shopping_list.results:
item.product_id = item.product.id
item.list_id = shopping_list.id 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) -> bool:
if meal is not None:
async with conn.execute(''' async with conn.execute('''
INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit) DELETE FROM ShoppingListItem
VALUES (?, ?, ?, ?) WHERE list_id IS NULL AND meal_id = ?
''', (item.product_id, item.list_id, item.quantity, item.unit)) as cursor: ''', (meal.id,)) as cursor:
item.id = cursor.lastrowid return cursor.rowcount > 0
async def remove_request(conn, request: ShoppingListRequest) -> None: elif ingredient is not None:
if request.list_id != None: async with conn.execute('''
raise ValueError('Request is already completed') DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ?
''', (ingredient.id, person.id)) as cursor:
return cursor.rowcount > 0
if request.meal and not request.meal_id: raise ValueError('Must specify either a meal or an ingredient to remove')
raise ValueError('Meal request must have a meal id')
if request.meal_id != None: async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE meal_id = ? AND list_id IS NULL
''', (request.meal_id,))
elif request.person_id != None and request.ingredient_id != None:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE person_id = ? AND ingredient_id = ? AND list_id IS NULL
''', (request.person_id, request.ingredient_id))
else:
raise ValueError('Request is invalid')
async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListRequest]:
# Join Ingredient and Product to also load ingredient and product # Join Ingredient and Product to also load ingredient and product
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
product_keys = [f'product.{key}' for key in Product.KEYS] product_keys = [f'product.{key}' for key in Product.KEYS]
request_keys = [f'shoppinglistrequest.{key}' for key in ShoppingListRequest.KEYS] request_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS]
person_keys = [f'person.{key}' for key in Person.KEYS] person_keys = [f'person.{key}' for key in Person.KEYS]
select = f''' select = f'''
SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)} SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)}
FROM ShoppingListRequest FROM ShoppingListItem
LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id LEFT JOIN Ingredient ON ShoppingListItem.ingredient_id = Ingredient.id
LEFT JOIN Product ON Ingredient.product_id = Product.id LEFT JOIN Product ON Ingredient.product_id = Product.id
LEFT JOIN Person ON ShoppingListRequest.person_id = Person.id LEFT JOIN Person ON ShoppingListItem.person_id = Person.id
''' '''
where, params = ' WHERE list_id IS NULL', () where, params = ' WHERE list_id IS NULL', ()
@ -191,39 +249,14 @@ async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterato
person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])} 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 person = Person(**person_keys) if person_keys['id'] else None
request_keys = {k:v for k,v in zip(ShoppingListRequest.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])} request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])}
request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person) request = ShoppingListItem(**request_keys, ingredient=ingredient, person=person)
if request.meal_id is not None: if request.meal_id is not None:
request.meal = await find_meal_by_id(conn, request.meal_id) request.meal = await find_meal_by_id(conn, request.meal_id)
yield request yield request
async def find_results_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListResult]:
product_keys = [f'product.{key}' for key in Product.KEYS]
result_keys = [f'shoppinglistresult.{key}' for key in ShoppingListResult.KEYS]
async with conn.execute(f'''
SELECT {','.join(product_keys + result_keys)}
FROM ShoppingListResult
LEFT JOIN Product ON ShoppingListResult.product_id = Product.id
WHERE list_id = ?
''', (list_id,)) as cursor:
async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[:len(Product.KEYS)])}
product = Product(**product_keys) if product_keys['id'] else None
result_keys = {k:v for k,v in zip(ShoppingListResult.KEYS, row[len(Product.KEYS):])}
result = ShoppingListResult(**result_keys, product=product)
yield result
async def fill_related(conn, shopping_list: ShoppingList) -> ShoppingList:
async for request in find_requests_by_list_id(conn, shopping_list.id):
shopping_list.requests.append(request)
async for item in find_results_by_list_id(conn, shopping_list.id):
shopping_list.results.append(item)
async def load_shopping_list(conn, id: int) -> ShoppingList: async def load_shopping_list(conn, id: int) -> ShoppingList:
shopping_list = None shopping_list = None
async with conn.execute(f''' async with conn.execute(f'''
@ -236,61 +269,19 @@ async def load_shopping_list(conn, id: int) -> ShoppingList:
break break
if shopping_list: if shopping_list:
await fill_related(conn, shopping_list) async for item in find_items_by_list_id(conn, shopping_list.id):
shopping_list.items.append(item)
return shopping_list return shopping_list
async def request_ingredient(conn, person: Person, ingredient: Ingredient) -> ShoppingListRequest: async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
if ingredient.id >= 0: if not meal_ids:
raise ValueError('How did you get an existing ingredient?') return
await insert_ingredient(conn, ingredient) async with conn.execute(f'''
SELECT {','.join(ShoppingListItem.KEYS)}
request = ShoppingListRequest(ingredient_id=ingredient.id, ingredient=ingredient, person_id=person.id, created_date=datetime.now().astimezone()) FROM ShoppingListItem
async with conn.execute(''' WHERE meal_id IN ({','.join(['?'] * len(meal_ids))})
INSERT INTO ShoppingListRequest (ingredient_id, person_id, created_date) ''', meal_ids) as cursor:
VALUES (?, ?, ?)
''', (request.ingredient_id, request.person_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
return request
async def request_meal(conn, person: Person, meal: Meal) -> ShoppingListRequest:
request = ShoppingListRequest(meal_id=meal.id, meal=meal, person_id=person.id, created_date=datetime.now().astimezone())
async with conn.execute('''
INSERT INTO ShoppingListRequest (meal_id, person_id, created_date)
VALUES (?, ?, ?)
''', (request.meal_id, request.person_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
return request
async def unrequest_meal(conn, meal: Meal) -> None:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE list_id IS NULL AND meal_id = ?
''', (meal.id,))
def get_current_requests(conn) -> AsyncIterator[ShoppingListRequest]:
return find_requests_by_list_id(conn, None)
async def sync_persons_requested_ingredients(conn, person: Person, requests: List[Ingredient]) -> AsyncIterator[ShoppingListRequest]:
# Delete existing and insert all as new
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE list_id IS NULL AND person_id = ? AND ingredient_id IS NOT NULL
''', (person.id,))
for ingredient in requests:
ingredient.id = -1
yield await request_ingredient(conn, person, ingredient)
async def get_shopping_list_with_meal(conn, meal_id: int) -> AsyncIterator[ShoppingList]:
async with conn.execute('''
SELECT list_id FROM ShoppingListRequest
WHERE meal_id = ? AND list_id IS NOT NULL
''', (meal_id,)) as cursor:
async for row in cursor: async for row in cursor:
list_id = row[0] yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)})
yield await load_shopping_list(conn, list_id)