diff --git a/db.py b/db.py index 1566893..604bcf5 100644 --- a/db.py +++ b/db.py @@ -20,4 +20,18 @@ async def create(conn: aiosqlite.Connection): await meals_db.create(conn) import shopping.db as shopping_db - await shopping_db.create(conn) \ No newline at end of file + await shopping_db.create(conn) + +if __name__ == '__main__': + import asyncio + from tests.test_data import create_test_data + + async def main(): + conn = await connect() + await create(conn) + await conn.commit() + await create_test_data(conn) + await conn.commit() + await conn.close() + + asyncio.run(main()) \ No newline at end of file diff --git a/main.py b/main.py index 3c9e54b..33927a0 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ import sqlite3 -import products, recipes, db as db, meals, persons, ingredients +import products, recipes, db, meals, persons, ingredients, shopping import datetime from pydantic import BaseModel -from typing import List, Annotated +from typing import List, Annotated, Union from fastapi import FastAPI, Depends, Query, Cookie from fastapi.responses import JSONResponse from fastapi.encoders import jsonable_encoder @@ -226,6 +226,57 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) await conn.commit() return meal +@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'}) + + return await shopping.current_shopping_list(conn) + + return await shopping.load_shopping_list(conn, list_id) + +@app.post("/shopping/current/found") +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]: + current = await shopping.current_shopping_list(conn) + return [r async for r in shopping.get_persons_requests(conn, current, person) if r.ingredient] + +@app.post("/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]: + current = await shopping.current_shopping_list(conn) + result = [r async for r in shopping.sync_persons_requested_ingredients(conn, current, person, requests) if r.ingredient] + await conn.commit() + return result + +class MealIdWrapper(BaseModel): + meal_id: int + +@app.post("/shopping/current/meals/me") +async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListRequest: + current = await shopping.current_shopping_list(conn) + 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, current, person, meal) + await conn.commit() + return response + +@app.delete("/shopping/current/meals/{meal_id}") +async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict: + current = await shopping.current_shopping_list(conn) + 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.delete_requests(conn, current, meal) + await conn.commit() + return {} + @app.get("/persons/") async def get_persons(q: str = None, conn: sqlite3.Connection = Depends(get_db)) -> List[meals.Person]: query = persons.search_by_name(conn, q) if q else persons.get_all(conn) diff --git a/meals/db.py b/meals/db.py index 4cf2c02..104d7b1 100644 --- a/meals/db.py +++ b/meals/db.py @@ -91,7 +91,11 @@ async def find_meal_by_id(conn, meal_id: int) -> Meal: LIMIT 1 ''', (meal_id,)) as cursor: async for row in cursor: - return 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''' diff --git a/shopping/__init__.py b/shopping/__init__.py index 09a88d0..73eead4 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, sync_persons_requests +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 diff --git a/shopping/db.py b/shopping/db.py index 7547d52..71a19f4 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -1,4 +1,4 @@ -from meals import Meal +from meals import Meal, find_meals_by_date_range, find_meal_by_id from ingredients import Ingredient, insert_ingredient from persons import Person from products import Product @@ -6,14 +6,14 @@ from products import Product from pydantic import BaseModel from typing import AsyncIterator, List, ClassVar, Optional -from datetime import datetime +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 list_id: int - ingredient_id: int + ingredient_id: Optional[int] = None ingredient: Optional[Ingredient] = None person_id: Optional[int] = None @@ -104,6 +104,35 @@ async def insert_shopping_list(conn, shopping_list: ShoppingList): 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 + 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] + person_keys = [f'person.{key}' for key in Person.KEYS] + + async with conn.execute(f''' + 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 + LEFT JOIN Person ON ShoppingListRequest.person_id = Person.id + WHERE ShoppingListRequest.id = ? + ''', (id,)) as cursor: + async for row in cursor: + product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):len(Ingredient.KEYS) + len(Product.KEYS)])} + product = Product(**product_keys) if product_keys['id'] else None + + ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])} + ingredient = 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(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])} + request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person) + return request + async def find_requests_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListRequest]: # Join Ingredient and Product to also load ingredient and product ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] @@ -125,13 +154,17 @@ async def find_requests_by_list_id(conn, list_id: int) -> AsyncIterator[Shopping product = Product(**product_keys) if product_keys['id'] else None ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])} - ingredient = Ingredient(**ingredient_keys, product=product) + ingredient = Ingredient(**ingredient_keys, product=product) if ingredient_keys['id'] else None person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])} person = 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) + + if request.meal_id: + request.meal = await find_meal_by_id(conn, request.meal_id) + yield request async def find_items_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListResult]: @@ -175,6 +208,12 @@ async def load_shopping_list(conn, id: int) -> ShoppingList: return shopping_list +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): + yield meal + async def current_shopping_list(conn) -> ShoppingList: shopping_list = None async with conn.execute(f''' @@ -189,6 +228,10 @@ async def current_shopping_list(conn) -> ShoppingList: if not shopping_list: 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(),)) + await insert_shopping_list(conn, shopping_list) return shopping_list @@ -209,13 +252,13 @@ async def get_persons_requests(conn, shopping_list: ShoppingList, person: Person if request.person_id == person.id: yield request -async def request_ingredient(conn, shopping_list: ShoppingList, person: Person, ingredient: Ingredient): +async def request_ingredient(conn, shopping_list: ShoppingList, person: Person, ingredient: Ingredient) -> ShoppingListRequest: if ingredient.id: raise ValueError('How did you get an existing ingredient?') await insert_ingredient(conn, ingredient) - request = ShoppingListRequest(ingredient_id=ingredient.id, list_id=shopping_list.id, person_id=person.id, created_date=datetime.now()) + request = ShoppingListRequest(ingredient_id=ingredient.id, ingredient=ingredient, list_id=shopping_list.id, person_id=person.id, created_date=datetime.now()) async with conn.execute(''' INSERT INTO ShoppingListRequest (ingredient_id, list_id, person_id, created_date) VALUES (?, ?, ?, ?) @@ -223,19 +266,36 @@ async def request_ingredient(conn, shopping_list: ShoppingList, person: Person, request.id = cursor.lastrowid shopping_list.requests.append(request) + return request -async def sync_persons_requests(conn, shopping_list: ShoppingList, person: Person, requests: List[Ingredient]): +async def request_meal(conn, shopping_list: ShoppingList, person: Person, meal: Meal) -> ShoppingListRequest: + request = ShoppingListRequest(meal_id=meal.id, meal=meal, list_id=shopping_list.id, person_id=person.id, created_date=datetime.now()) + + async with conn.execute(''' + INSERT INTO ShoppingListRequest (meal_id, list_id, person_id, created_date) + VALUES (?, ?, ?, ?) + ''', (request.meal_id, request.list_id, request.person_id, request.created_date)) as cursor: + request.id = cursor.lastrowid + + return request + +async def delete_requests(conn, shopping_list: ShoppingList, meal: Meal) -> None: + await conn.execute(''' + DELETE FROM ShoppingListRequest + WHERE list_id = ? AND meal_id = ? + ''', (shopping_list.id, meal.id)) + +async def sync_persons_requested_ingredients(conn, shopping_list: ShoppingList, person: Person, requests: List[Ingredient]) -> AsyncIterator[ShoppingListRequest]: # Delete existing and insert all as new - async for request in get_persons_requests(conn, shopping_list, person): - await conn.execute(''' - DELETE FROM ShoppingListRequest - WHERE id = ? - ''', (request.id,)) + await conn.execute(''' + DELETE FROM ShoppingListRequest + WHERE list_id = ? AND person_id = ? AND ingredient_id IS NOT NULL + ''', (shopping_list.id, person.id)) for ingredient in requests: ingredient.id = 0 - await request_ingredient(conn, shopping_list, person, ingredient) - + yield await request_ingredient(conn, shopping_list, person, ingredient) + async def mark_found(conn, product: Product, quantity: float, unit: str) -> ShoppingListResult: shopping_list = await current_shopping_list(conn) existing = await find_existing_result(conn, product, shopping_list) diff --git a/tests/test_data.py b/tests/test_data.py index 33a50b3..9d8bddd 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -83,7 +83,7 @@ class Products: apple = products.Product( id=0, name="Apple", - product_id="0", + product_id="3542", 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", @@ -93,7 +93,7 @@ class Products: banana = products.Product( id=0, name="Banana", - product_id="0", + product_id="214", 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", @@ -101,6 +101,8 @@ class Products: ) _tags = { + apple.product_id: ['apple', 'fruit', 'fresh fruit'], + banana.product_id: ['banana', 'fruit', 'fresh fruit'], broccoli.product_id: ['broccoli', 'fresh broccoli'], garlic_bread.product_id: ['garlic bread', 'bread', 'garlic', 'frozen garlic bread'], beans_round.product_id: ['beans', 'green beans', 'fresh green beans', 'fresh beans'], @@ -226,35 +228,22 @@ async def create_persons(conn): for person in class_fields(Persons).values(): await persons.insert_person(conn, person) -if __name__ == '__main__': - import asyncio - from db import connect, create +async def create_test_data(conn): + await create_persons(conn) - async def initdb(): - conn = await connect() - await create(conn) - await conn.commit() + for product in class_fields(Products).values(): + await products.insert_product(conn, product, {}) + await products.add_missing_tags(conn, product, Products._tags[product.product_id]) - await create_persons(conn) + for recipe in class_fields(Recipes).values(): + 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) - for product in class_fields(Products).values(): - await products.insert_product(conn, product, {}) - await products.add_missing_tags(conn, product, Products._tags[product.product_id]) - - for recipe in class_fields(Recipes).values(): - 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) - - for meal in class_fields(Meals).values(): - await meals_db.insert_meal(conn, meal) - - await conn.commit() - await conn.close() - - asyncio.run(initdb()) + for meal in class_fields(Meals).values(): + await meals_db.insert_meal(conn, meal) """ import re diff --git a/tests/test_shopping.py b/tests/test_shopping.py index b666011..607ecbd 100644 --- a/tests/test_shopping.py +++ b/tests/test_shopping.py @@ -40,7 +40,7 @@ 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_requests(self.conn, shopping_list, person, [ingredient]) + 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) @@ -60,8 +60,8 @@ 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_requests(self.conn, shopping_list, person, [first]) - await shopping.sync_persons_requests(self.conn, shopping_list, person, [first, second]) + 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) @@ -82,7 +82,7 @@ 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_requests(self.conn, shopping_list, person, [ingredient]) + 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.current_shopping_list(self.conn)