Compare commits

..

10 commits

13 changed files with 477 additions and 168 deletions

View file

@ -1,48 +1,68 @@
from ingredients.db import Ingredient, find_ingredients_by_meal_id, find_ingredients_by_recipe_id, insert_ingredient, delete_ingredients_by_meal_id from ingredients.db import Ingredient, find_ingredients_by_meal_id, find_ingredients_by_recipe_id, insert_ingredient, delete_ingredients_by_meal_id
import units import units
from products import Product, find_product_by_tag from products import Product, find_product_by_tag, get_or_create, add_missing_tags
from ingredient_parser import parse_multiple_ingredients from ingredient_parser import parse_ingredient
import re
from typing import List from typing import List
def parse_ingredient_from_nlp(ingredients: List[str]) -> Ingredient: async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
results = [] match = re.match(r'^(\d+)?\s*(http.*)$', link)
for ingredient in parse_multiple_ingredients(ingredients): if not match:
name = ingredient.name.text if ingredient.name else '' return None
quantity, unit = None, None quantity = int(match.group(1)) if match.group(1) else 1
for amount in ingredient.amount: url = match.group(2)
if quantity is None and amount.quantity: product = await get_or_create(conn, url, [])
quantity = amount.quantity if product:
await add_missing_tags(conn, product, [product.name])
if unit is None and amount.unit: return Ingredient(id=-1,
real_unit = units.get_unit(amount.unit) name=product.name,
if real_unit: line=f"{quantity}x {product.name}",
unit = real_unit.name unit=units.ITEMS.name,
if isinstance(quantity, str):
try:
quantity = float(quantity)
except ValueError:
pass
if quantity is None or not isinstance(quantity, (int, float)):
quantity = 1
if unit is None:
unit = units.ITEMS.name
results.append(Ingredient(id=0,
line=ingredient.sentence,
name=name,
quantity=quantity, quantity=quantity,
unit=unit, preparation='',
preparation=ingredient.preparation.text if ingredient.preparation else '', product_id=product.id,
product_id=0 product=product
)) )
return results def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
ingredient = parse_ingredient(ingredient_string)
name = ingredient.name.text if ingredient.name else ''
quantity, unit = None, None
for amount in ingredient.amount:
if quantity is None and amount.quantity:
quantity = amount.quantity
if unit is None and amount.unit:
real_unit = units.get_unit(amount.unit)
if real_unit:
unit = real_unit.name
if isinstance(quantity, str):
try:
quantity = float(quantity)
except ValueError:
pass
if quantity is None or not isinstance(quantity, (int, float)):
quantity = 1
if unit is None:
unit = units.ITEMS.name
return Ingredient(id=0,
line=ingredient.sentence,
name=name,
quantity=quantity,
unit=unit,
preparation=ingredient.preparation.text if ingredient.preparation else '',
product_id=-1
)
async def _find_existing_product(conn, ingredient: str) -> Product: async def _find_existing_product(conn, ingredient: str) -> Product:
async for item in find_product_by_tag(conn, ingredient): async for item in find_product_by_tag(conn, ingredient):

View file

