Compare commits

..

No commits in common. "c098cd8fd0d754f9e5d77f5e84856d98cb267ce3" and "be1d2b75ad10de70363965f866842c3fe56c9550" have entirely different histories.

13 changed files with 167 additions and 476 deletions

View file

@ -1,68 +1,48 @@
from ingredients.db import Ingredient, find_ingredients_by_meal_id, find_ingredients_by_recipe_id, insert_ingredient, delete_ingredients_by_meal_id
import units
from products import Product, find_product_by_tag, get_or_create, add_missing_tags
from products import Product, find_product_by_tag
from ingredient_parser import parse_ingredient
import re
from ingredient_parser import parse_multiple_ingredients
from typing import List
async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
match = re.match(r'^(\d+)?\s*(http.*)$', link)
if not match:
return None
def parse_ingredient_from_nlp(ingredients: List[str]) -> Ingredient:
results = []
for ingredient in parse_multiple_ingredients(ingredients):
name = ingredient.name.text if ingredient.name else ''
quantity = int(match.group(1)) if match.group(1) else 1
url = match.group(2)
product = await get_or_create(conn, url, [])
if product:
await add_missing_tags(conn, product, [product.name])
quantity, unit = None, None
for amount in ingredient.amount:
if quantity is None and amount.quantity:
quantity = amount.quantity
return Ingredient(id=-1,
name=product.name,
line=f"{quantity}x {product.name}",
unit=units.ITEMS.name,
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
results.append(Ingredient(id=0,
line=ingredient.sentence,
name=name,
quantity=quantity,
preparation='',
product_id=product.id,
product=product
)
unit=unit,
preparation=ingredient.preparation.text if ingredient.preparation else '',
product_id=0
))
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
)
return results
async def _find_existing_product(conn, ingredient: str) -> Product:
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):
KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id']
id: int = -1
id: int
name: str
line: str
unit: str
@ -34,10 +34,10 @@ async def create(conn):
);''')
async def insert_ingredient(conn, ingredient: Ingredient):
if ingredient.product:
if not ingredient.product_id and ingredient.product:
ingredient.product_id = ingredient.product.id
if ingredient.product_id < 0:
if not ingredient.product_id:
raise ValueError('Product must be inserted before ingredient')
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)
@app.get("/recipes/parse")
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, person, url)
async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db)) -> recipes.Recipe:
parsed = await recipes.parse_recipe(conn, url)
if not parsed:
return JSONResponse(status_code=400, content={'message': 'Recipe not found'})
return parsed
@ -44,24 +44,7 @@ async def parse_ingredients(lines: Annotated[
Query(alias="ingredients",
title="Array of ingredients to parse")],
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]:
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()
result = ingredients.parse_ingredient_from_nlp(lines)
await ingredients.match_existing_products(conn, result)
return result
@ -112,29 +95,29 @@ async def get_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db))
return r
@app.post('/recipes/')
async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
if not recipe.ingredients:
async def create_recipe(item: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
if not item.ingredients:
return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'})
for ingredient in recipe.ingredients:
for ingredient in item.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
recipe.id = 0
if item.id:
await recipes.hide_recipe(conn, item.id, user)
item.based_on_recipe = item.id
item.id = 0
recipe.created_by_id = user.id
await recipes.insert_recipe(conn, recipe)
for ingredient in recipe.ingredients:
ingredient.recipe_id = recipe.id
item.created_by_id = user.id
await recipes.insert_recipe(conn, item)
for ingredient in item.ingredients:
ingredient.recipe_id = item.id
ingredient.product_id = ingredient.product.id
await ingredients.insert_ingredient(conn, ingredient)
await conn.commit()
return recipe
return item
@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:
@ -163,6 +146,10 @@ async def get_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) ->
if not meal:
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
def get_duplicates(items: List[meals.Person]) -> set[str]:
@ -241,48 +228,17 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db))
@app.get("/shopping/{list_id}")
async def get_shopping_list(list_id: Union[int, str], conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
if list_id.lower() == 'current':
return await shopping.current_shopping_list(conn)
if isinstance(list_id, str):
if list_id.lower() != 'current':
return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'})
try:
list_id = int(list_id)
except ValueError:
return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'})
return await shopping.current_shopping_list(conn)
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")
async def mark_shopping_list(ingredients: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db)) -> FoundResult:
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
async def mark_shopping_list(ingredient: ingredients.Ingredient, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingListResult:
return await shopping.mark_found(conn, ingredient.product, ingredient.quantity, ingredient.unit)
@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]:

View file

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

View file

@ -4,16 +4,6 @@ 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 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:
product_id = get_product_id(link)
@ -23,16 +13,11 @@ async def create_product(link: str) -> Product:
product_url = get_product_details_url(product_id)
product, data = None, await scrape_woolies_data(product_url)
if data:
_dump_json_data_to_log(data, product_id)
quantity, unit = get_package_size(data)
product = Product(
product = Product(
id=0,
product_id=product_id,
name=data['Product']['Name'],
link=link,
quantity=quantity,
unit=unit,
img_small=data['Product']['SmallImageFile'],
img_large=data['Product']['LargeImageFile'],
)
@ -69,17 +54,3 @@ async def get_or_create(conn, url: str, tags: List[str]) -> Product:
await add_missing_tags(conn, product, tags)
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,15 +4,11 @@ from pydantic import BaseModel
import json
class Product(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large']
NON_INSERT_KEYS: ClassVar[List[str]] = ['id']
id: int = -1
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'link', 'name', 'img_small', 'img_large']
id: int
product_id: str
link: str
name: str
quantity: int
unit: str
img_small: str
img_large: str
@ -23,8 +19,6 @@ async def create(conn):
product_id TEXT UNIQUE,
link TEXT,
name TEXT,
quantity INTEGER,
unit TEXT,
img_small TEXT,
img_large TEXT,
raw_data TEXT
@ -68,13 +62,10 @@ async def find_product_by_product_id(conn, product_id: str) -> Product:
return Product(**{k:v for k,v in zip(Product.KEYS, row)})
async def insert_product(conn, product: Product, data: dict):
insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS]
insert_values = [getattr(product, k) for k in insert_keys]
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:
async with conn.execute('''
INSERT INTO Product (name, product_id, link, img_small, img_large, raw_data)
VALUES (?, ?, ?, ?, ?, ?)
''', (product.name, product.product_id, product.link, product.img_small, product.img_large, json.dumps(data))) as cursor:
product.id = cursor.lastrowid
await conn.commit()

View file

@ -1,42 +1,18 @@
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.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
from ingredients import parse_ingredient_from_nlp, match_existing_products
from ingredients import parse_ingredient_from_nlp as _parse_ingredient_from_nlp, match_existing_products as _match_existing_products
import re
async def parse_recipe(conn, created_by: Person, url: str) -> Recipe:
async def parse_recipe(conn, url: str) -> Recipe:
ldata = await _scrape_recipe_ldata(url)
if ldata:
return await _get_recipe_from_ldata(conn, url, ldata, created_by)
return await _get_recipe_from_ldata(conn, url, ldata)
return None
def find_yield(recipe_ldata: dict) -> int:
if 'recipeYield' in recipe_ldata:
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)
async def _get_recipe_from_ldata(conn, url: str, ldata: dict) -> dict:
ingredients = _parse_ingredient_from_nlp(ldata['recipeIngredient'])
ingredients = await _match_existing_products(conn, ingredients)
name = ldata['name'] if 'name' in ldata else url
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):
images = [image['url'] for image in images]
@ -51,9 +27,6 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person
id=0,
name=name,
link=url,
serves=serves,
image_urls=images,
ingredients=ingredients,
created_by=created_by,
created_by_id=created_by.id,
ingredients=ingredients
)

View file

@ -7,19 +7,16 @@ from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Tuple, Optional
class Recipe(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'serves', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden']
NON_INSERT_KEYS: ClassVar[List[str]] = ['id', 'created_date', 'hidden_by_id', 'date_hidden']
id: int = -1
KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden']
id: int
name: str
link: str
serves: int
image_urls: List[str] = []
ingredients: List[Ingredient] = []
based_on_recipe: Optional[int] = None
date_created: datetime.datetime = datetime.datetime.now()
created_by_id: Optional[int]
date_created: Optional[datetime.datetime] = None
created_by_id: Optional[int] = None
created_by: Optional[Person] = None
date_hidden: Optional[datetime.datetime] = None
@ -32,7 +29,6 @@ async def create(conn):
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
link TEXT NOT NULL,
serves INTEGER NOT NULL,
image_urls TEXT NOT NULL,
based_on_recipe INTEGER NULL,
@ -47,22 +43,11 @@ async def create(conn):
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):
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
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:
async with conn.execute('''
INSERT INTO Recipe (name, link, image_urls, based_on_recipe, created_by_id)
VALUES (?, ?, ?, ?, ?)
''', (recipe.name, recipe.link, json.dumps(recipe.image_urls), recipe.based_on_recipe, recipe.created_by_id)) as cursor:
recipe.id = cursor.lastrowid
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, unmark_found, sync_persons_requested_ingredients, load_shopping_list, get_persons_requests, request_meal, delete_requests, mark_purchased
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

View file

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

View file

@ -24,8 +24,6 @@ class Products:
id=0,
name="Fresh Broccoli",
product_id="134681",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134681.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134681.jpg",
@ -36,8 +34,6 @@ class Products:
id=0,
name="La Famiglia Garlic Bread",
product_id="294517",
quantity=1,
unit="Loaf",
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_large="https://cdn0.woolworths.media/content/wowproductimages/large/294517.jpg",
@ -48,8 +44,6 @@ class Products:
id=0,
name="Beans Round",
product_id="134072",
quantity=1,
unit="kg",
link="https://www.woolworths.com.au/shop/productdetails/134072/beans-round",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/134072.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/134072.jpg",
@ -60,8 +54,6 @@ class Products:
id=0,
name="Western Star Unsalted Butter Chef's Choice",
product_id="712251",
quantity=500,
unit="g",
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_large="https://cdn0.woolworths.media/content/wowproductimages/large/712251.jpg",
@ -71,8 +63,6 @@ class Products:
saxa_iodised_table_salt_shaker = products.Product(
id=0,
name="Saxa Iodised Table Salt Shaker",
quantity=750,
unit="g",
product_id="33245",
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",
@ -83,8 +73,6 @@ class Products:
mckenzies_pepper_black_ground = products.Product(
id=0,
name="Mckenzie's Pepper Black Ground",
quantity=100,
unit="g",
product_id="75194",
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",
@ -96,8 +84,6 @@ class Products:
id=0,
name="Apple",
product_id="3542",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/0/apple",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
@ -108,8 +94,6 @@ class Products:
id=0,
name="Banana",
product_id="214",
quantity=1,
unit="Items",
link="https://www.woolworths.com.au/shop/productdetails/0/banana",
img_small="https://cdn0.woolworths.media/content/wowproductimages/small/0.jpg",
img_large="https://cdn0.woolworths.media/content/wowproductimages/large/0.jpg",
@ -144,7 +128,7 @@ class Ingredients:
id=0,
line='1kg Broccoli, Chopped',
name='Broccoli',
unit='kg',
unit='1kg',
quantity='1',
preparation='Chopped',
product=Products.broccoli,
@ -207,7 +191,6 @@ class Recipes:
id=0,
name='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'],
ingredients=[Ingredients.broccoli_chopped_1kg],
created_by_id=Persons.jacob.id,
@ -217,7 +200,6 @@ class Recipes:
id=0,
name="How to Steam Green Beans",
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"],
ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper],
created_by_id=Persons.jacob.id,

View file

@ -1,4 +1,3 @@
from datetime import datetime, timedelta
import importlib
import unittest
import tests.test_data as test_data
@ -32,29 +31,7 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
async def test_current_shopping_list(self):
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 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)
self.assertEqual(len(shopping_list.items), 0)
async def test_sync_persons_requests(self):
ingredient = test_data.Ingredients.one_apple
@ -63,12 +40,11 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient]):
pass
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient])
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 1)
self.assertEqual(len(shopping_list.results), 0)
self.assertEqual(len(shopping_list.items), 0)
request = shopping_list.requests[0]
self.assertEqual(request.person_id, person.id)
@ -84,15 +60,12 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
await products.insert_product(self.conn, second.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first]):
pass
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second]):
pass
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first])
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second])
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 2)
self.assertEqual(len(shopping_list.results), 0)
self.assertEqual(len(shopping_list.items), 0)
request_by_line = {r.ingredient.line: r for r in shopping_list.requests}
self.assertEqual(len(request_by_line), 2)
@ -104,52 +77,19 @@ class TestShopping(unittest.IsolatedAsyncioTestCase):
async def test_mark_found(self):
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
await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertIsNone(shopping_list.purchased_date)
await shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient])
await shopping.mark_found(self.conn, ingredient.product, 2, 'items')
shopping_list = await shopping.mark_purchased(self.conn)
self.assertIsNotNone(shopping_list.purchased_date)
self.assertLessEqual(shopping_list.purchased_date - datetime.now(), timedelta(seconds=1))
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 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)