Allowed for null products, move ingredient spreading server-side #1
7 changed files with 319 additions and 227 deletions
23
common.py
Normal file
23
common.py
Normal 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
2
db.py
|
|
@ -1,6 +1,6 @@
|
|||
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)
|
||||
|
||||
async def create(conn: aiosqlite.Connection):
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
|
|||
if unit is None:
|
||||
unit = units.ITEMS.name
|
||||
|
||||
return Ingredient(id=0,
|
||||
return Ingredient(id=-1,
|
||||
line=ingredient.sentence,
|
||||
name=name,
|
||||
quantity=quantity,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ async def insert_ingredient(conn, ingredient: Ingredient):
|
|||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
if ingredient.product_id < 0:
|
||||
raise ValueError('Product must be inserted before ingredient')
|
||||
ingredient.product_id = None
|
||||
|
||||
async with conn.execute('''
|
||||
INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)
|
||||
|
|
|
|||
109
main.py
109
main.py
|
|
@ -3,7 +3,7 @@ import products, recipes, db, meals, persons, ingredients, shopping
|
|||
import datetime
|
||||
|
||||
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.responses import JSONResponse
|
||||
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:
|
||||
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:
|
||||
await recipes.hide_recipe(conn, recipe.id, user)
|
||||
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)
|
||||
for ingredient in recipe.ingredients:
|
||||
ingredient.recipe_id = recipe.id
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
await ingredients.insert_ingredient(conn, ingredient)
|
||||
|
||||
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'})
|
||||
|
||||
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()
|
||||
return meal
|
||||
|
||||
@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)
|
||||
if not meal:
|
||||
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 shopping.unrequest_meal(conn, meal)
|
||||
|
||||
await conn.commit()
|
||||
return meal
|
||||
|
||||
class CurrentShoppingList(BaseModel):
|
||||
requests: List[shopping.ShoppingListRequest]
|
||||
overlapping_previous_shops: List[shopping.ShoppingList]
|
||||
outstanding_items: List[shopping.ShoppingListItem]
|
||||
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")
|
||||
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]
|
||||
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))]
|
||||
shopping_list_lookup = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids }
|
||||
|
||||
overlapping = {}
|
||||
for meal_id in set(requested_meals + upcoming_meals):
|
||||
async for r in shopping.get_shopping_list_with_meal(conn, meal_id):
|
||||
overlapping[r.id] = r
|
||||
# Reduce the data structure to items and lookups
|
||||
items = meal_requests + outstanding_requests + purchased_requests + [item for sl in shopping_list_lookup.values() for item in sl.items]
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(items)
|
||||
|
||||
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}")
|
||||
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
|
||||
return await shopping.load_shopping_list(conn, list_id)
|
||||
|
||||
class ShoppingListPurchase(shopping.ShoppingList):
|
||||
completed_requests: List[shopping.ShoppingListRequest] = []
|
||||
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList:
|
||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(shopping_list.items)
|
||||
return PurchasedShoppingList(list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup)
|
||||
|
||||
@app.post("/api/shopping/")
|
||||
async def purchase_ingredients(lst: ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
|
||||
await shopping.insert_shopping_list(conn, lst)
|
||||
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)
|
||||
async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
|
||||
shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name)
|
||||
|
||||
await shopping.purchase(conn, shopping_list)
|
||||
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")
|
||||
async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]:
|
||||
return [r async for r in shopping.get_current_requests(conn) if r.ingredient and r.person_id == person.id]
|
||||
async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
|
||||
return [r.ingredient async for r in shopping.get_persons_requests(conn, person.id)]
|
||||
|
||||
@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]:
|
||||
result = [r async for r in shopping.sync_persons_requested_ingredients(conn, person, requests) if r.ingredient]
|
||||
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]:
|
||||
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()
|
||||
return result
|
||||
return await get_my_shopping_list(conn, person)
|
||||
|
||||
class MealIdWrapper(BaseModel):
|
||||
meal_id: int
|
||||
|
||||
@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)
|
||||
if not meal:
|
||||
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()
|
||||
return response
|
||||
|
||||
|
|
@ -321,7 +348,7 @@ async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db
|
|||
if not meal:
|
||||
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()
|
||||
return {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
329
shopping/db.py
329
shopping/db.py
|
|
@ -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 persons import Person
|
||||
from products import Product
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, List, ClassVar, Optional
|
||||
|
||||
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']
|
||||
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
|
||||
|
||||
person_id: Optional[int] = None
|
||||
person: Optional[Person] = 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()
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -41,14 +36,14 @@ class StoreEnum(str, Enum):
|
|||
coles = 'coles'
|
||||
home = ''
|
||||
|
||||
class ShoppingList(BaseModel):
|
||||
class ShoppingList(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name']
|
||||
id: int = -1
|
||||
created_date: datetime = datetime.now().astimezone()
|
||||
store_name: StoreEnum = ''
|
||||
|
||||
requests: List[ShoppingListRequest] = []
|
||||
results: List[ShoppingListResult] = []
|
||||
purchased_by_id: int = -1
|
||||
purchased_by: Optional[Person] = None
|
||||
items: List[ShoppingListItem] = []
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
|
|
@ -56,123 +51,186 @@ async def create(conn):
|
|||
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 ShoppingListRequest (
|
||||
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(list_id) REFERENCES ShoppingList(id)
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS ShoppingListResult (
|
||||
id INTEGER PRIMARY KEY,
|
||||
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')
|
||||
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')
|
||||
|
||||
if request.ingredient and request.meal:
|
||||
raise ValueError('Request cannot have both an ingredient and 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 an ingredient is provided, it must have a person
|
||||
if request.ingredient and not request.person:
|
||||
raise ValueError('Ingredient requests must have a person')
|
||||
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')
|
||||
|
||||
async def insert_shopping_list(conn, shopping_list: ShoppingList):
|
||||
shopping_list.created_date = datetime.now().astimezone()
|
||||
|
||||
async with conn.execute('''
|
||||
INSERT INTO ShoppingList (created_date, store_name)
|
||||
VALUES (?, ?)
|
||||
''', (shopping_list.created_date.isoformat(), shopping_list.store_name,)) as cursor:
|
||||
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 request in shopping_list.requests:
|
||||
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
|
||||
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 ShoppingListResult (product_id, list_id, quantity, unit)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (item.product_id, item.list_id, item.quantity, item.unit)) as cursor:
|
||||
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
|
||||
|
||||
async def remove_request(conn, request: ShoppingListRequest) -> None:
|
||||
if request.list_id != None:
|
||||
raise ValueError('Request is already completed')
|
||||
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)
|
||||
|
||||
if request.meal and not request.meal_id:
|
||||
raise ValueError('Meal request must have a meal id')
|
||||
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
||||
if request.meal_id != None:
|
||||
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('''
|
||||
DELETE FROM ShoppingListRequest
|
||||
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
|
||||
''', (request.meal_id,))
|
||||
''', (meal.id,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] > 0
|
||||
|
||||
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))
|
||||
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')
|
||||
|
||||
else:
|
||||
raise ValueError('Request is invalid')
|
||||
if ingredient is None and meal is None:
|
||||
raise ValueError('Must specify either an ingredient or a meal to request')
|
||||
|
||||
async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListRequest]:
|
||||
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('''
|
||||
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]:
|
||||
# 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'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]
|
||||
|
||||
select = f'''
|
||||
SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)}
|
||||
FROM ShoppingListRequest
|
||||
LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id
|
||||
FROM ShoppingListItem
|
||||
LEFT JOIN Ingredient ON ShoppingListItem.ingredient_id = Ingredient.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', ()
|
||||
|
|
@ -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 = 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 = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person)
|
||||
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 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:
|
||||
shopping_list = None
|
||||
async with conn.execute(f'''
|
||||
|
|
@ -236,61 +269,19 @@ async def load_shopping_list(conn, id: int) -> ShoppingList:
|
|||
break
|
||||
|
||||
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
|
||||
|
||||
async def request_ingredient(conn, person: Person, ingredient: Ingredient) -> ShoppingListRequest:
|
||||
if ingredient.id >= 0:
|
||||
raise ValueError('How did you get an existing ingredient?')
|
||||
async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
||||
await insert_ingredient(conn, ingredient)
|
||||
|
||||
request = ShoppingListRequest(ingredient_id=ingredient.id, ingredient=ingredient, person_id=person.id, created_date=datetime.now().astimezone())
|
||||
async with conn.execute('''
|
||||
INSERT INTO ShoppingListRequest (ingredient_id, person_id, created_date)
|
||||
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 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:
|
||||
list_id = row[0]
|
||||
yield await load_shopping_list(conn, list_id)
|
||||
yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)})
|
||||
Loading…
Reference in a new issue