@ -5,7 +5,7 @@ from typing import AsyncIterator, List, ClassVar, Optional
class Ingredient(BaseModel): class Ingredient(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id'] KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id']
id: int id: int = -1
name: str name: str
line: str line: str
unit: str unit: str
@ -34,10 +34,10 @@ async def create(conn):
);''') );''')
async def insert_ingredient(conn, ingredient: Ingredient): async def insert_ingredient(conn, ingredient: Ingredient):
if not ingredient.product_id and ingredient.product: if ingredient.product:
ingredient.product_id = ingredient.product.id ingredient.product_id = ingredient.product.id
if not ingredient.product_id: if ingredient.product_id < 0:
raise ValueError('Product must be inserted before ingredient') raise ValueError('Product must be inserted before ingredient')
async with conn.execute(''' async with conn.execute('''

94
main.py
View file

@ -32,8 +32,8 @@ async def cookie_person(user_id: Annotated[int, Cookie(alias='user_id')], conn:
return await persons.get_by_id(conn, user_id) return await persons.get_by_id(conn, user_id)
@app.get("/recipes/parse") @app.get("/recipes/parse")
async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db)) -> recipes.Recipe: async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db), person = Depends(cookie_person)) -> recipes.Recipe:
parsed = await recipes.parse_recipe(conn, url) parsed = await recipes.parse_recipe(conn, person, url)
if not parsed: if not parsed:
return JSONResponse(status_code=400, content={'message': 'Recipe not found'}) return JSONResponse(status_code=400, content={'message': 'Recipe not found'})
return parsed return parsed
@ -44,7 +44,24 @@ async def parse_ingredients(lines: Annotated[
Query(alias="ingredients", Query(alias="ingredients",
title="Array of ingredients to parse")], title="Array of ingredients to parse")],
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]: conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]:
result = ingredients.parse_ingredient_from_nlp(lines)
had_links = False
result = []
for line in lines:
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
await conn.commit()
await ingredients.match_existing_products(conn, result) await ingredients.match_existing_products(conn, result)
return result return result
@ -95,29 +112,29 @@ async def get_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db))
return r return r
@app.post('/recipes/') @app.post('/recipes/')
async def create_recipe(item: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe: async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
if not item.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 item.ingredients: for ingredient in recipe.ingredients:
if not ingredient.product: if not ingredient.product:
return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'}) return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'})
if item.id: if recipe.id >= 0:
await recipes.hide_recipe(conn, item.id, user) await recipes.hide_recipe(conn, recipe.id, user)
item.based_on_recipe = item.id recipe.based_on_recipe = recipe.id
item.id = 0 recipe.id = 0
item.created_by_id = user.id recipe.created_by_id = user.id
await recipes.insert_recipe(conn, item) await recipes.insert_recipe(conn, recipe)
for ingredient in item.ingredients: for ingredient in recipe.ingredients:
ingredient.recipe_id = item.id ingredient.recipe_id = recipe.id
ingredient.product_id = ingredient.product.id ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient) await ingredients.insert_ingredient(conn, ingredient)
await conn.commit() await conn.commit()
return item return recipe
@app.delete('/recipes/{recipe_id}') @app.delete('/recipes/{recipe_id}')
async def delete_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe: async def delete_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
@ -146,10 +163,6 @@ async def get_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 meals.load_participants(conn, meal)
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
return meal return meal
def get_duplicates(items: List[meals.Person]) -> set[str]: def get_duplicates(items: List[meals.Person]) -> set[str]:
@ -228,17 +241,48 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db))
@app.get("/shopping/{list_id}") @app.get("/shopping/{list_id}")
async def get_shopping_list(list_id: Union[int, str], conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: async def get_shopping_list(list_id: Union[int, str], conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
if isinstance(list_id, str): if list_id.lower() == 'current':
if list_id.lower() != 'current':
return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'})
return await shopping.current_shopping_list(conn) return await shopping.current_shopping_list(conn)
try:
list_id = int(list_id)
except ValueError:
return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'})
return await shopping.load_shopping_list(conn, list_id) return await shopping.load_shopping_list(conn, list_id)
@app.post("/shopping/current/purchased")
async def mark_purchased(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingList:
response = await shopping.mark_purchased(conn)
# Its easier to make the next shopping list now, while we know calling it requires commit()
await shopping.current_shopping_list(conn)
await conn.commit()
return response
class FoundResult(BaseModel):
created: List[shopping.ShoppingListResult] = []
removed: List[shopping.ShoppingListResult] = []
@app.post("/shopping/current/found") @app.post("/shopping/current/found")
async def mark_shopping_list(ingredient: ingredients.Ingredient, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingListResult: async def mark_shopping_list(ingredients: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db)) -> FoundResult:
return await shopping.mark_found(conn, ingredient.product, ingredient.quantity, ingredient.unit) now = datetime.datetime.now()
result = FoundResult()
for ingredient in ingredients:
existing, created = await shopping.mark_found(conn, ingredient, now)
result.created.append(created)
if existing:
result.removed.append(existing)
await conn.commit()
return result
@app.delete("/shopping/current/found/{product_id}")
async def unmark_shopping_list(product_id: int, conn: sqlite3.Connection = Depends(get_db)) -> List[shopping.ShoppingListResult]:
response = await shopping.unmark_found(conn, product_id)
await conn.commit()
return response
@app.get("/shopping/current/me/ingredients") @app.get("/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[shopping.ShoppingListRequest]:

View file

@ -11,8 +11,9 @@ import datetime
class Meal(BaseModel): class Meal(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'meal_date'] KEYS: ClassVar[List[str]] = ['id', 'meal_date']
id: int id: int = -1
meal_date: datetime.datetime meal_date: datetime.datetime
purchase_date: Optional[datetime.datetime] = None
chefs: List[Person] = [] chefs: List[Person] = []
cleanup: List[Person] = [] cleanup: List[Person] = []
@ -60,7 +61,7 @@ async def sync_meal_participants(conn, meal_id: int, participants: List[Person],
await insert_meal_participant(conn, meal_id, person.id, role) await insert_meal_participant(conn, meal_id, person.id, role)
async def insert_meal_recipe(conn, meal_id: int, recipe_id: int): async def insert_meal_recipe(conn, meal_id: int, recipe_id: int):
if not recipe_id: if recipe_id < 0:
raise ValueError('Recipe must be inserted before meal') raise ValueError('Recipe must be inserted before meal')
await conn.execute(''' await conn.execute('''
@ -91,28 +92,20 @@ async def find_meal_by_id(conn, meal_id: int) -> Meal:
LIMIT 1 LIMIT 1
''', (meal_id,)) as cursor: ''', (meal_id,)) as cursor:
async for row in cursor: async for row in cursor:
meal = Meal(**{k:v for k,v in zip(Meal.KEYS, row)}) meal = await with_purchase_date(conn, Meal(**{k:v for k,v in zip(Meal.KEYS, row)}))
await load_participants(conn, meal) await load_participants(conn, meal)
await load_recipes(conn, meal) await load_recipes(conn, meal)
await load_extra_ingredients(conn, meal) await load_extra_ingredients(conn, meal)
return meal return meal
async def find_meal_by_date(conn, date: datetime) -> Meal:
async with conn.execute(f'''
SELECT {','.join(Meal.KEYS)} FROM Meal
WHERE meal_date = ?
LIMIT 1
''', (date,)) as cursor:
async for row in cursor:
return Meal(**{k:v for k,v in zip(Meal.KEYS, row)})
async def find_meals_by_date_range(conn, start: datetime, end: datetime) -> AsyncIterator[Meal]: async def find_meals_by_date_range(conn, start: datetime, end: datetime) -> AsyncIterator[Meal]:
async with conn.execute(f''' async with conn.execute(f'''
SELECT {','.join(Meal.KEYS)} FROM Meal SELECT {','.join(Meal.KEYS)} FROM Meal
WHERE meal_date >= ? AND meal_date <= ? WHERE meal_date >= ? AND meal_date <= ?
''', (start, end)) as cursor: ''', (start, end)) as cursor:
async for row in cursor: async for row in cursor:
yield Meal(**{k:v for k,v in zip(Meal.KEYS, row)}) yield await with_purchase_date(conn, Meal(**{k:v for k,v in zip(Meal.KEYS, row)}))
async def load_participants(conn, meal: Meal) -> None: async def load_participants(conn, meal: Meal) -> None:
async with conn.execute(f''' async with conn.execute(f'''
@ -198,3 +191,17 @@ async def update_meal(conn, meal: Meal) -> None:
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
await sync_recipes(conn, meal.id, meal.recipes) await sync_recipes(conn, meal.id, meal.recipes)
async def with_purchase_date(conn, meal: Meal) -> Meal:
async with conn.execute('''
SELECT purchased_date FROM ShoppingList
WHERE id = (
SELECT list_id FROM ShoppingListRequest
WHERE meal_id = ?
LIMIT 1
)
''', (meal.id,)) as cursor:
async for row in cursor:
meal.purchase_date = row[0]
return meal

View file

@ -4,7 +4,7 @@ from typing import AsyncIterator, ClassVar, List
class Person(BaseModel): class Person(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'name'] KEYS: ClassVar[List[str]] = ['id', 'name']
id: int id: int = -1
name: str name: str
async def create(conn): async def create(conn):

View file

@ -4,6 +4,16 @@ from products.db import Product, find_product_by_tag, find_product_by_product_id
from products.scraping import scrape_woolies_data, get_product_id, get_product_details_url from products.scraping import scrape_woolies_data, get_product_id, get_product_details_url
from typing import List from typing import List
import re
def get_package_size(data: dict) -> str:
size = data['Product']['PackageSize']
if size:
match = re.match(r'(\d+)(.*)', size)
if match:
return int(match.group(1)), match.group(2)
return 1, 'items'
async def create_product(link: str) -> Product: async def create_product(link: str) -> Product:
product_id = get_product_id(link) product_id = get_product_id(link)
@ -13,11 +23,16 @@ async def create_product(link: str) -> Product:
product_url = get_product_details_url(product_id) product_url = get_product_details_url(product_id)
product, data = None, await scrape_woolies_data(product_url) product, data = None, await scrape_woolies_data(product_url)
if data: if data:
product = Product( _dump_json_data_to_log(data, product_id)
quantity, unit = get_package_size(data)
product = Product(
id=0, id=0,
product_id=product_id, product_id=product_id,
name=data['Product']['Name'], name=data['Product']['Name'],
link=link, link=link,
quantity=quantity,
unit=unit,
img_small=data['Product']['SmallImageFile'], img_small=data['Product']['SmallImageFile'],
img_large=data['Product']['LargeImageFile'], img_large=data['Product']['LargeImageFile'],
) )
@ -54,3 +69,17 @@ async def get_or_create(conn, url: str, tags: List[str]) -> Product:
await add_missing_tags(conn, product, tags) await add_missing_tags(conn, product, tags)
return product return product
def _dump_json_data_to_log(data: dict, product_id: str) -> str:
import os, re
dir = './data/dump'
if not os.path.exists(dir):
os.makedirs(dir)
prefix = f'product_{product_id}'
suffix = '.json'
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)]
id = max(file_ids) + 1 if file_ids else 0
filename = f'{prefix}{id}{suffix}'
with open(os.path.join(dir, filename), 'w') as f:
json.dump(data, f, indent=4)

View file

@ -4,11 +4,15 @@ from pydantic import BaseModel
import json import json
class Product(BaseModel): class Product(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'link', 'name', 'img_small', 'img_large'] KEYS: ClassVar[List[str]] = ['id', 'product_id', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large']
id: int NON_INSERT_KEYS: ClassVar[List[str]] = ['id']
id: int = -1
product_id: str product_id: str
link: str link: str
name: str name: str
quantity: int
unit: str
img_small: str img_small: str
img_large: str img_large: str
@ -19,6 +23,8 @@ async def create(conn):
product_id TEXT UNIQUE, product_id TEXT UNIQUE,
link TEXT, link TEXT,
name TEXT, name TEXT,
quantity INTEGER,
unit TEXT,
img_small TEXT, img_small TEXT,
img_large TEXT, img_large TEXT,
raw_data TEXT raw_data TEXT
@ -62,10 +68,13 @@ async def find_product_by_product_id(conn, product_id: str) -> Product:
return Product(**{k:v for k,v in zip(Product.KEYS, row)}) return Product(**{k:v for k,v in zip(Product.KEYS, row)})
async def insert_product(conn, product: Product, data: dict): async def insert_product(conn, product: Product, data: dict):
async with conn.execute(''' insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS]
INSERT INTO Product (name, product_id, link, img_small, img_large, raw_data) insert_values = [getattr(product, k) for k in insert_keys]
VALUES (?, ?, ?, ?, ?, ?)
''', (product.name, product.product_id, product.link, product.img_small, product.img_large, json.dumps(data))) as cursor: async with conn.execute(f'''
INSERT INTO Product ({','.join(insert_keys)}, raw_data)
VALUES ({','.join(['?'] * len(insert_keys))}, ?)
''', (*insert_values, json.dumps(data))) as cursor:
product.id = cursor.lastrowid product.id = cursor.lastrowid
await conn.commit() await conn.commit()

View file

@ -1,18 +1,42 @@
from persons import Person
from recipes.db import Recipe, insert_recipe, find_recipe_by_id, get_all, find_recipes_by_name, row_to_recipe, load_recipe_ingredients, hide_recipe from recipes.db import Recipe, insert_recipe, find_recipe_by_id, get_all, find_recipes_by_name, row_to_recipe, load_recipe_ingredients, hide_recipe
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
from ingredients import parse_ingredient_from_nlp as _parse_ingredient_from_nlp, match_existing_products as _match_existing_products from ingredients import parse_ingredient_from_nlp, match_existing_products
async def parse_recipe(conn, url: str) -> Recipe: import re
async def parse_recipe(conn, created_by: Person, url: str) -> Recipe:
ldata = await _scrape_recipe_ldata(url) ldata = await _scrape_recipe_ldata(url)
if ldata: if ldata:
return await _get_recipe_from_ldata(conn, url, ldata) return await _get_recipe_from_ldata(conn, url, ldata, created_by)
return None return None
async def _get_recipe_from_ldata(conn, url: str, ldata: dict) -> dict: def find_yield(recipe_ldata: dict) -> int:
ingredients = _parse_ingredient_from_nlp(ldata['recipeIngredient']) if 'recipeYield' in recipe_ldata:
ingredients = await _match_existing_products(conn, ingredients) yield_vals = recipe_ldata['recipeYield']
if not isinstance(yield_vals, list):
yield_vals = [yield_vals]
for val in yield_vals:
try:
return int(val)
except ValueError:
pass
for val in yield_vals:
match = re.match(r'(\d+)', val)
if match:
return int(match.group(1))
return 4
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> dict:
ingredients = parse_ingredient_from_nlp(ldata['recipeIngredient'])
ingredients = await match_existing_products(conn, ingredients)
name = ldata['name'] if 'name' in ldata else url name = ldata['name'] if 'name' in ldata else url
images = ldata['image'] if 'image' in ldata else [] images = ldata['image'] if 'image' in ldata else []
serves = find_yield(ldata)
if isinstance(images, list) and len(images) > 0 and isinstance(images[0], dict): if isinstance(images, list) and len(images) > 0 and isinstance(images[0], dict):
images = [image['url'] for image in images] images = [image['url'] for image in images]
@ -27,6 +51,9 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict) -> dict:
id=0, id=0,
name=name, name=name,
link=url, link=url,
serves=serves,
image_urls=images, image_urls=images,
ingredients=ingredients ingredients=ingredients,
created_by=created_by,
created_by_id=created_by.id,
) )

View file

@ -7,16 +7,19 @@ from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Tuple, Optional from typing import AsyncIterator, List, ClassVar, Tuple, Optional
class Recipe(BaseModel): class Recipe(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden'] KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'serves', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden']
id: int NON_INSERT_KEYS: ClassVar[List[str]] = ['id', 'created_date', 'hidden_by_id', 'date_hidden']
id: int = -1
name: str name: str
link: str link: str
serves: int
image_urls: List[str] = [] image_urls: List[str] = []
ingredients: List[Ingredient] = [] ingredients: List[Ingredient] = []
based_on_recipe: Optional[int] = None based_on_recipe: Optional[int] = None
date_created: Optional[datetime.datetime] = None date_created: datetime.datetime = datetime.datetime.now()
created_by_id: Optional[int] = None created_by_id: Optional[int]
created_by: Optional[Person] = None created_by: Optional[Person] = None
date_hidden: Optional[datetime.datetime] = None date_hidden: Optional[datetime.datetime] = None
@ -29,6 +32,7 @@ async def create(conn):
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
link TEXT NOT NULL, link TEXT NOT NULL,
serves INTEGER NOT NULL,
image_urls TEXT NOT NULL, image_urls TEXT NOT NULL,
based_on_recipe INTEGER NULL, based_on_recipe INTEGER NULL,
@ -43,11 +47,22 @@ async def create(conn):
FOREIGN KEY (hidden_by_id) REFERENCES Person(id) FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
);''') );''')
def _as_insert_field(recipe: Recipe, name: str):
value = getattr(recipe, name)
if name == 'image_urls':
return json.dumps(value)
return value
async def insert_recipe(conn, recipe: Recipe): async def insert_recipe(conn, recipe: Recipe):
async with conn.execute(''' fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
INSERT INTO Recipe (name, link, image_urls, based_on_recipe, created_by_id) actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
VALUES (?, ?, ?, ?, ?)
''', (recipe.name, recipe.link, json.dumps(recipe.image_urls), recipe.based_on_recipe, recipe.created_by_id)) as cursor: insert_stmt = f'''
INSERT INTO Recipe ({','.join(fields_to_insert)})
VALUES ({','.join(['?'] * len(fields_to_insert))})
'''
async with conn.execute(insert_stmt, actual_values) as cursor:
recipe.id = cursor.lastrowid recipe.id = cursor.lastrowid
async def hide_recipe(conn, recipe_id: int, person: Person): async def hide_recipe(conn, recipe_id: int, person: Person):

