From e0b643bfec31efce8628809bc183a9841278bdb0 Mon Sep 17 00:00:00 2001 From: jableader Date: Mon, 20 May 2024 20:09:57 +1000 Subject: [PATCH] Turns out zero is a valid id --- ingredients/db.py | 6 +-- main.py | 43 ++++++++++++++-------- meals/db.py | 4 +- persons/db.py | 2 +- products/__init__.py | 4 +- products/db.py | 2 +- recipes/db.py | 2 +- shopping/__init__.py | 2 +- shopping/db.py | 83 +++++++++++++++++++++++++++++++----------- tests/test_shopping.py | 53 ++++++++++++++++++++++++--- 10 files changed, 148 insertions(+), 53 deletions(-) diff --git a/ingredients/db.py b/ingredients/db.py index 946c408..2d38af7 100644 --- a/ingredients/db.py +++ b/ingredients/db.py @@ -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 + id: int = -1 name: str line: str unit: str @@ -34,10 +34,10 @@ async def create(conn): );''') async def insert_ingredient(conn, ingredient: Ingredient): - if not ingredient.product_id and ingredient.product: + if ingredient.product: ingredient.product_id = ingredient.product.id - if not ingredient.product_id: + if ingredient.product_id < 0: raise ValueError('Product must be inserted before ingredient') async with conn.execute(''' diff --git a/main.py b/main.py index 528cf53..eb7ef3c 100644 --- a/main.py +++ b/main.py @@ -95,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(item: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe: - if not item.ingredients: +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: 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: return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'}) - if item.id: - await recipes.hide_recipe(conn, item.id, user) - item.based_on_recipe = item.id - item.id = 0 + if recipe.id >= 0: + await recipes.hide_recipe(conn, recipe.id, user) + recipe.based_on_recipe = recipe.id + recipe.id = 0 - item.created_by_id = user.id - await recipes.insert_recipe(conn, item) - for ingredient in item.ingredients: - ingredient.recipe_id = item.id + recipe.created_by_id = user.id + await recipes.insert_recipe(conn, recipe) + for ingredient in recipe.ingredients: + ingredient.recipe_id = recipe.id ingredient.product_id = ingredient.product.id await ingredients.insert_ingredient(conn, ingredient) await conn.commit() - return item + return recipe @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: @@ -224,14 +224,25 @@ 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 isinstance(list_id, str): - if list_id.lower() != 'current': - return JSONResponse(status_code=400, content={'message': 'Invalid shopping list ID'}) - + if list_id.lower() == 'current': 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) +@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] = [] diff --git a/meals/db.py b/meals/db.py index 104d7b1..81a9763 100644 --- a/meals/db.py +++ b/meals/db.py @@ -11,7 +11,7 @@ import datetime class Meal(BaseModel): KEYS: ClassVar[List[str]] = ['id', 'meal_date'] - id: int + id: int = -1 meal_date: datetime.datetime chefs: List[Person] = [] @@ -60,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 not recipe_id: + if recipe_id < 0: raise ValueError('Recipe must be inserted before meal') await conn.execute(''' diff --git a/persons/db.py b/persons/db.py index 23a1df6..1c0b5e1 100644 --- a/persons/db.py +++ b/persons/db.py @@ -4,7 +4,7 @@ from typing import AsyncIterator, ClassVar, List class Person(BaseModel): KEYS: ClassVar[List[str]] = ['id', 'name'] - id: int + id: int = -1 name: str async def create(conn): diff --git a/products/__init__.py b/products/__init__.py index 86da4bb..c1c48b3 100644 --- a/products/__init__.py +++ b/products/__init__.py @@ -17,7 +17,7 @@ def get_package_size(data: dict) -> str: async def create_product(link: str) -> Product: product_id = get_product_id(link) - if not product_id: + if product_id < 0: return None, None product_url = get_product_details_url(product_id) @@ -55,7 +55,7 @@ async def add_missing_tags(conn, product: Product, tags: List[str]): async def get_or_create(conn, url: str, tags: List[str]) -> Product: product_id = get_product_id(url) - if not product_id: + if product_id < 0: return None existing = await find_product_by_product_id(conn, product_id) diff --git a/products/db.py b/products/db.py index ec7ac4f..22107b4 100644 --- a/products/db.py +++ b/products/db.py @@ -7,7 +7,7 @@ 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 + id: int = -1 product_id: str link: str name: str diff --git a/recipes/db.py b/recipes/db.py index e442869..ab4bca8 100644 --- a/recipes/db.py +++ b/recipes/db.py @@ -10,7 +10,7 @@ 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 + id: int = -1 name: str link: str serves: int diff --git a/shopping/__init__.py b/shopping/__init__.py index c74fc21..1a56f55 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -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 +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 diff --git a/shopping/db.py b/shopping/db.py index d44b0ed..b632ab0 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -10,7 +10,7 @@ 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 = 0 + id: int = -1 list_id: int ingredient_id: Optional[int] = None @@ -26,7 +26,7 @@ class ShoppingListRequest(BaseModel): class ShoppingListResult(BaseModel): KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit', 'created_date', 'found_date'] - id: int = 0 + id: int = -1 list_id: int product_id: int product: Optional[Product] = None @@ -39,7 +39,7 @@ class ShoppingListResult(BaseModel): class ShoppingList(BaseModel): KEYS: ClassVar[List[str]] = ['id', 'created_date', 'purchased_date'] - id: int = 0 + id: int = -1 created_date: datetime = datetime.now() purchased_date: Optional[datetime] = None @@ -75,35 +75,64 @@ async def create(conn): list_id INTEGER, quantity REAL, unit TEXT, - created_date TEXT, + created_date TEXT DEFAULT CURRENT_TIMESTAMP, 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 (id, created_date, purchased_date) - VALUES (?, ?, ?) - ''', (shopping_list.id, shopping_list.created_date, shopping_list.purchased_date)) as cursor: + INSERT INTO ShoppingList (created_date, purchased_date) + VALUES (CURRENT_TIMESTAMP, NULL) + ''') as cursor: shopping_list.id = cursor.lastrowid 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) - request.ingredient_id = request.ingredient.id - await conn.execute(''' - 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)) + 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 - 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)) + item.list_id = shopping_list.id + + async with conn.execute(''' + 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]: # Join Ingredient and Product to also load ingredient and product @@ -133,7 +162,7 @@ async def find_request(conn, id: int) -> Optional[ShoppingListRequest]: 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) - if request.meal_id: + if request.meal_id is not None: request.meal = await find_meal_by_id(conn, request.meal_id) return request @@ -167,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 = 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) yield request @@ -235,7 +264,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, 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) @@ -356,4 +385,16 @@ async def unmark_found(conn, product_id: int) -> List[ShoppingListRequest]: 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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/test_shopping.py b/tests/test_shopping.py index 486a1ce..ce36cd2 100644 --- a/tests/test_shopping.py +++ b/tests/test_shopping.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta import importlib import unittest import tests.test_data as test_data @@ -34,6 +34,28 @@ class TestShopping(unittest.IsolatedAsyncioTestCase): 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) + + async def test_sync_persons_requests(self): ingredient = test_data.Ingredients.one_apple person = test_data.Persons.jacob @@ -41,7 +63,8 @@ class TestShopping(unittest.IsolatedAsyncioTestCase): await products.insert_product(self.conn, ingredient.product, {}) 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) self.assertEqual(len(shopping_list.requests), 1) @@ -61,8 +84,11 @@ class TestShopping(unittest.IsolatedAsyncioTestCase): await products.insert_product(self.conn, second.product, {}) shopping_list = await shopping.current_shopping_list(self.conn) - 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]) + 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 shopping_list = await shopping.current_shopping_list(self.conn) self.assertEqual(len(shopping_list.requests), 2) @@ -109,4 +135,21 @@ class TestShopping(unittest.IsolatedAsyncioTestCase): 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) \ No newline at end of file + 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) + + 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)) + + 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) \ No newline at end of file