From a9981005a40d32d5cb6bf04b480d1a947527d1b1 Mon Sep 17 00:00:00 2001 From: jableader Date: Sun, 27 Jul 2025 11:58:28 +1000 Subject: [PATCH 1/7] Prior to db refactor --- ingredients/db.py | 2 +- main.py | 23 +++++------------------ shopping/__init__.py | 2 +- shopping/db.py | 32 +++++++++++++++++++------------- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/ingredients/db.py b/ingredients/db.py index 2d38af7..b10dad1 100644 --- a/ingredients/db.py +++ b/ingredients/db.py @@ -38,7 +38,7 @@ async def insert_ingredient(conn, ingredient: Ingredient): ingredient.product_id = ingredient.product.id if ingredient.product_id < 0: - raise ValueError('Product must be inserted before ingredient') + ingredient.product_id = None async with conn.execute(''' INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id) diff --git a/main.py b/main.py index bb86610..f94cf04 100644 --- a/main.py +++ b/main.py @@ -109,10 +109,6 @@ async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depen if not recipe.ingredients: return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'}) - for ingredient in recipe.ingredients: - if not ingredient.product: - return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'}) - if recipe.id >= 0: await recipes.hide_recipe(conn, recipe.id, user) recipe.based_on_recipe = recipe.id @@ -122,7 +118,9 @@ async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depen await recipes.insert_recipe(conn, recipe) for ingredient in recipe.ingredients: ingredient.recipe_id = recipe.id - ingredient.product_id = ingredient.product.id + if ingredient.product: + ingredient.product_id = ingredient.product.id + await ingredients.insert_ingredient(conn, ingredient) await conn.commit() @@ -275,20 +273,9 @@ async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: return await shopping.load_shopping_list(conn, list_id) -class ShoppingListPurchase(shopping.ShoppingList): - completed_requests: List[shopping.ShoppingListRequest] = [] - @app.post("/api/shopping/") -async def purchase_ingredients(lst: ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: - await shopping.insert_shopping_list(conn, lst) - for request in lst.completed_requests: - if request.meal_id: - meal = await meals.find_meal_by_id(conn, request.meal_id) - if meal: - await meals.mark_purchased(conn, meal) - - await shopping.remove_request(conn, request) - +async def purchase_ingredients(lst: shopping.ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: + await shopping.purchase_ingredients(conn, lst) await conn.commit() return lst diff --git a/shopping/__init__.py b/shopping/__init__.py index e7e75b8..a569739 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -1,2 +1,2 @@ -from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, sync_persons_requested_ingredients, load_shopping_list, get_current_requests, request_meal, unrequest_meal, insert_shopping_list, get_shopping_list_with_meal, remove_request +from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, ShoppingListPurchase, sync_persons_requested_ingredients, load_shopping_list, get_current_requests, request_meal, unrequest_meal, purchase_ingredients, get_shopping_list_with_meal, remove_request diff --git a/shopping/db.py b/shopping/db.py index 2a7163a..e4207aa 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -50,6 +50,9 @@ class ShoppingList(BaseModel): requests: List[ShoppingListRequest] = [] results: List[ShoppingListResult] = [] +class ShoppingListPurchase(ShoppingList): + completed_requests: List[ShoppingListRequest] = [] + async def create(conn): await conn.execute(''' CREATE TABLE IF NOT EXISTS ShoppingList ( @@ -92,14 +95,11 @@ def validate_request(request: ShoppingListRequest) -> None: 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 purchase_ingredients(conn, shopping_list: ShoppingListPurchase): shopping_list.created_date = datetime.now().astimezone() async with conn.execute(''' @@ -108,7 +108,7 @@ async def insert_shopping_list(conn, shopping_list: ShoppingList): ''', (shopping_list.created_date.isoformat(), shopping_list.store_name,)) as cursor: shopping_list.id = cursor.lastrowid - for request in shopping_list.requests: + for request in shopping_list.completed_requests: request.list_id = shopping_list.id validate_request(request) @@ -128,15 +128,21 @@ async def insert_shopping_list(conn, shopping_list: ShoppingList): ''', (request.ingredient_id, shopping_list.id, request.person_id, request.meal_id, request.created_date.isoformat())) as cursor: request.id = cursor.lastrowid - for item in shopping_list.results: - item.product_id = item.product.id - item.list_id = shopping_list.id + if request.ingredient_id and request.person_id: + await conn.execute(''' + DELETE FROM ShoppingListRequest + WHERE ingredient_id = ? AND person_id = ? AND list_id IS NULL + ''', (request.ingredient_id, request.person_id)) + +from meals import find_meal_by_id +async def find_completed_meals(conn, shopping_list_request: List[ShoppingListRequest]) -> AsyncIterator[Meal]: + meal_ids = {request.meal_id for request in shopping_list_request if request.meal_id is not None} + if not meal_ids: + return + + meals = [await find_meal_by_id(conn, meal_id) for meal_id in meal_ids] + ingredients_as_requests = [ShoppingListRequest(meal_id=meal.id, meal=meal, list_id=shopping_list_request[0].list_id) for meal in meals] - async with conn.execute(''' - INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit) - VALUES (?, ?, ?, ?) - ''', (item.product_id, item.list_id, item.quantity, item.unit)) as cursor: - item.id = cursor.lastrowid async def remove_request(conn, request: ShoppingListRequest) -> None: if request.list_id != None: -- 2.45.2 From efd0dabc8775dca12b8764a6f6676e5802f9eddf Mon Sep 17 00:00:00 2001 From: jableader Date: Sun, 27 Jul 2025 15:24:54 +1000 Subject: [PATCH 2/7] Can edit own ingredient requests --- common.py | 23 +++ db.py | 2 +- ingredients/__init__.py | 2 +- main.py | 62 ++++---- shopping/__init__.py | 29 +++- shopping/db.py | 314 +++++++++++++++++----------------------- 6 files changed, 223 insertions(+), 209 deletions(-) create mode 100644 common.py diff --git a/common.py b/common.py new file mode 100644 index 0000000..25407d2 --- /dev/null +++ b/common.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field, model_validator +from typing import Optional, Any + +class BaseLinkedModel(BaseModel): + model_config = dict(arbitrary_types_allowed=True) + + @model_validator(mode="before") + @classmethod + def auto_populate_ids(cls, data: dict[str, Any]) -> dict[str, Any]: + if isinstance(data, dict): + for key, value in data.copy().items(): + if not key.endswith("_id") and hasattr(value, "id") and value is not None: + id_key = key + "_id" + + if id_key in data: + # If the id_key already exists, ensure it matches the value's id + if data[id_key] != value.id: + raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}") + else: + # If the id_key does not exist, set it to the value's id + data[id_key] = value.id + + return data \ No newline at end of file diff --git a/db.py b/db.py index 604bcf5..3c12fee 100644 --- a/db.py +++ b/db.py @@ -1,6 +1,6 @@ import aiosqlite -async def connect(path = './data/your_database.db') -> aiosqlite.Connection: +async def connect(path = './data/doof.sqlite') -> aiosqlite.Connection: return await aiosqlite.connect(path) async def create(conn: aiosqlite.Connection): diff --git a/ingredients/__init__.py b/ingredients/__init__.py index 0d4799d..f23244a 100644 --- a/ingredients/__init__.py +++ b/ingredients/__init__.py @@ -55,7 +55,7 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient: if unit is None: unit = units.ITEMS.name - return Ingredient(id=0, + return Ingredient(id=-1, line=ingredient.sentence, name=name, quantity=quantity, diff --git a/main.py b/main.py index f94cf04..bb801e9 100644 --- a/main.py +++ b/main.py @@ -252,53 +252,65 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) return meal class CurrentShoppingList(BaseModel): - requests: List[shopping.ShoppingListRequest] - overlapping_previous_shops: List[shopping.ShoppingList] + requests: List[shopping.ShoppingListItem] + previously_purchased: List[shopping.ShoppingListItem] = [] + other_shopping_lists: List[shopping.ShoppingList] = [] @app.get("/api/shopping/current") async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList: - current_requests = [r async for r in shopping.get_current_requests(conn)] - - requested_meals = [r.meal_id for r in current_requests if r.meal_id] - upcoming_meals = [m.id async for m in meals.find_upcoming_meals_by_date_range(conn, datetime.datetime.now().astimezone(), datetime.datetime.now().astimezone() + datetime.timedelta(days=14))] + outstanding_requests, purchased_requests = await shopping.get_outstanding_requests(conn) + other_shopping_list_ids = {item.shopping_list for item in purchased_requests} + other_shopping_lists = [await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids if list_id is not None] - overlapping = {} - for meal_id in set(requested_meals + upcoming_meals): - async for r in shopping.get_shopping_list_with_meal(conn, meal_id): - overlapping[r.id] = r - - return CurrentShoppingList(requests=current_requests, overlapping_previous_shops=list(overlapping.values())) + return CurrentShoppingList(requests=outstanding_requests, previously_purchased=purchased_requests, other_shopping_lists=other_shopping_lists) @app.get("/api/shopping/{list_id}") async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: return await shopping.load_shopping_list(conn, list_id) @app.post("/api/shopping/") -async def purchase_ingredients(lst: shopping.ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: - await shopping.purchase_ingredients(conn, lst) +async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingList: + shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name) + + await shopping.purchase(conn, shopping_list) await conn.commit() - return lst - + + return shopping_list + @app.get("/api/shopping/current/me/ingredients") -async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]: - return [r async for r in shopping.get_current_requests(conn) if r.ingredient and r.person_id == person.id] +async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]: + return [r.ingredient async for r in shopping.get_persons_requests(conn, person.id)] @app.post("/api/shopping/current/me/ingredients") -async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[shopping.ShoppingListRequest]: - result = [r async for r in shopping.sync_persons_requested_ingredients(conn, person, requests) if r.ingredient] +async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]: + def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool: + return a.id == b.id or a.line == b.line + + my_shopping_list = [r.ingredient async for r in shopping.get_persons_requests(conn, person.id) if r.ingredient is not None] + to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)] + to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)] + + for r in to_remove: + await shopping.remove_request(conn, person, ingredient=r) + + for r in to_add: + if r.id < 0: + await ingredients.insert_ingredient(conn, r) + await shopping.request(conn, person, ingredient=r) + await conn.commit() - return result + return await get_my_shopping_list(conn, person) class MealIdWrapper(BaseModel): meal_id: int @app.post("/api/shopping/current/meals/me") -async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListRequest: +async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem: meal = await meals.find_meal_by_id(conn, r.meal_id) if not meal: return JSONResponse(status_code=404, content={'message': 'Meal not found'}) - - response = await shopping.request_meal(conn, person, meal) + + response = await shopping.request(conn, person, meal=meal) await conn.commit() return response @@ -308,7 +320,7 @@ async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db if not meal: return JSONResponse(status_code=404, content={'message': 'Meal not found'}) - await shopping.unrequest_meal(conn, meal) + await shopping.remove_request(conn, person, meal=meal) await conn.commit() return {} diff --git a/shopping/__init__.py b/shopping/__init__.py index a569739..cc69981 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -1,2 +1,29 @@ -from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, ShoppingListPurchase, sync_persons_requested_ingredients, load_shopping_list, get_current_requests, request_meal, unrequest_meal, purchase_ingredients, get_shopping_list_with_meal, remove_request +from typing import AsyncIterator, Iterator, List, Tuple +from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request +from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients + +async def get_persons_requests(conn, person_id: int) -> AsyncIterator[ShoppingListItem]: + async for item in _find_items_by_list_id(conn, None): + if item.person_id == person_id and item.ingredient_id is not None: + yield item + +def flatten_items(items: Iterator[ShoppingListItem]) -> Iterator[ShoppingListItem]: + for item in items: + if item.meal: + for ingredient in item.meal.ingredients + item.meal.extra_ingredients: + yield ShoppingListItem(ingredient=ingredient, meal=item.meal, person_id=item.person_id, created_date=item.created_date) + else: + yield item + +async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem]]: + current_requests = [r async for r in _find_items_by_list_id(conn, None)] + + meal_ids = {item.meal_id for item in current_requests if item.meal_id is not None and item.meal_id > 0} + purchased_ingredients = {r.ingredient_id async for r in _get_purchased_ingredients(conn, meal_ids)} + + flattened = flatten_items(current_requests) + outstanding_items = [r for r in flattened if r.ingredient_id not in purchased_ingredients] + purchased_items = [r for r in flattened if r.ingredient_id in purchased_ingredients] + + return outstanding_items, purchased_items diff --git a/shopping/db.py b/shopping/db.py index e4207aa..1a378a5 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -1,38 +1,29 @@ +from common import BaseLinkedModel from meals import Meal, find_meal_by_id from ingredients import Ingredient, insert_ingredient from persons import Person from products import Product -from pydantic import BaseModel from typing import AsyncIterator, List, ClassVar, Optional from datetime import datetime -class ShoppingListRequest(BaseModel): +class ShoppingListItem(BaseLinkedModel): KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'created_date'] id: int = -1 list_id: Optional[int] = None + person_id: int = -1 + person: Optional[Person] = None + ingredient_id: Optional[int] = None ingredient: Optional[Ingredient] = None - person_id: Optional[int] = None - person: Optional[Person] = None - meal_id: Optional[int] = None meal: Optional[Meal] = None created_date: datetime = datetime.now().astimezone() -class ShoppingListResult(BaseModel): - KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit' ] - id: int = -1 - list_id: int - product_id: int - product: Optional[Product] = None - - quantity: float - unit: str from enum import Enum @@ -41,17 +32,14 @@ class StoreEnum(str, Enum): coles = 'coles' home = '' -class ShoppingList(BaseModel): +class ShoppingList(BaseLinkedModel): KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name'] id: int = -1 created_date: datetime = datetime.now().astimezone() store_name: StoreEnum = '' - - requests: List[ShoppingListRequest] = [] - results: List[ShoppingListResult] = [] - -class ShoppingListPurchase(ShoppingList): - completed_requests: List[ShoppingListRequest] = [] + purchased_by_id: int = -1 + purchased_by: Optional[Person] = None + items: List[ShoppingListItem] = [] async def create(conn): await conn.execute(''' @@ -59,10 +47,12 @@ async def create(conn): id INTEGER PRIMARY KEY, created_date DATETIME NOT NULL, store_name TEXT NOT NULL, + purchased_by_id INTEGER, + FOREIGN KEY(purchased_by_id) REFERENCES Person(id) );''') - + await conn.execute(''' - CREATE TABLE IF NOT EXISTS ShoppingListRequest ( + CREATE TABLE IF NOT EXISTS ShoppingListItem ( id INTEGER PRIMARY KEY, ingredient_id INTEGER, list_id INTEGER, @@ -70,115 +60,144 @@ async def create(conn): meal_id INTEGER, created_date DATETIME NOT NULL, FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id), + FOREIGN KEY(list_id) REFERENCES ShoppingList(id), FOREIGN KEY(person_id) REFERENCES Person(id), - FOREIGN KEY(meal_id) REFERENCES Meal(id), - FOREIGN KEY(list_id) REFERENCES ShoppingList(id) + FOREIGN KEY(meal_id) REFERENCES Meal(id) );''') - await conn.execute(''' - CREATE TABLE IF NOT EXISTS ShoppingListResult ( - id INTEGER PRIMARY KEY, - product_id INTEGER, - list_id INTEGER, - quantity REAL, - unit TEXT, - FOREIGN KEY(product_id) REFERENCES Product(id), - FOREIGN KEY(list_id) REFERENCES ShoppingList(id) - );''') -def validate_request(request: ShoppingListRequest) -> None: - # A request must always have a list id - if request.list_id < 0: - raise ValueError('Request must have a list id') +def validate_request(request: ShoppingListItem) -> None: + if request.person_id < 0: + raise ValueError('Requests must have a person') # A request must have either an ingredient or a meal, but not both if not request.ingredient and not request.meal: raise ValueError('Request must have either an ingredient or a meal') - - # If 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 purchase_ingredients(conn, shopping_list: ShoppingListPurchase): +async def purchase(conn, shopping_list: ShoppingList) -> None: + if shopping_list.purchased_by_id is None: + raise ValueError('Shopping list must have a person id') + + if shopping_list.items is None or len(shopping_list.items) == 0: + raise ValueError('Shopping list must have items') + shopping_list.created_date = datetime.now().astimezone() async with conn.execute(''' - INSERT INTO ShoppingList (created_date, store_name) - VALUES (?, ?) - ''', (shopping_list.created_date.isoformat(), shopping_list.store_name,)) as cursor: + INSERT INTO ShoppingList (created_date, store_name, purchased_by_id) + VALUES (?, ?, ?) + ''', (shopping_list.created_date.isoformat(), shopping_list.store_name, shopping_list.purchased_by_id)) as cursor: shopping_list.id = cursor.lastrowid - for request in shopping_list.completed_requests: - request.list_id = shopping_list.id + for item in shopping_list.items: + item.list_id = shopping_list.id + validate_request(item) - validate_request(request) + if item.ingredient and item.ingredient.id < 0: + await insert_ingredient(conn, item.ingredient) - if request.ingredient and request.ingredient.id < 0: - await insert_ingredient(conn, request.ingredient) + if item.ingredient_id is None or item.ingredient_id < 0: + raise ValueError('Ingredient request must have a valid ingredient id') - if request.ingredient: - request.ingredient_id = request.ingredient.id + isMeal = item.meal_id is None or item.meal_id < 0 + isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0 - if request.meal: - request.meal_id = request.meal.id + if not isMeal and not isPersonRequest: + raise ValueError('Ingredient request must have either a meal or a person id') + + if isPersonRequest: + # Update existing request from its null id, or throw + async with conn.execute(''' + UPDATE ShoppingListItem + SET list_id = ? + WHERE ingredient_id = ? AND person_id = ? AND meal_id IS NULL + ''', (shopping_list.id, item.ingredient_id, item.person_id)) as cursor: + if cursor.rowcount == 0: + raise ValueError('Ingredient request must have a valid person id and ingredient id') + + elif isMeal: + # Insert new request for meal + if item.meal_id is None or item.meal_id < 0: + raise ValueError('Meal request must have a valid meal id') + + async with conn.execute(''' + INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, created_date) + VALUES (?, ?, ?, ?, ?) + ''', (item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.created_date.isoformat())) as cursor: + item.id = cursor.lastrowid + + # TODO: Calculate which meals have been fulfilled and update meal status +async def requested_meal_ids(conn) -> AsyncIterator[ShoppingListItem]: + async with conn.execute(f''' + SELECT {ShoppingListItem.KEYS} + FROM ShoppingListItem + WHERE list_id IS NULL AND meal_id IS NOT NULL AND meal_id > 0 + ''') as cursor: + async for row in cursor: + yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)}) + +async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None) -> ShoppingListItem: + if ingredient is not None and meal is not None: + raise ValueError('Cannot request both an ingredient and a meal') + + if ingredient is None and meal is None: + raise ValueError('Must specify either an ingredient or a meal to request') + + if meal is not None and meal.id < 0: + raise ValueError('Meal must have a valid id') + + if ingredient is not None and ingredient.id < 0: + await insert_ingredient(conn, ingredient) + + item = ShoppingListItem(ingredient=ingredient, person=person, meal=meal) + + validate_request(item) + + async with conn.execute(''' + INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date) + VALUES (?, ?, ?, ?) + ''', (item.ingredient_id, item.person_id, item.meal_id, item.created_date.isoformat())) as cursor: + item.id = cursor.lastrowid + + return item + +async def remove_request(conn, person: Person, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> None: + failed = True + if meal is not None: + # Ensure a record is removed async with conn.execute(''' - INSERT INTO ShoppingListRequest (ingredient_id, list_id, person_id, meal_id, created_date) - VALUES (?, ?, ?, ?, ?) - ''', (request.ingredient_id, shopping_list.id, request.person_id, request.meal_id, request.created_date.isoformat())) as cursor: - request.id = cursor.lastrowid + DELETE FROM ShoppingListItem + WHERE list_id IS NULL AND meal_id = ? + ''', (meal.id,)) as cursor: + if cursor.rowcount > 0: + failed = False - if request.ingredient_id and request.person_id: - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE ingredient_id = ? AND person_id = ? AND list_id IS NULL - ''', (request.ingredient_id, request.person_id)) + elif ingredient is not None: + # Ensure a record is removed + async with conn.execute(''' + DELETE FROM ShoppingListItem + WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ? + ''', (ingredient.id, person.id)) as cursor: + if cursor.rowcount > 0: + failed = False -from meals import find_meal_by_id -async def find_completed_meals(conn, shopping_list_request: List[ShoppingListRequest]) -> AsyncIterator[Meal]: - meal_ids = {request.meal_id for request in shopping_list_request if request.meal_id is not None} - if not meal_ids: - return - - meals = [await find_meal_by_id(conn, meal_id) for meal_id in meal_ids] - ingredients_as_requests = [ShoppingListRequest(meal_id=meal.id, meal=meal, list_id=shopping_list_request[0].list_id) for meal in meals] + if failed: + raise ValueError('Must specify either a meal or an ingredient to remove') - -async def remove_request(conn, request: ShoppingListRequest) -> None: - if request.list_id != None: - raise ValueError('Request is already completed') - - if request.meal and not request.meal_id: - raise ValueError('Meal request must have a meal id') - - if request.meal_id != None: - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE meal_id = ? AND list_id IS NULL - ''', (request.meal_id,)) - - elif request.person_id != None and request.ingredient_id != None: - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE person_id = ? AND ingredient_id = ? AND list_id IS NULL - ''', (request.person_id, request.ingredient_id)) - - else: - raise ValueError('Request is invalid') - -async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListRequest]: +async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]: # Join Ingredient and Product to also load ingredient and product ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] product_keys = [f'product.{key}' for key in Product.KEYS] - request_keys = [f'shoppinglistrequest.{key}' for key in ShoppingListRequest.KEYS] + request_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS] person_keys = [f'person.{key}' for key in Person.KEYS] select = f''' SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)} - FROM ShoppingListRequest - LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id + FROM ShoppingListItem + LEFT JOIN Ingredient ON ShoppingListItem.ingredient_id = Ingredient.id LEFT JOIN Product ON Ingredient.product_id = Product.id - LEFT JOIN Person ON ShoppingListRequest.person_id = Person.id + LEFT JOIN Person ON ShoppingListItem.person_id = Person.id ''' where, params = ' WHERE list_id IS NULL', () @@ -197,39 +216,14 @@ async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterato person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])} person = Person(**person_keys) if person_keys['id'] else None - request_keys = {k:v for k,v in zip(ShoppingListRequest.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])} - request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person) + request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])} + request = ShoppingListItem(**request_keys, ingredient=ingredient, person=person) if request.meal_id is not None: request.meal = await find_meal_by_id(conn, request.meal_id) yield request -async def find_results_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListResult]: - product_keys = [f'product.{key}' for key in Product.KEYS] - result_keys = [f'shoppinglistresult.{key}' for key in ShoppingListResult.KEYS] - - async with conn.execute(f''' - SELECT {','.join(product_keys + result_keys)} - FROM ShoppingListResult - LEFT JOIN Product ON ShoppingListResult.product_id = Product.id - WHERE list_id = ? - ''', (list_id,)) as cursor: - async for row in cursor: - product_keys = {k:v for k,v in zip(Product.KEYS, row[:len(Product.KEYS)])} - product = Product(**product_keys) if product_keys['id'] else None - - result_keys = {k:v for k,v in zip(ShoppingListResult.KEYS, row[len(Product.KEYS):])} - result = ShoppingListResult(**result_keys, product=product) - yield result - -async def fill_related(conn, shopping_list: ShoppingList) -> ShoppingList: - async for request in find_requests_by_list_id(conn, shopping_list.id): - shopping_list.requests.append(request) - - async for item in find_results_by_list_id(conn, shopping_list.id): - shopping_list.results.append(item) - async def load_shopping_list(conn, id: int) -> ShoppingList: shopping_list = None async with conn.execute(f''' @@ -242,61 +236,19 @@ async def load_shopping_list(conn, id: int) -> ShoppingList: break if shopping_list: - await fill_related(conn, shopping_list) + async for item in find_items_by_list_id(conn, shopping_list.id): + shopping_list.items.append(item) return shopping_list -async def request_ingredient(conn, person: Person, ingredient: Ingredient) -> ShoppingListRequest: - if ingredient.id >= 0: - raise ValueError('How did you get an existing ingredient?') +async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]: + if not meal_ids: + return - await insert_ingredient(conn, ingredient) - - request = ShoppingListRequest(ingredient_id=ingredient.id, ingredient=ingredient, person_id=person.id, created_date=datetime.now().astimezone()) - async with conn.execute(''' - INSERT INTO ShoppingListRequest (ingredient_id, person_id, created_date) - VALUES (?, ?, ?) - ''', (request.ingredient_id, request.person_id, request.created_date.isoformat())) as cursor: - request.id = cursor.lastrowid - - return request - -async def request_meal(conn, person: Person, meal: Meal) -> ShoppingListRequest: - request = ShoppingListRequest(meal_id=meal.id, meal=meal, person_id=person.id, created_date=datetime.now().astimezone()) - - async with conn.execute(''' - INSERT INTO ShoppingListRequest (meal_id, person_id, created_date) - VALUES (?, ?, ?) - ''', (request.meal_id, request.person_id, request.created_date.isoformat())) as cursor: - request.id = cursor.lastrowid - - return request - -async def unrequest_meal(conn, meal: Meal) -> None: - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE list_id IS NULL AND meal_id = ? - ''', (meal.id,)) - -def get_current_requests(conn) -> AsyncIterator[ShoppingListRequest]: - return find_requests_by_list_id(conn, None) - -async def sync_persons_requested_ingredients(conn, person: Person, requests: List[Ingredient]) -> AsyncIterator[ShoppingListRequest]: - # Delete existing and insert all as new - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE list_id IS NULL AND person_id = ? AND ingredient_id IS NOT NULL - ''', (person.id,)) - - for ingredient in requests: - ingredient.id = -1 - yield await request_ingredient(conn, person, ingredient) - -async def get_shopping_list_with_meal(conn, meal_id: int) -> AsyncIterator[ShoppingList]: - async with conn.execute(''' - SELECT list_id FROM ShoppingListRequest - WHERE meal_id = ? AND list_id IS NOT NULL - ''', (meal_id,)) as cursor: + async with conn.execute(f''' + SELECT {','.join(ShoppingListItem.KEYS)} + FROM ShoppingListItem + WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) + ''', meal_ids) as cursor: async for row in cursor: - list_id = row[0] - yield await load_shopping_list(conn, list_id) \ No newline at end of file + yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)}) \ No newline at end of file -- 2.45.2 From ba6a859ec74c388662561e999fb1ff95cae049c9 Mon Sep 17 00:00:00 2001 From: jableader Date: Mon, 28 Jul 2025 20:51:40 +1000 Subject: [PATCH 3/7] First render --- main.py | 48 ++++++++++++++++++++++++++++++++++++-------- shopping/__init__.py | 16 +++++++++------ shopping/db.py | 29 +++++++++++++------------- 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/main.py b/main.py index bb801e9..25dc8fd 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ import products, recipes, db, meals, persons, ingredients, shopping import datetime from pydantic import BaseModel -from typing import List, Annotated, Optional, Union +from typing import Dict, List, Annotated, Optional, Union from fastapi import FastAPI, Depends, Query, Cookie from fastapi.responses import JSONResponse from fastapi.encoders import jsonable_encoder @@ -252,17 +252,49 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) return meal class CurrentShoppingList(BaseModel): - requests: List[shopping.ShoppingListItem] - previously_purchased: List[shopping.ShoppingListItem] = [] - other_shopping_lists: List[shopping.ShoppingList] = [] + outstanding_items: List[shopping.ShoppingListItem] + requested_meals: List[shopping.ShoppingListItem] + purchased_items: List[shopping.ShoppingListItem] = [] + + ingredients_lookup: Dict[int, ingredients.Ingredient] = {} + meals_lookup: Dict[int, meals.Meal] = {} + shopping_list_lookup: Dict[int, shopping.ShoppingList] = {} + recipes_lookup: Dict[int, recipes.Recipe] = {} @app.get("/api/shopping/current") async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList: - outstanding_requests, purchased_requests = await shopping.get_outstanding_requests(conn) - other_shopping_list_ids = {item.shopping_list for item in purchased_requests} - other_shopping_lists = [await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids if list_id is not None] + outstanding_requests, purchased_requests, meal_requests = await shopping.get_outstanding_requests(conn) + other_shopping_list_ids = {item.list_id for item in purchased_requests} - return CurrentShoppingList(requests=outstanding_requests, previously_purchased=purchased_requests, other_shopping_lists=other_shopping_lists) + shopping_list_lookup = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids } + meals_lookup = {} + ingredients_lookup = {} + recipes_lookup = {} + + # Reduce the data structure to items and lookups + items = meal_requests + outstanding_requests + purchased_requests + [item for sl in shopping_list_lookup.values() for item in sl.items] + for item in items: + if item.meal and not item.meal.id in meals_lookup: + meals_lookup[item.meal.id] = item.meal + item.meal = None + + if item.ingredient and not item.ingredient.id in ingredients_lookup: + ingredients_lookup[item.ingredient.id] = item.ingredient + item.ingredient = None + + if item.recipe and not item.recipe.id in recipes_lookup: + recipes_lookup[item.recipe.id] = item.recipe + item.recipe = None + + return CurrentShoppingList( + outstanding_items=outstanding_requests, + requested_meals=meal_requests, + purchased_items=purchased_requests, + meals_lookup=meals_lookup, + shopping_list_lookup=shopping_list_lookup, + ingredients_lookup=ingredients_lookup, + recipes_lookup=recipes_lookup + ) @app.get("/api/shopping/{list_id}") async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: diff --git a/shopping/__init__.py b/shopping/__init__.py index cc69981..8e1ec32 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -11,19 +11,23 @@ async def get_persons_requests(conn, person_id: int) -> AsyncIterator[ShoppingLi def flatten_items(items: Iterator[ShoppingListItem]) -> Iterator[ShoppingListItem]: for item in items: if item.meal: - for ingredient in item.meal.ingredients + item.meal.extra_ingredients: + for mealRecipe in item.meal.recipes: + for ingredient in mealRecipe.recipe.ingredients: + yield ShoppingListItem(ingredient=ingredient, meal=item.meal, recipe=mealRecipe.recipe, person_id=item.person_id, created_date=item.created_date) + + for ingredient in item.meal.extra_ingredients: yield ShoppingListItem(ingredient=ingredient, meal=item.meal, person_id=item.person_id, created_date=item.created_date) else: yield item -async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem]]: +async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem], List[ShoppingListItem]]: current_requests = [r async for r in _find_items_by_list_id(conn, None)] - - meal_ids = {item.meal_id for item in current_requests if item.meal_id is not None and item.meal_id > 0} - purchased_ingredients = {r.ingredient_id async for r in _get_purchased_ingredients(conn, meal_ids)} + meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0 and r.meal is not None] + meals = {r.meal_id: r.meal for r in meal_requests} + purchased_ingredients = {r.ingredient_id async for r in _get_purchased_ingredients(conn, list(meals.keys()))} flattened = flatten_items(current_requests) outstanding_items = [r for r in flattened if r.ingredient_id not in purchased_ingredients] purchased_items = [r for r in flattened if r.ingredient_id in purchased_ingredients] - return outstanding_items, purchased_items + return outstanding_items, purchased_items, meal_requests diff --git a/shopping/db.py b/shopping/db.py index 1a378a5..6f2f877 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -1,4 +1,5 @@ from common import BaseLinkedModel +from recipes import Recipe from meals import Meal, find_meal_by_id from ingredients import Ingredient, insert_ingredient from persons import Person @@ -19,6 +20,9 @@ class ShoppingListItem(BaseLinkedModel): ingredient_id: Optional[int] = None ingredient: Optional[Ingredient] = None + recipe_id: Optional[int] = None + recipe: Optional[Recipe] = None + meal_id: Optional[int] = None meal: Optional[Meal] = None @@ -58,11 +62,13 @@ async def create(conn): list_id INTEGER, person_id INTEGER, meal_id INTEGER, + recipe_id INTEGER, created_date DATETIME NOT NULL, FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id), FOREIGN KEY(list_id) REFERENCES ShoppingList(id), FOREIGN KEY(person_id) REFERENCES Person(id), - FOREIGN KEY(meal_id) REFERENCES Meal(id) + FOREIGN KEY(meal_id) REFERENCES Meal(id), + FOREIGN KEY(recipe_id) REFERENCES Recipe(id) );''') @@ -110,7 +116,11 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: async with conn.execute(''' UPDATE ShoppingListItem SET list_id = ? - WHERE ingredient_id = ? AND person_id = ? AND meal_id IS NULL + WHERE ingredient_id = ? + AND list_id IS NULL + AND person_id = ? + AND meal_id IS NULL + AND recipe_id IS NULL ''', (shopping_list.id, item.ingredient_id, item.person_id)) as cursor: if cursor.rowcount == 0: raise ValueError('Ingredient request must have a valid person id and ingredient id') @@ -121,22 +131,13 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: raise ValueError('Meal request must have a valid meal id') async with conn.execute(''' - INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, created_date) - VALUES (?, ?, ?, ?, ?) - ''', (item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.created_date.isoformat())) as cursor: + INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date) + VALUES (?, ?, ?, ?, ?, ?) + ''', (item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.recipe_id, item.created_date.isoformat())) as cursor: item.id = cursor.lastrowid # TODO: Calculate which meals have been fulfilled and update meal status -async def requested_meal_ids(conn) -> AsyncIterator[ShoppingListItem]: - async with conn.execute(f''' - SELECT {ShoppingListItem.KEYS} - FROM ShoppingListItem - WHERE list_id IS NULL AND meal_id IS NOT NULL AND meal_id > 0 - ''') as cursor: - async for row in cursor: - yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)}) - async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None) -> ShoppingListItem: if ingredient is not None and meal is not None: raise ValueError('Cannot request both an ingredient and a meal') -- 2.45.2 From 78317fb5d40fef63909a4815cf2ab72534936eb0 Mon Sep 17 00:00:00 2001 From: jableader Date: Mon, 28 Jul 2025 21:50:49 +1000 Subject: [PATCH 4/7] Fixed person/meal saving in shopping --- shopping/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopping/db.py b/shopping/db.py index 6f2f877..b1d9ec2 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -105,7 +105,7 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: if item.ingredient_id is None or item.ingredient_id < 0: raise ValueError('Ingredient request must have a valid ingredient id') - isMeal = item.meal_id is None or item.meal_id < 0 + isMeal = item.meal_id is not None and item.meal_id >= 0 isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0 if not isMeal and not isPersonRequest: -- 2.45.2 From 571f187abdff9679aa41040542fa41d1fd64de13 Mon Sep 17 00:00:00 2001 From: jableader Date: Mon, 28 Jul 2025 22:36:56 +1000 Subject: [PATCH 5/7] View purchased shopping lists --- main.py | 35 ++++++++++++++++------------------- shopping/__init__.py | 18 +++++++++++++++++- shopping/db.py | 3 +++ 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 25dc8fd..c7214d8 100644 --- a/main.py +++ b/main.py @@ -267,24 +267,10 @@ async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) other_shopping_list_ids = {item.list_id for item in purchased_requests} shopping_list_lookup = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids } - meals_lookup = {} - ingredients_lookup = {} - recipes_lookup = {} # Reduce the data structure to items and lookups items = meal_requests + outstanding_requests + purchased_requests + [item for sl in shopping_list_lookup.values() for item in sl.items] - for item in items: - if item.meal and not item.meal.id in meals_lookup: - meals_lookup[item.meal.id] = item.meal - item.meal = None - - if item.ingredient and not item.ingredient.id in ingredients_lookup: - ingredients_lookup[item.ingredient.id] = item.ingredient - item.ingredient = None - - if item.recipe and not item.recipe.id in recipes_lookup: - recipes_lookup[item.recipe.id] = item.recipe - item.recipe = None + meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(items) return CurrentShoppingList( outstanding_items=outstanding_requests, @@ -296,18 +282,29 @@ async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) recipes_lookup=recipes_lookup ) +class PurchasedShoppingList(BaseModel): + list: shopping.ShoppingList + meals_lookup: Dict[int, meals.Meal] = {} + ingredients_lookup: Dict[int, ingredients.Ingredient] = {} + recipes_lookup: Dict[int, recipes.Recipe] = {} + @app.get("/api/shopping/{list_id}") -async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList: - return await shopping.load_shopping_list(conn, list_id) +async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList: + shopping_list = await shopping.load_shopping_list(conn, list_id) + result = PurchasedShoppingList(list=shopping_list) + shopping.remove_references(result.list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup) + return result @app.post("/api/shopping/") -async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingList: +async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList: shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name) await shopping.purchase(conn, shopping_list) await conn.commit() - return shopping_list + result = PurchasedShoppingList(list=shopping_list) + shopping.remove_references(shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup) + return result @app.get("/api/shopping/current/me/ingredients") async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]: diff --git a/shopping/__init__.py b/shopping/__init__.py index 8e1ec32..3a9c61b 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -1,8 +1,24 @@ -from typing import AsyncIterator, Iterator, List, Tuple +from typing import Any, AsyncIterator, Dict, Iterator, List, Tuple from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients +def remove_references(items: List[ShoppingListItem], meals_lookup = {}, recipes_lookup = {}, ingredients_lookup = {}) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]: + for item in items: + if item.meal and not item.meal.id in meals_lookup: + meals_lookup[item.meal.id] = item.meal + item.meal = None + + if item.ingredient and not item.ingredient.id in ingredients_lookup: + ingredients_lookup[item.ingredient.id] = item.ingredient + item.ingredient = None + + if item.recipe and not item.recipe.id in recipes_lookup: + recipes_lookup[item.recipe.id] = item.recipe + item.recipe = None + + return meals_lookup, recipes_lookup, ingredients_lookup + async def get_persons_requests(conn, person_id: int) -> AsyncIterator[ShoppingListItem]: async for item in _find_items_by_list_id(conn, None): if item.person_id == person_id and item.ingredient_id is not None: diff --git a/shopping/db.py b/shopping/db.py index b1d9ec2..e94da58 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -86,6 +86,9 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: if shopping_list.items is None or len(shopping_list.items) == 0: raise ValueError('Shopping list must have items') + + if shopping_list.purchased_by_id < 0: + raise ValueError('Shopping list must have a valid person id') shopping_list.created_date = datetime.now().astimezone() -- 2.45.2 From e06ad7611b1c41a6e57ab691ddc19ac2d70dff2d Mon Sep 17 00:00:00 2001 From: jableader Date: Tue, 29 Jul 2025 09:06:36 +1000 Subject: [PATCH 6/7] Set meal as purchased --- main.py | 11 +++++------ shopping/__init__.py | 6 +++++- shopping/db.py | 41 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index c7214d8..1345e62 100644 --- a/main.py +++ b/main.py @@ -234,19 +234,19 @@ async def mark_consumed(meal_id: int, consumed_date: Optional[datetime.datetime] return JSONResponse(status_code=404, content={'message': 'Meal not found'}) await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) - await shopping.unrequest_meal(conn, meal) + await shopping.remove_request(conn, person, meal=meal) await conn.commit() return meal @app.delete("/api/meals/{meal_id}") -async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal: +async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + await shopping.remove_request(conn, person, meal=meal) await meals.delete_meal(conn, meal.id) - await shopping.unrequest_meal(conn, meal) await conn.commit() return meal @@ -291,9 +291,8 @@ class PurchasedShoppingList(BaseModel): @app.get("/api/shopping/{list_id}") async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList: shopping_list = await shopping.load_shopping_list(conn, list_id) - result = PurchasedShoppingList(list=shopping_list) - shopping.remove_references(result.list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup) - return result + meals_lookup, recipes_lookup, ingredients_lookup = shopping.remove_references(shopping_list.items) + return PurchasedShoppingList(list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup) @app.post("/api/shopping/") async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList: diff --git a/shopping/__init__.py b/shopping/__init__.py index 3a9c61b..5443f04 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -3,7 +3,11 @@ from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purc from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients -def remove_references(items: List[ShoppingListItem], meals_lookup = {}, recipes_lookup = {}, ingredients_lookup = {}) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]: +def remove_references(items: List[ShoppingListItem], meals_lookup = None, recipes_lookup = None, ingredients_lookup = None) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]: + meals_lookup = meals_lookup or {} + recipes_lookup = recipes_lookup or {} + ingredients_lookup = ingredients_lookup or {} + for item in items: if item.meal and not item.meal.id in meals_lookup: meals_lookup[item.meal.id] = item.meal diff --git a/shopping/db.py b/shopping/db.py index e94da58..0aff51d 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -1,6 +1,6 @@ from common import BaseLinkedModel from recipes import Recipe -from meals import Meal, find_meal_by_id +from meals import Meal, find_meal_by_id, mark_purchased from ingredients import Ingredient, insert_ingredient from persons import Person from products import Product @@ -71,7 +71,6 @@ async def create(conn): FOREIGN KEY(recipe_id) REFERENCES Recipe(id) );''') - def validate_request(request: ShoppingListItem) -> None: if request.person_id < 0: raise ValueError('Requests must have a person') @@ -139,7 +138,40 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: ''', (item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.recipe_id, item.created_date.isoformat())) as cursor: item.id = cursor.lastrowid - # TODO: Calculate which meals have been fulfilled and update meal status + meal_ids = list({ item.meal_id for item in shopping_list.items if item.meal_id is not None and item.meal_id >= 0 }) + await update_purchased_meals(conn, meal_ids) + +async def update_purchased_meals(conn, meal_ids: List[int]) -> None: + if not meal_ids: + return + + purchased_ingredient_ids = {item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)} + for meal_id in meal_ids: + meal = await find_meal_by_id(conn, meal_id) + ingredients = {ingredient.id for recipe in meal.recipes for ingredient in recipe.recipe.ingredients} | \ + {ingredient.id for ingredient in meal.extra_ingredients} + + remaining_ingredients = ingredients - purchased_ingredient_ids + if not remaining_ingredients: + await mark_purchased(conn, meal) + + # If all ingredients are purchased, update the meal status + await conn.execute(''' + UPDATE Meal + SET purchase_date = ? + WHERE id = ? + ''', (datetime.now().isoformat(), meal.id)) + +async def is_requested(conn, meal: Meal) -> bool: + if meal.id < 0: + return False + + async with conn.execute(''' + SELECT COUNT(*) FROM ShoppingListItem + WHERE meal_id = ? AND list_id IS NULL + ''', (meal.id,)) as cursor: + row = await cursor.fetchone() + return row[0] > 0 async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None) -> ShoppingListItem: if ingredient is not None and meal is not None: @@ -158,6 +190,9 @@ async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, validate_request(item) + if meal is not None and await is_requested(conn, meal): + raise ValueError('Meal is already requested') + async with conn.execute(''' INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date) VALUES (?, ?, ?, ?) -- 2.45.2 From dca63bd51a64029b0c18bd0aff551b605dbf0dab Mon Sep 17 00:00:00 2001 From: jableader Date: Tue, 29 Jul 2025 09:13:40 +1000 Subject: [PATCH 7/7] Changed remove_request to not throw when item isn't requested --- shopping/db.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/shopping/db.py b/shopping/db.py index 0aff51d..aa6ed52 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -201,28 +201,22 @@ async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, return item -async def remove_request(conn, person: Person, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> None: - failed = True +async def remove_request(conn, person: Person, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> bool: if meal is not None: - # Ensure a record is removed async with conn.execute(''' DELETE FROM ShoppingListItem WHERE list_id IS NULL AND meal_id = ? ''', (meal.id,)) as cursor: - if cursor.rowcount > 0: - failed = False + return cursor.rowcount > 0 elif ingredient is not None: - # Ensure a record is removed async with conn.execute(''' DELETE FROM ShoppingListItem WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ? ''', (ingredient.id, person.id)) as cursor: - if cursor.rowcount > 0: - failed = False + return cursor.rowcount > 0 - if failed: - raise ValueError('Must specify either a meal or an ingredient to remove') + raise ValueError('Must specify either a meal or an ingredient to remove') async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]: # Join Ingredient and Product to also load ingredient and product -- 2.45.2