View file

@ -1,2 +1,2 @@
from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, current_shopping_list, mark_found, sync_persons_requested_ingredients, load_shopping_list, get_persons_requests, request_meal, delete_requests from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, current_shopping_list, mark_found, unmark_found, sync_persons_requested_ingredients, load_shopping_list, get_persons_requests, request_meal, delete_requests, mark_purchased

View file

@ -4,13 +4,13 @@ from persons import Person
from products import Product from products import Product
from pydantic import BaseModel from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Optional from typing import AsyncIterator, List, ClassVar, Optional, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
class ShoppingListRequest(BaseModel): class ShoppingListRequest(BaseModel):
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 = 0 id: int = -1
list_id: int list_id: int
ingredient_id: Optional[int] = None ingredient_id: Optional[int] = None
@ -26,9 +26,10 @@ class ShoppingListRequest(BaseModel):
class ShoppingListResult(BaseModel): class ShoppingListResult(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit', 'created_date', 'found_date'] KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit', 'created_date', 'found_date']
id: int = 0 id: int = -1
product_id: int
list_id: int list_id: int
product_id: int
product: Optional[Product] = None
quantity: float quantity: float
unit: str unit: str
@ -38,12 +39,12 @@ class ShoppingListResult(BaseModel):
class ShoppingList(BaseModel): class ShoppingList(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'purchased_date'] KEYS: ClassVar[List[str]] = ['id', 'created_date', 'purchased_date']
id: int = 0 id: int = -1
created_date: datetime = datetime.now() created_date: datetime = datetime.now()
purchased_date: Optional[datetime] = None purchased_date: Optional[datetime] = None
requests: List[ShoppingListRequest] = [] requests: List[ShoppingListRequest] = []
items: List[ShoppingListResult] = [] results: List[ShoppingListResult] = []
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute('''
@ -74,45 +75,74 @@ async def create(conn):
list_id INTEGER, list_id INTEGER,
quantity REAL, quantity REAL,
unit TEXT, unit TEXT,
created_date TEXT, created_date TEXT DEFAULT CURRENT_TIMESTAMP,
found_date TEXT, found_date TEXT,
FOREIGN KEY(product_id) REFERENCES Product(id), FOREIGN KEY(product_id) REFERENCES Product(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(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
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')
# 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')
async def insert_shopping_list(conn, shopping_list: ShoppingList): async def insert_shopping_list(conn, shopping_list: ShoppingList):
async with conn.execute(''' async with conn.execute('''
INSERT INTO ShoppingList (id, created_date, purchased_date) INSERT INTO ShoppingList (created_date, purchased_date)
VALUES (?, ?, ?) VALUES (CURRENT_TIMESTAMP, NULL)
''', (shopping_list.id, shopping_list.created_date, shopping_list.purchased_date)) as cursor: ''') as cursor:
shopping_list.id = cursor.lastrowid shopping_list.id = cursor.lastrowid
for request in shopping_list.requests: for request in shopping_list.requests:
if not request.ingredient_id: request.list_id = shopping_list.id
validate_request(request)
if request.ingredient and request.ingredient.id < 0:
await insert_ingredient(conn, request.ingredient) await insert_ingredient(conn, request.ingredient)
request.ingredient_id = request.ingredient.id if request.ingredient:
await conn.execute(''' request.ingredient_id = request.ingredient.id
INSERT INTO ShoppingListRequest (id, ingredient_id, list_id, person_id, meal_id, created_date)
VALUES (?, ?, ?, ?, ?, ?)
''', (request.id, request.ingredient_id, shopping_list.id, request.person_id, request.meal_id, request.created_date))
for item in shopping_list.items: 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)) as cursor:
request.id = cursor.lastrowid
for item in shopping_list.results:
item.product_id = item.product.id item.product_id = item.product.id
await conn.execute(''' item.list_id = shopping_list.id
INSERT INTO ShoppingListResult (id, product_id, list_id, quantity, unit, created_date, found_date)
VALUES (?, ?, ?, ?, ?, ?, ?) async with conn.execute('''
''', (item.id, item.product_id, shopping_list.id, item.quantity, item.unit, item.created_date, item.found_date)) INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit, created_date, found_date)
VALUES (?, ?, ?, ?, ?, ?)
''', (item.product_id, item.list_id, item.quantity, item.unit, item.created_date, item.found_date)) as cursor:
item.id = cursor.lastrowid
async def find_request(conn, id: int) -> Optional[ShoppingListRequest]: async def find_request(conn, id: int) -> Optional[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]
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] ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
person_keys = [f'person.{key}' for key in Person.KEYS] person_keys = [f'person.{key}' for key in Person.KEYS]
request_keys = [f'shoppinglistrequest.{key}' for key in ShoppingListRequest.KEYS]
async with conn.execute(f''' async with conn.execute(f'''
SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)} SELECT {','.join(product_keys + ingredient_keys + person_keys + request_keys)}
FROM ShoppingListRequest FROM ShoppingListRequest
LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id
LEFT JOIN Product ON Ingredient.product_id = Product.id LEFT JOIN Product ON Ingredient.product_id = Product.id
@ -120,17 +150,21 @@ async def find_request(conn, id: int) -> Optional[ShoppingListRequest]:
WHERE ShoppingListRequest.id = ? WHERE ShoppingListRequest.id = ?
''', (id,)) as cursor: ''', (id,)) as cursor:
async for row in cursor: async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):len(Ingredient.KEYS) + len(Product.KEYS)])} product_keys = {k:v for k,v in zip(Product.KEYS, row[:len(Product.KEYS)])}
product = Product(**product_keys) if product_keys['id'] else None product = Product(**product_keys) if product_keys['id'] else None
ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])} ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[len(Product.KEYS):len(Product.KEYS) + len(Ingredient.KEYS)])}
ingredient = Ingredient(**ingredient_keys, product=product) ingredient = Ingredient(**ingredient_keys, product=product) if ingredient_keys['id'] else None
person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])} person_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(ShoppingListRequest.KEYS, row[len(Product.KEYS) + len(Ingredient.KEYS):-len(Person.KEYS)])}
request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person) request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person)
if request.meal_id is not None:
request.meal = await find_meal_by_id(conn, request.meal_id)
return request return request
async def find_requests_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListRequest]: async def find_requests_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListRequest]:
@ -162,7 +196,7 @@ async def find_requests_by_list_id(conn, list_id: int) -> AsyncIterator[Shopping
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(ShoppingListRequest.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])}
request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person) request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person)
if request.meal_id: 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
@ -190,7 +224,7 @@ async def fill_related(conn, shopping_list: ShoppingList) -> ShoppingList:
shopping_list.requests.append(request) shopping_list.requests.append(request)
async for item in find_items_by_list_id(conn, shopping_list.id): async for item in find_items_by_list_id(conn, shopping_list.id):
shopping_list.items.append(item) 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
@ -212,7 +246,8 @@ async def _upcoming_meals(conn) -> AsyncIterator[Meal]:
start = datetime.now() start = datetime.now()
end = start + timedelta(days=7) end = start + timedelta(days=7)
async for meal in find_meals_by_date_range(conn, start, end): async for meal in find_meals_by_date_range(conn, start, end):
yield meal if meal.purchase_date is None:
yield meal
async def current_shopping_list(conn) -> ShoppingList: async def current_shopping_list(conn) -> ShoppingList:
shopping_list = None shopping_list = None
@ -230,7 +265,7 @@ async def current_shopping_list(conn) -> ShoppingList:
shopping_list = ShoppingList() shopping_list = ShoppingList()
async for meal in _upcoming_meals(conn): async for meal in _upcoming_meals(conn):
shopping_list.requests.append(ShoppingListRequest(list_id=shopping_list.id, meal_id=meal.id, created_date=datetime.now(),)) shopping_list.requests.append(ShoppingListRequest(list_id=shopping_list.id, meal_id=meal.id, meal=meal, created_date=datetime.now(),))
await insert_shopping_list(conn, shopping_list) await insert_shopping_list(conn, shopping_list)
@ -296,26 +331,71 @@ async def sync_persons_requested_ingredients(conn, shopping_list: ShoppingList,
ingredient.id = 0 ingredient.id = 0
yield await request_ingredient(conn, shopping_list, person, ingredient) yield await request_ingredient(conn, shopping_list, person, ingredient)
async def mark_found(conn, product: Product, quantity: float, unit: str) -> ShoppingListResult: async def find_existing_result(conn, shopping_list: ShoppingList, product: Product, unit: str) -> Optional[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 ShoppingListResult.list_id = ? AND ShoppingListResult.product_id = ? AND ShoppingListResult.unit = ?
LIMIT 1
''', (shopping_list.id, product.id, unit)) 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):])}
return ShoppingListResult(**result_keys, product=product)
return None
async def mark_found(conn, ingredient: Ingredient, date_found: datetime) -> Tuple[ShoppingListResult, ShoppingListResult]:
product, quantity, unit = ingredient.product, ingredient.quantity, ingredient.unit
shopping_list = await current_shopping_list(conn) shopping_list = await current_shopping_list(conn)
existing = await find_existing_result(conn, product, shopping_list)
if existing: created = ShoppingListResult(product=product, product_id=product.id, list_id=shopping_list.id, quantity=quantity, unit=unit, found_date=date_found)
existing.quantity, existing.unit, existing.found_date = quantity, unit, datetime.now() removed = await find_existing_result(conn, shopping_list, product, unit)
async with conn.execute('''
INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit, created_date, found_date)
VALUES (?, ?, ?, ?, ?, ?)
''', (created.product_id, created.list_id, quantity, unit, created.created_date, created.found_date)) as cursor:
created.id = cursor.lastrowid
if removed:
shopping_list.results = [r for r in shopping_list.results if r.id != removed.id]
await conn.execute(''' await conn.execute('''
UPDATE ShoppingListResult DELETE FROM ShoppingListResult
SET quantity = ?, unit = ?, found_date = ?
WHERE id = ? WHERE id = ?
''', (existing.quantity, existing.unit, existing.found_date, existing.id)) ''', (removed.id,))
return existing return removed, created
else:
result = ShoppingListResult(product=product, product_id=product.id, list_id=shopping_list.id, quantity=quantity, unit=unit, found_date=datetime.now()) async def unmark_found(conn, product_id: int) -> List[ShoppingListRequest]:
async with conn.execute(''' shopping_list = await current_shopping_list(conn)
INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit, created_date, found_date)
VALUES (?, ?, ?, ?, ?, ?) requests = []
''', (result.product_id, result.list_id, quantity, unit, result.created_date, result.found_date)) as cursor: async with conn.execute('''
result.id = cursor.lastrowid DELETE FROM ShoppingListResult
shopping_list.items.append(result) WHERE list_id = ? AND product_id = ?
return result ''', (shopping_list.id, product_id)) as cursor:
async for row in cursor:
requests.append(row)
deleted = [r for r in shopping_list.results if r.product_id == product_id]
shopping_list.results = [r for r in shopping_list.results if r.product_id != product_id]
return deleted
async def mark_purchased(conn) -> ShoppingList:
shopping_list = await current_shopping_list(conn)
shopping_list.purchased_date = datetime.now()
await conn.execute('''
UPDATE ShoppingList
SET purchased_date = ?
WHERE id = ?
''', (shopping_list.purchased_date, shopping_list.id))
return shopping_list

View file

@ -24,6 +24,8 @@ class Products:
id=0, id=0,
name="Fresh Broccoli", name="Fresh Broccoli",
product_id="134681", product_id="134681",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli", link="https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134681.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134681.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134681.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134681.jpg",
@ -34,6 +36,8 @@ class Products:
id=0, id=0,
name="La Famiglia Garlic Bread", name="La Famiglia Garlic Bread",
product_id="294517", product_id="294517",
quantity=1,
unit="Loaf",
link="https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread", link="https://www.woolworths.com.au/shop/productdetails/294517/la-famiglia-garlic-bread",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/294517.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg",
@ -44,6 +48,8 @@ class Products:
id=0, id=0,
name="Beans Round", name="Beans Round",
product_id="134072", product_id="134072",
quantity=1,
unit="kg",
link="https://www.woolworths.com.au/shop/productdetails/134072/beans-round", link="https://www.woolworths.com.au/shop/productdetails/134072/beans-round",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134072.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134072.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134072.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134072.jpg",
@ -54,6 +60,8 @@ class Products:
id=0, id=0,
name="Western Star Unsalted Butter Chef's Choice", name="Western Star Unsalted Butter Chef's Choice",
product_id="712251", product_id="712251",
quantity=500,
unit="g",
link="https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice", link="https://www.woolworths.com.au/shop/productdetails/712251/western-star-unsalted-butter-chef-s-choice",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/712251.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg",
@ -63,6 +71,8 @@ class Products:
saxa_iodised_table_salt_shaker = products.Product( saxa_iodised_table_salt_shaker = products.Product(
id=0, id=0,
name="Saxa Iodised Table Salt Shaker", name="Saxa Iodised Table Salt Shaker",
quantity=750,
unit="g",
product_id="33245", product_id="33245",
link="https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker", link="https://www.woolworths.com.au/shop/productdetails/33245/saxa-iodised-table-salt-shaker",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/033245.jpg",
@ -73,6 +83,8 @@ class Products:
mckenzies_pepper_black_ground = products.Product( mckenzies_pepper_black_ground = products.Product(
id=0, id=0,
name="Mckenzie's Pepper Black Ground", name="Mckenzie's Pepper Black Ground",
quantity=100,
unit="g",
product_id="75194", product_id="75194",
link="https://www.woolworths.com.au/shop/productdetails/75194/mckenzie-s-pepper-black-ground", link="https://www.woolworths.com.au/shop/productdetails/75194/mckenzie-s-pepper-black-ground",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/075194.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/075194.jpg",
@ -84,6 +96,8 @@ class Products:
id=0, id=0,
name="Apple", name="Apple",
product_id="3542", product_id="3542",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/0/apple", link="https://www.woolworths.com.au/shop/productdetails/0/apple",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
@ -94,6 +108,8 @@ class Products:
id=0, id=0,
name="Banana", name="Banana",
product_id="214", product_id="214",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/0/banana", link="https://www.woolworths.com.au/shop/productdetails/0/banana",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg", img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg", img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
@ -128,7 +144,7 @@ class Ingredients:
id=0, id=0,
line='1kg Broccoli, Chopped', line='1kg Broccoli, Chopped',
name='Broccoli', name='Broccoli',
unit='1kg', unit='kg',
quantity='1', quantity='1',
preparation='Chopped', preparation='Chopped',
product=Products.broccoli, product=Products.broccoli,
@ -191,6 +207,7 @@ class Recipes:
id=0, id=0,
name='Broccoli Soup', name='Broccoli Soup',
link='https://www.bbcgoodfood.com/recipes/broccoli-soup', link='https://www.bbcgoodfood.com/recipes/broccoli-soup',
serves=4,
image_urls=['https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg'], image_urls=['https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg'],
ingredients=[Ingredients.broccoli_chopped_1kg], ingredients=[Ingredients.broccoli_chopped_1kg],
created_by_id=Persons.jacob.id, created_by_id=Persons.jacob.id,
@ -200,6 +217,7 @@ class Recipes:
id=0, id=0,
name="How to Steam Green Beans", name="How to Steam Green Beans",
link="https://www.thespruceeats.com/steamed-green-beans-3057051", link="https://www.thespruceeats.com/steamed-green-beans-3057051",
serves=4,
image_urls=["https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"], image_urls=["https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"],
ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper], ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper],
created_by_id=Persons.jacob.id, created_by_id=Persons.jacob.id,

View file

@ -1,3 +1,4 @@
from datetime import datetime, timedelta
import importlib import importlib
import unittest import unittest
import tests.test_data as test_data import tests.test_data as test_data
@ -31,7 +32,29 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
async def test_current_shopping_list(self): async def test_current_shopping_list(self):
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 0) self.assertEqual(len(shopping_list.requests), 0)
self.assertEqual(len(shopping_list.items), 0) self.assertEqual(len(shopping_list.results), 0)
async def test_get_current_adds_upcoming_meals(self):
import meals, recipes
meal = test_data.Meals.broccoli_soup_for_jacob
for product in [i.product for i in meal.extra_ingredients] + [i.product for r in meal.recipes for i in r.ingredients]:
if product.id < 0:
await products.insert_product(self.conn, product, {})
for recipe in meal.recipes:
await recipes.insert_recipe(self.conn, recipe)
meal.meal_date = datetime.now() + timedelta(days=1)
await meals.insert_meal(self.conn, meal)
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 1)
self.assertEqual(len(shopping_list.results), 0)
request = shopping_list.requests[0]
self.assertEqual(request.meal_id, meal.id)
async def test_sync_persons_requests(self): async def test_sync_persons_requests(self):
ingredient = test_data.Ingredients.one_apple ingredient = test_data.Ingredients.one_apple
@ -40,11 +63,12 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
await products.insert_product(self.conn, ingredient.product, {}) await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient]) async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient]):
pass
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 1) self.assertEqual(len(shopping_list.requests), 1)
self.assertEqual(len(shopping_list.items), 0) self.assertEqual(len(shopping_list.results), 0)
request = shopping_list.requests[0] request = shopping_list.requests[0]
self.assertEqual(request.person_id, person.id) self.assertEqual(request.person_id, person.id)
@ -60,12 +84,15 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
await products.insert_product(self.conn, second.product, {}) await products.insert_product(self.conn, second.product, {})
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first]) async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first]):
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second]) pass
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second]):
pass
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 2) self.assertEqual(len(shopping_list.requests), 2)
self.assertEqual(len(shopping_list.items), 0) self.assertEqual(len(shopping_list.results), 0)
request_by_line = {r.ingredient.line: r for r in shopping_list.requests} request_by_line = {r.ingredient.line: r for r in shopping_list.requests}
self.assertEqual(len(request_by_line), 2) self.assertEqual(len(request_by_line), 2)
@ -77,19 +104,52 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
async def test_mark_found(self): async def test_mark_found(self):
ingredient = test_data.Ingredients.one_apple ingredient = test_data.Ingredients.one_apple
await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 1)
self.assertEqual(shopping_list.results[0].product_id, ingredient.product.id)
self.assertEqual(shopping_list.results[0].quantity, 1)
self.assertEqual(shopping_list.results[0].unit, ingredient.unit)
ingredient.quantity = 2
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 1)
self.assertEqual(shopping_list.results[0].product_id, ingredient.product.id)
self.assertEqual(shopping_list.results[0].quantity, 2)
self.assertEqual(shopping_list.results[0].unit, ingredient.unit)
ingredient.unit = 'kg'
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 2)
results_by_unit = {r.unit: r for r in shopping_list.results}
self.assertEqual(len(results_by_unit), 2)
self.assertIn('kg', results_by_unit)
self.assertIn('Items', results_by_unit)
self.assertEqual(results_by_unit['kg'].product_id, results_by_unit['Items'].product_id)
async def test_purchase(self):
ingredient = test_data.Ingredients.one_apple
person = test_data.Persons.jacob person = test_data.Persons.jacob
await products.insert_product(self.conn, ingredient.product, {}) await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.current_shopping_list(self.conn)
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient]) self.assertIsNone(shopping_list.purchased_date)
await shopping.mark_found(self.conn, ingredient.product, 2, 'items')
shopping_list = await shopping.current_shopping_list(self.conn) shopping_list = await shopping.mark_purchased(self.conn)
self.assertEqual(len(shopping_list.requests), 1) self.assertIsNotNone(shopping_list.purchased_date)
self.assertLessEqual(shopping_list.purchased_date - datetime.now(), timedelta(seconds=1))
self.assertEqual(len(shopping_list.items), 1)
self.assertEqual(shopping_list.items[0].product_id, ingredient.product.id)
self.assertEqual(shopping_list.items[0].quantity, 2)
self.assertEqual(shopping_list.items[0].unit, 'items')
new_shopping_list = await shopping.current_shopping_list(self.conn)
self.assertNotEqual(shopping_list.id, new_shopping_list.id)
self.assertIsNone(new_shopping_list.purchased_date)