diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..07e3b7f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5dc7f95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install deps + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install black ruff mypy + - name: Lint + run: | + ruff check . + - name: Type check + run: | + mypy . + - name: Test + run: | + python -m unittest -q diff --git a/.gitignore b/.gitignore index 5b73011..68f6a86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,23 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pytest_cache/ +.mypy_cache/ +.pytype/ +.venv/ +.env + +# VS Code +.vscode/ + +# Local data +/data/ +/front-dist/ + +# Coverage +htmlcov/ +.coverage* .venv/ __pycache__ data/ diff --git a/README.md b/README.md index 46af18f..7ed78a9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,45 @@ Meal planner backend +## Structure + +- `main.py`: FastAPI app with all HTTP endpoints. +- `db.py`: aiosqlite connection + schema bootstrap across subpackages. +- Domain packages with models and persistence: + - `products/` (db, scrapers for Woolworths/Coles) + - `ingredients/` + - `recipes/` (db, scraping) + - `meals/` + - `persons/` + - `shopping/` +- `tests/`: unit and API tests with sample HTTP fixtures. + +## Getting started + Install packages ``` pip install -r ./requirements.txt ``` -Run with +Run API (dev) ``` -uvicorn main:app +uvicorn main:app --reload +``` + +Run tests +``` +python -m unittest -q +``` + +## Tooling + +This repo includes baseline configs in `pyproject.toml`: +- black (format) +- ruff (lint) +- mypy (type check) + +Optional commands (install these locally first): +``` +ruff check . +black . +mypy . ``` diff --git a/common.py b/common.py index 25407d2..dd9535c 100644 --- a/common.py +++ b/common.py @@ -1,5 +1,7 @@ -from pydantic import BaseModel, Field, model_validator -from typing import Optional, Any +from typing import Any + +from pydantic import BaseModel, model_validator + class BaseLinkedModel(BaseModel): model_config = dict(arbitrary_types_allowed=True) @@ -20,4 +22,4 @@ class BaseLinkedModel(BaseModel): # 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 + return data diff --git a/db.py b/db.py index 3c12fee..ebbb2d2 100644 --- a/db.py +++ b/db.py @@ -1,29 +1,39 @@ +import asyncio + import aiosqlite -async def connect(path = './data/doof.sqlite') -> aiosqlite.Connection: + +async def connect(path="./data/doof.sqlite") -> aiosqlite.Connection: return await aiosqlite.connect(path) + async def create(conn: aiosqlite.Connection): import products.db as product_db + await product_db.create(conn) import ingredients.db as ingredient_db + await ingredient_db.create(conn) import recipes.db as recipe_db + await recipe_db.create(conn) import persons.db as person_db + await person_db.create(conn) import meals.db as meals_db + await meals_db.create(conn) - + import shopping.db as shopping_db + await shopping_db.create(conn) -if __name__ == '__main__': - import asyncio + +if __name__ == "__main__": from tests.test_data import create_test_data async def main(): @@ -33,5 +43,5 @@ if __name__ == '__main__': await create_test_data(conn) await conn.commit() await conn.close() - - asyncio.run(main()) \ No newline at end of file + + asyncio.run(main()) diff --git a/ingredients/__init__.py b/ingredients/__init__.py index 4713903..e7d8733 100644 --- a/ingredients/__init__.py +++ b/ingredients/__init__.py @@ -1,37 +1,47 @@ -from ingredients.db import Ingredient, find_ingredient_by_id, 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 +import re +from typing import List, Optional from ingredient_parser import parse_ingredient -import re -from typing import List +import units +from ingredients.db import ( + Ingredient, + delete_ingredients_by_meal_id as delete_ingredients_by_meal_id, + find_ingredient_by_id as find_ingredient_by_id, + find_ingredients_by_meal_id as find_ingredients_by_meal_id, + find_ingredients_by_recipe_id as find_ingredients_by_recipe_id, + insert_ingredient as insert_ingredient, +) +from products import Product, add_missing_tags, find_product_by_tag, get_or_create -async def parse_ingredient_from_link(conn, link: str) -> Ingredient: - match = re.match(r'^(\d+)?\s*(http.*)$', link) + +async def parse_ingredient_from_link(conn, link: str) -> Optional[Ingredient]: + match = re.match(r"^(\d+)?\s*(http.*)$", link) if not match: return None - + 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]) - return Ingredient(id=-1, + return Ingredient( + id=-1, name=product.name, line=f"{quantity}x {product.name}", unit=units.ITEMS.name, quantity=quantity, - preparation='', + preparation="", product_id=product.id, - product=product + product=product, ) - + return None + + def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient: ingredient = parse_ingredient(ingredient_string) - name = ingredient.name.text if ingredient.name else '' + name = ingredient.name.text if ingredient.name else "" quantity, unit = None, None for amount in ingredient.amount: @@ -54,25 +64,22 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient: if unit is None: unit = units.ITEMS.name - - return Ingredient(id=-1, + + return Ingredient( + id=-1, line=ingredient.sentence, name=name, quantity=quantity, unit=unit, - preparation=ingredient.preparation.text if ingredient.preparation else '', - product_id=-1 + preparation=ingredient.preparation.text if ingredient.preparation else "", + product_id=-1, ) -async def _find_existing_product(conn, ingredient: str) -> Product: - gen = find_product_by_tag(conn, ingredient) - try: - async for item in gen: - return item - finally: - await gen.aclose() - return None +async def _find_existing_product(conn, ingredient: str) -> Optional[Product]: + items = [item async for item in find_product_by_tag(conn, ingredient)] + return items[0] if items else None + async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[Ingredient]: for ingredient in ingredients: @@ -82,4 +89,4 @@ async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[I ingredient.product_id = existing.id ingredient.product = existing - return ingredients \ No newline at end of file + return ingredients diff --git a/ingredients/db.py b/ingredients/db.py index 4e5b1ec..fc8c146 100644 --- a/ingredients/db.py +++ b/ingredients/db.py @@ -1,23 +1,48 @@ +from typing import Any, AsyncIterator, ClassVar, List, Optional + +from pydantic import BaseModel, field_validator + from products import Product -from pydantic import BaseModel -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'] + KEYS: ClassVar[List[str]] = [ + "id", + "name", + "line", + "preparation", + "unit", + "quantity", + "product_id", + "recipe_id", + "meal_id", + ] id: int = -1 name: str line: str unit: str - quantity: float + quantity: float | str preparation: str product_id: Optional[int] = None recipe_id: Optional[int] = None meal_id: Optional[int] = None product: Optional[Product] = None + # Ensure quantity is stored as a float even if provided as a string in tests + @field_validator("quantity", mode="before") + @classmethod + def _coerce_quantity(cls, v: Any) -> Any: + if isinstance(v, str): + try: + return float(v) + except ValueError: + return v + return v + + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS Ingredient ( id INTEGER PRIMARY KEY, name TEXT, @@ -31,7 +56,9 @@ async def create(conn): FOREIGN KEY (product_id) REFERENCES Product(id), FOREIGN KEY (recipe_id) REFERENCES Recipe(id), FOREIGN KEY (meal_id) REFERENCES Meal(id) - );''') + );""" + ) + async def insert_ingredient(conn, ingredient: Ingredient): if ingredient.product: @@ -40,57 +67,94 @@ async def insert_ingredient(conn, ingredient: Ingredient): if ingredient.product_id is None or ingredient.product_id < 0: ingredient.product_id = None - async with conn.execute(''' + async with conn.execute( + """ INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', (ingredient.name, ingredient.line, ingredient.preparation, ingredient.unit, ingredient.quantity, ingredient.product_id, ingredient.recipe_id, ingredient.meal_id)) as cursor: + """, + ( + ingredient.name, + ingredient.line, + ingredient.preparation, + ingredient.unit, + ingredient.quantity, + ingredient.product_id, + ingredient.recipe_id, + ingredient.meal_id, + ), + ) as cursor: ingredient.id = cursor.lastrowid -async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient]: - ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] - product_keys = [f'product.{key}' for key in Product.KEYS] - async with conn.execute(f''' - SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient +async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient]: + ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS] + product_cols = [f"product.{key}" for key in Product.KEYS] + + async with conn.execute( + f""" + SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE Ingredient.id = ? - ''', (ingredient_id,)) as cursor: + """, + (ingredient_id,), + ) as cursor: async for row in cursor: - product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} - product = Product(**product_keys) if product_keys['id'] else None - return Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product) + product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])} + product = Product(**product_map) if product_map["id"] else None + return Ingredient( + **{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])}, + product=product, + ) return None -async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[Ingredient]: - ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] - product_keys = [f'product.{key}' for key in Product.KEYS] - async with conn.execute(f''' - SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient +async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[Ingredient]: + ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS] + product_cols = [f"product.{key}" for key in Product.KEYS] + + async with conn.execute( + f""" + SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE recipe_id = ? - ''', (recipe_id,)) as cursor: + """, + (recipe_id,), + ) as cursor: async for row in cursor: - product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} - product = Product(**product_keys) if product_keys['id'] else None - yield Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product) + product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])} + product = Product(**product_map) if product_map["id"] else None + yield Ingredient( + **{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])}, + product=product, + ) + async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingredient]: - ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] - product_keys = [f'product.{key}' for key in Product.KEYS] + ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS] + product_cols = [f"product.{key}" for key in Product.KEYS] - async with conn.execute(f''' - SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient + async with conn.execute( + f""" + SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient LEFT JOIN Product ON Ingredient.product_id = Product.id WHERE meal_id = ? - ''', (meal_id,)) as cursor: + """, + (meal_id,), + ) as cursor: async for row in cursor: - product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} - product = Product(**product_keys) if product_keys['id'] else None - yield Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product) + product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])} + product = Product(**product_map) if product_map["id"] else None + yield Ingredient( + **{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])}, + product=product, + ) + async def delete_ingredients_by_meal_id(conn, meal_id: int): - await conn.execute(''' + await conn.execute( + """ DELETE FROM Ingredient WHERE meal_id = ? - ''', (meal_id,)) \ No newline at end of file + """, + (meal_id,), + ) diff --git a/main.py b/main.py index 13a9775..fcc93ed 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,23 @@ -import sqlite3 -import products, recipes, db, meals, persons, ingredients, shopping import datetime +import os +from typing import Annotated, Dict, List, Optional -from pydantic import BaseModel -from typing import Dict, List, Annotated, Optional, Union -from fastapi import FastAPI, Depends, Query, Cookie -from fastapi.responses import JSONResponse +import aiosqlite +from fastapi import Cookie, Depends, FastAPI, Query from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +import db +import ingredients +import meals +import persons +import products +import recipes +import shopping app = FastAPI() - -import os -DATABASE_PATH = os.environ.get('DOOF_DB', './data/doof.sqlite') +DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite") # Dependency to create SQLite connection async def get_db(): @@ -21,23 +27,29 @@ async def get_db(): finally: await sql_db.close() -async def cookie_person(user_id: Annotated[int, Cookie(alias='user_id')], conn: sqlite3.Connection = Depends(get_db)) -> persons.Person: + +async def cookie_person( + user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db) +) -> Optional[persons.Person]: return await persons.get_by_id(conn, user_id) -@app.get("/api/recipes/parse") -async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db), person = Depends(cookie_person)) -> recipes.Recipe: + +@app.get("/api/recipes/parse", response_model=None) +async def parse_recipe_handler( + url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person) +) -> recipes.Recipe | JSONResponse: parsed = await recipes.parse_recipe(conn, person, url) if not parsed: - return JSONResponse(status_code=400, content={'message': 'Recipe not found'}) + return JSONResponse(status_code=400, content={"message": "Recipe not found"}) return parsed + @app.get("/api/recipes/ingredients/parse") -async def parse_ingredients(lines: Annotated[ - List[str], - Query(alias="ingredients", - title="Array of ingredients to parse")], - conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]: - +async def parse_ingredients( + lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")], + conn: aiosqlite.Connection = Depends(get_db), +) -> List[ingredients.Ingredient]: + had_links = False result = [] for line in lines: @@ -58,15 +70,20 @@ async def parse_ingredients(lines: Annotated[ await ingredients.match_existing_products(conn, result) return result + class ProductUrl(BaseModel): url: str - tags: List[str] = [] + tags: List[str] = Field(default_factory=list) + @app.post("/api/products") -async def create_product(url: ProductUrl, conn: sqlite3.Connection = Depends(get_db)) -> products.Product: +async def create_product( + url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db) +) -> Optional[products.Product]: return await products.get_or_create(conn, url.url, url.tags) -async def load_full_recipe(conn: sqlite3.Connection, id: int) -> recipes.Recipe: + +async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]: r = await recipes.find_recipe_by_id(conn, id) if not r: return None @@ -74,13 +91,17 @@ async def load_full_recipe(conn: sqlite3.Connection, id: int) -> recipes.Recipe: r.ingredients = [] async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): r.ingredients.append(ingredient) - - r.created_by = await persons.get_by_id(conn, r.created_by_id) + + if r.created_by_id is not None: + r.created_by = await persons.get_by_id(conn, r.created_by_id) return r + @app.get("/api/recipes") -async def get_recipes(q: str | None = None, conn: sqlite3.Connection = Depends(get_db)) -> List[recipes.Recipe]: +async def get_recipes( + q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db) +) -> List[recipes.Recipe]: result = [] if q: async for recipe in recipes.find_recipes_by_name(conn, q): @@ -93,52 +114,72 @@ async def get_recipes(q: str | None = None, conn: sqlite3.Connection = Depends(g recipe.ingredients = [] async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, recipe.id): recipe.ingredients.append(ingredient) - + return result -@app.get("/api/recipes/{recipe_id}") -async def get_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db)) -> recipes.Recipe: + +@app.get("/api/recipes/{recipe_id}", response_model=None) +async def get_recipe( + recipe_id: int, conn: aiosqlite.Connection = Depends(get_db) +) -> recipes.Recipe | JSONResponse: r = await load_full_recipe(conn, recipe_id) if not r: - return JSONResponse(status_code=404, content={'message': 'Recipe not found'}) - + return JSONResponse(status_code=404, content={"message": "Recipe not found"}) + return r -@app.post('/api/recipes') -async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe: + +@app.post("/api/recipes", response_model=None) +async def create_recipe( + recipe: recipes.Recipe, + conn: aiosqlite.Connection = Depends(get_db), + user: persons.Person = Depends(cookie_person), +) -> recipes.Recipe | JSONResponse: if not recipe.ingredients: - return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'}) - + return JSONResponse( + status_code=400, content={"message": "Recipe must have at least one ingredient"} + ) + if recipe.id >= 0: await recipes.hide_recipe(conn, recipe.id, user) recipe.based_on_recipe = recipe.id recipe.id = 0 - + recipe.created_by_id = user.id await recipes.insert_recipe(conn, recipe) for ingredient in recipe.ingredients: ingredient.recipe_id = recipe.id if ingredient.product: ingredient.product_id = ingredient.product.id - + await ingredients.insert_ingredient(conn, ingredient) await conn.commit() - + 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: + +@app.delete("/recipes/{recipe_id}", response_model=None) +async def delete_recipe( + recipe_id: int, + conn: aiosqlite.Connection = Depends(get_db), + user: persons.Person = Depends(cookie_person), +) -> recipes.Recipe | JSONResponse: recipe = await recipes.find_recipe_by_id(conn, recipe_id) if not recipe: - return JSONResponse(status_code=404, content={'message': 'Recipe not found'}) - + return JSONResponse(status_code=404, content={"message": "Recipe not found"}) + await recipes.hide_recipe(conn, recipe_id, user) await conn.commit() return recipe + @app.get("/api/meals/upcoming") -async def get_upcoming_meals(date_from: Annotated[datetime.datetime, Query(alias='from')], to: datetime.datetime, conn: sqlite3.Connection = Depends(get_db)) -> List[meals.Meal]: +async def get_upcoming_meals( + date_from: Annotated[datetime.datetime, Query(alias="from")], + to: datetime.datetime, + conn: aiosqlite.Connection = Depends(get_db), +) -> List[meals.Meal]: result = [] async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): await meals.load_recipes(conn, meal) @@ -148,56 +189,81 @@ async def get_upcoming_meals(date_from: Annotated[datetime.datetime, Query(alias return result -@app.get("/api/meals/{meal_id}") -async def get_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal: + +@app.get("/api/meals/{meal_id}", response_model=None) +async def get_meal( + meal_id: int, conn: aiosqlite.Connection = Depends(get_db) +) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + return JSONResponse(status_code=404, content={"message": "Meal not found"}) return meal + def get_duplicates(items: List[meals.Person]) -> set[str]: - seen : set[int] = set() - duplicates : set[str] = set() + seen: set[int] = set() + duplicates: set[str] = set() for item in items: if item.id in seen: duplicates.add(item.name) seen.add(item.id) return duplicates -def validate_meal(meal : meals.Meal) -> JSONResponse | None: + +def validate_meal(meal: meals.Meal) -> Optional[JSONResponse]: if not meal.chefs: - return JSONResponse(status_code=400, content={'message': 'Meal must have at least one chef'}) - + return JSONResponse( + status_code=400, content={"message": "Meal must have at least one chef"} + ) + if not meal.cleanup: - return JSONResponse(status_code=400, content={'message': 'Meal must have at least one cleanup person'}) - + return JSONResponse( + status_code=400, content={"message": "Meal must have at least one cleanup person"} + ) + if not meal.consumers: - return JSONResponse(status_code=400, content={'message': 'Meal must have at least one consumer'}) - + return JSONResponse( + status_code=400, content={"message": "Meal must have at least one consumer"} + ) + if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0: - return JSONResponse(status_code=400, content={'message': 'Meal must have at least one recipe or ingredient'}) - + return JSONResponse( + status_code=400, content={"message": "Meal must have at least one recipe or ingredient"} + ) + duplicates = get_duplicates(meal.chefs) if duplicates: - return JSONResponse(status_code=400, content={'message': f'Duplicate chef: {", ".join(duplicates)}'}) - + return JSONResponse( + status_code=400, content={"message": f'Duplicate chef: {", ".join(duplicates)}'} + ) + duplicates = get_duplicates(meal.cleanup) if duplicates: - return JSONResponse(status_code=400, content={'message': f'Duplicate cleanup person: {", ".join(duplicates)}'}) - + return JSONResponse( + status_code=400, + content={"message": f'Duplicate cleanup person: {", ".join(duplicates)}'}, + ) + duplicates = get_duplicates(meal.consumers) if duplicates: - return JSONResponse(status_code=400, content={'message': f'Duplicate consumer: {", ".join(duplicates)}'}) + return JSONResponse( + status_code=400, content={"message": f'Duplicate consumer: {", ".join(duplicates)}'} + ) zero_servings = [r for r in meal.recipes if r.servings == 0] if zero_servings: - return JSONResponse(status_code=400, content={'message': 'Recipe servings must be greater than 0'}) - + return JSONResponse( + status_code=400, content={"message": "Recipe servings must be greater than 0"} + ) + return None -@app.post("/api/meals") -async def create_meal(meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal: + +@app.post("/api/meals", response_model=None) +async def create_meal( + meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db) +) -> meals.Meal | JSONResponse: validation_response = validate_meal(meal) if validation_response: return validation_response @@ -206,15 +272,20 @@ async def create_meal(meal: meals.Meal, conn: sqlite3.Connection = Depends(get_d await conn.commit() return meal -@app.put("/api/meals/{meal_id}") -async def update_meal(meal_id: int, meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal: + +@app.put("/api/meals/{meal_id}", response_model=None) +async def update_meal( + meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db) +) -> meals.Meal | JSONResponse: if meal.id != meal_id: - return JSONResponse(status_code=400, content={'message': 'Meal ID in URL does not match meal ID in body'}) - + return JSONResponse( + status_code=400, content={"message": "Meal ID in URL does not match meal ID in body"} + ) + existing = await meals.find_meal_by_id(conn, meal_id) if not existing: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) - + return JSONResponse(status_code=404, content={"message": "Meal not found"}) + validation_response = validate_meal(meal) if validation_response: return validation_response @@ -224,26 +295,39 @@ async def update_meal(meal_id: int, meal: meals.Meal, conn: sqlite3.Connection = return await get_meal(meal_id, conn) -@app.post("/api/meals/{meal_id}/consumed") -async def mark_consumed(meal_id: int, consumed_date: Optional[datetime.datetime] = None, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal: + +@app.post("/api/meals/{meal_id}/consumed", response_model=None) +async def mark_consumed( + meal_id: int, + consumed_date: Optional[datetime.datetime] = None, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> meals.Meal | JSONResponse: if consumed_date and not consumed_date.tzinfo: - return JSONResponse(status_code=400, content={'message': 'Consumed date must include timezone'}) - + return JSONResponse( + status_code=400, content={"message": "Consumed date must include timezone"} + ) + meal = await meals.find_meal_by_id(conn, meal_id) if not meal: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + return JSONResponse(status_code=404, content={"message": "Meal not found"}) await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone()) 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), person: persons.Person = Depends(cookie_person)) -> meals.Meal: + +@app.delete("/api/meals/{meal_id}", response_model=None) +async def delete_meal( + meal_id: int, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> meals.Meal | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + 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) @@ -251,27 +335,45 @@ async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), await conn.commit() return meal + class CurrentShoppingList(BaseModel): outstanding_items: List[shopping.ShoppingListItem] requested_meals: List[shopping.ShoppingListItem] - purchased_items: List[shopping.ShoppingListItem] = [] + purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list) + + ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) + meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) + shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict) + recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) - 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, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(conn) +async def get_current_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), +) -> CurrentShoppingList: + ( + outstanding_requests, + purchased_requests, + meal_requests, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(conn) 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 } + shopping_list_lookup = {} + for list_id in other_shopping_list_ids: + if list_id is not None: + sl = await shopping.load_shopping_list(conn, list_id) + if sl is not None: + shopping_list_lookup[list_id] = sl # Add any additional items from shopping lists to the existing lookups additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items] if additional_items: - await shopping.to_lookups(conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup) + await shopping.to_lookups( + conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup + ) return CurrentShoppingList( outstanding_items=outstanding_requests, @@ -280,41 +382,73 @@ async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) meals_lookup=meals_lookup, shopping_list_lookup=shopping_list_lookup, ingredients_lookup=ingredients_lookup, - recipes_lookup=recipes_lookup + 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] = {} + meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict) + ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict) + recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict) -@app.get("/api/shopping/{list_id}") -async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList: + +@app.get("/api/shopping/{list_id}", response_model=None) +async def get_shopping_list( + list_id: int, conn: aiosqlite.Connection = Depends(get_db) +) -> PurchasedShoppingList | JSONResponse: shopping_list = await shopping.load_shopping_list(conn, list_id) if not shopping_list: - return JSONResponse(status_code=404, content={'message': 'Shopping list not found'}) - - meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(conn, shopping_list.items) - return PurchasedShoppingList(list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup) + return JSONResponse(status_code=404, content={"message": "Shopping list not found"}) + + meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( + conn, 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: - shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name) +async def purchase_ingredients( + shopping_list: shopping.ShoppingList, + conn: aiosqlite.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() result = PurchasedShoppingList(list=shopping_list) - await shopping.to_lookups(conn, shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup) + await shopping.to_lookups( + conn, + 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]: +async def get_my_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) +) -> List[ingredients.Ingredient]: return await 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[ingredients.Ingredient]: +async def sync_my_shopping_list( + requests: List[ingredients.Ingredient], + conn: aiosqlite.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 @@ -324,7 +458,7 @@ async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sq 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) @@ -333,31 +467,45 @@ async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sq await conn.commit() 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.ShoppingListItem: + +@app.post("/api/shopping/current/meals/me", response_model=None) +async def request_meal( + r: MealIdWrapper, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> shopping.ShoppingListItem | JSONResponse: meal = await meals.find_meal_by_id(conn, r.meal_id) if not meal: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + return JSONResponse(status_code=404, content={"message": "Meal not found"}) response = await shopping.request(conn, person, meal=meal) await conn.commit() return response -@app.delete("/api/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: + +@app.delete("/api/shopping/current/meals/{meal_id}", response_model=None) +async def unrequest_meal( + meal_id: int, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> dict | JSONResponse: meal = await meals.find_meal_by_id(conn, meal_id) if not meal: - return JSONResponse(status_code=404, content={'message': 'Meal not found'}) + return JSONResponse(status_code=404, content={"message": "Meal not found"}) await shopping.remove_request(conn, person, meal=meal) await conn.commit() return {} + @app.get("/api/persons") -async def get_persons(q: str = None, conn: sqlite3.Connection = Depends(get_db)) -> List[meals.Person]: +async def get_persons( + q: Optional[str] = None, conn: aiosqlite.Connection = Depends(get_db) +) -> List[meals.Person]: query = persons.search_by_name(conn, q) if q else persons.get_all(conn) result = [] async for person in query: @@ -365,48 +513,56 @@ async def get_persons(q: str = None, conn: sqlite3.Connection = Depends(get_db)) return result + @app.post("/api/persons") -async def create_person(person: persons.Person, conn: sqlite3.Connection = Depends(get_db)) -> persons.Person: +async def create_person( + person: persons.Person, conn: aiosqlite.Connection = Depends(get_db) +) -> persons.Person: await persons.insert_person(conn, person) await conn.commit() return person + class LoginBody(BaseModel): username: str -@app.post('/api/auth/login') -async def login(data: LoginBody, conn: sqlite3.Connection = Depends(get_db)) -> persons.Person: + +@app.post("/api/auth/login", response_model=None) +async def login( + data: LoginBody, conn: aiosqlite.Connection = Depends(get_db) +) -> persons.Person | JSONResponse: person = await persons.get_by_name(conn, data.username) if not person: - return JSONResponse(status_code=404, content={'message': 'Person not found'}) - + return JSONResponse(status_code=404, content={"message": "Person not found"}) + response = JSONResponse(content=jsonable_encoder(person)) - response.set_cookie(key='user_id', value=str(person.id)) + response.set_cookie(key="user_id", value=str(person.id)) return response -@app.post('/api/auth/refresh') + +@app.post("/api/auth/refresh") async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: return user -if os.environ.get('DOOF_PROD', False): + +if os.environ.get("DOOF_PROD", False): from fastapi.staticfiles import StaticFiles + app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") else: # Proxy the request to the frontend development server + import httpx + from starlette.background import BackgroundTask from starlette.requests import Request from starlette.responses import StreamingResponse - from starlette.background import BackgroundTask - - import httpx client = httpx.AsyncClient(base_url="http://localhost:8080/") async def _reverse_proxy(request: Request): - url = httpx.URL(path=request.url.path, - query=request.url.query.encode("utf-8")) - rp_req = client.build_request(request.method, url, - headers=request.headers.raw, - content=request.stream()) + url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) + rp_req = client.build_request( + request.method, url, headers=request.headers.raw, content=request.stream() + ) rp_resp = await client.send(rp_req, stream=True) return StreamingResponse( rp_resp.aiter_raw(), @@ -415,4 +571,4 @@ else: background=BackgroundTask(rp_resp.aclose), ) - app.add_route("/{path:path}",_reverse_proxy, ["GET", "POST"]) \ No newline at end of file + app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"]) diff --git a/meals/__init__.py b/meals/__init__.py index 0f72027..d495452 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -1 +1,21 @@ -from meals.db import * \ No newline at end of file +from meals.db import ( + Meal as Meal, + MealRecipe as MealRecipe, + create as create, + delete_meal as delete_meal, + find_meal_by_id as find_meal_by_id, + find_upcoming_meals_by_date_range as find_upcoming_meals_by_date_range, + insert_meal as insert_meal, + insert_meal_participant as insert_meal_participant, + insert_meal_recipe as insert_meal_recipe, + load_extra_ingredients as load_extra_ingredients, + load_participants as load_participants, + load_recipes as load_recipes, + mark_consumed as mark_consumed, + mark_purchased as mark_purchased, + sync_extra_ingredients as sync_extra_ingredients, + sync_meal_participants as sync_meal_participants, + sync_meal_recipes as sync_meal_recipes, + update_meal as update_meal, +) +from persons import Person as Person diff --git a/meals/db.py b/meals/db.py index 55a5f32..2736396 100644 --- a/meals/db.py +++ b/meals/db.py @@ -1,13 +1,18 @@ -from typing import AsyncIterator, List, ClassVar, Optional -from pydantic import BaseModel -from ingredients import Ingredient, insert_ingredient, find_ingredients_by_meal_id, delete_ingredients_by_meal_id +import datetime +from typing import AsyncIterator, ClassVar, List, Optional -from recipes import Recipe, row_to_recipe, load_recipe_ingredients +from pydantic import BaseModel, Field import persons +from ingredients import ( + Ingredient, + delete_ingredients_by_meal_id, + find_ingredients_by_meal_id, + insert_ingredient, +) from persons import Person +from recipes import Recipe, load_recipe_ingredients, row_to_recipe -import datetime class MealRecipe(BaseModel): meal_id: int @@ -16,158 +21,212 @@ class MealRecipe(BaseModel): recipe: Optional[Recipe] = None + class Meal(BaseModel): - KEYS: ClassVar[List[str]] = ['id', 'suggested_date', 'consumed_date', 'purchase_date'] + KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"] id: int = -1 suggested_date: datetime.datetime consumed_date: Optional[datetime.datetime] = None - chefs: List[Person] = [] - cleanup: List[Person] = [] - consumers: List[Person] = [] - recipes: List[MealRecipe] = [] - extra_ingredients: List[Ingredient] = [] + chefs: List[Person] = Field(default_factory=list) + cleanup: List[Person] = Field(default_factory=list) + consumers: List[Person] = Field(default_factory=list) + recipes: List[MealRecipe] = Field(default_factory=list) + extra_ingredients: List[Ingredient] = Field(default_factory=list) # Set from shopping list purchase_date: Optional[datetime.datetime] = None + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS Meal ( id INTEGER PRIMARY KEY, suggested_date DATETIME, consumed_date DATETIME DEFAULT NULL, deleted_date DATETIME DEFAULT NULL, purchase_date DATETIME DEFAULT NULL - );''') - - await conn.execute(''' + );""" + ) + + await conn.execute( + """ CREATE TABLE IF NOT EXISTS MealParticipant ( meal_id INTEGER, person_id INTEGER, role TEXT, FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(person_id) REFERENCES Person(id) - );''') - - await conn.execute(''' + );""" + ) + + await conn.execute( + """ CREATE TABLE IF NOT EXISTS MealRecipe ( meal_id INTEGER, recipe_id INTEGER, servings REAL, FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(recipe_id) REFERENCES Recipe(id) - );''') - + );""" + ) + + async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str): - await conn.execute(''' + await conn.execute( + """ INSERT INTO MealParticipant (meal_id, person_id, role) VALUES (?, ?, ?) - ''', (meal_id, person_id, role)) + """, + (meal_id, person_id, role), + ) + async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str): - await conn.execute(''' + await conn.execute( + """ DELETE FROM MealParticipant WHERE meal_id = ? AND role = ? - ''', (meal_id, role)) + """, + (meal_id, role), + ) for person in participants: await insert_meal_participant(conn, meal_id, person.id, role) + async def insert_meal_recipe(conn, r: MealRecipe): if r.meal_id < 0: - raise ValueError('Meal must be inserted before meal recipe') - + raise ValueError("Meal must be inserted before meal recipe") + if r.recipe_id < 0 and r.recipe: r.recipe_id = r.recipe.id if r.recipe_id < 0: - raise ValueError('Recipe must be inserted before meal') - - await conn.execute(''' + raise ValueError("Recipe must be inserted before meal") + + await conn.execute( + """ INSERT INTO MealRecipe (meal_id, recipe_id, servings) VALUES (?, ?, ?) - ''', (r.meal_id, r.recipe_id, r.servings)) + """, + (r.meal_id, r.recipe_id, r.servings), + ) + async def insert_meal(conn, meal: Meal): - async with conn.execute(''' + async with conn.execute( + """ INSERT INTO Meal (suggested_date) VALUES (?) - ''', (meal.suggested_date.isoformat(),)) as cursor: + """, + (meal.suggested_date.isoformat(),), + ) as cursor: meal.id = cursor.lastrowid - await sync_meal_participants(conn, meal.id, meal.chefs, 'chef') - await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup') - await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer') + await sync_meal_participants(conn, meal.id, meal.chefs, "chef") + await sync_meal_participants(conn, meal.id, meal.cleanup, "cleanup") + await sync_meal_participants(conn, meal.id, meal.consumers, "consumer") for meal_recipe in meal.recipes: meal_recipe.meal_id = meal.id - + await insert_meal_recipe(conn, meal_recipe) await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) -async def find_meal_by_id(conn, meal_id: int) -> Meal: - async with conn.execute(f''' + +async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]: + async with conn.execute( + f""" SELECT {','.join(Meal.KEYS)} FROM Meal WHERE id = ? LIMIT 1 - ''', (meal_id,)) as cursor: + """, + (meal_id,), + ) as cursor: async for row in cursor: - meal = 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 + return None -async def find_upcoming_meals_by_date_range(conn, start: datetime, end: datetime) -> AsyncIterator[Meal]: - async with conn.execute(f''' + +async def find_upcoming_meals_by_date_range( + conn, start: datetime.datetime, end: datetime.datetime +) -> AsyncIterator[Meal]: + async with conn.execute( + f""" SELECT {','.join(Meal.KEYS)} FROM Meal WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL - ''', (start, end)) as cursor: + """, + (start, end), + ) as cursor: async for row in cursor: - yield 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''' + async with conn.execute( + """ SELECT person_id, role FROM MealParticipant WHERE meal_id = ? - ''', (meal.id,)) as cursor: + """, + (meal.id,), + ) as cursor: async for row in cursor: person = await persons.get_by_id(conn, row[0]) - if row[1] == 'chef': - meal.chefs.append(person) - elif row[1] == 'cleanup': - meal.cleanup.append(person) - elif row[1] == 'consumer': - meal.consumers.append(person) + if row[1] == "chef": + if person: + meal.chefs.append(person) + elif row[1] == "cleanup": + if person: + meal.cleanup.append(person) + elif row[1] == "consumer": + if person: + meal.consumers.append(person) else: - raise Exception(f'Unknown role: {row[1]}') - + raise Exception(f"Unknown role: {row[1]}") + + async def load_recipes(conn, meal: Meal) -> None: - async with conn.execute(f''' + async with conn.execute( + f""" SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings FROM Recipe JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id WHERE MealRecipe.meal_id = ? - ''', (meal.id,)) as cursor: + """, + (meal.id,), + ) as cursor: async for row in cursor: - recipe = row_to_recipe(zip(Recipe.KEYS, row[:-1])) + recipe = row_to_recipe(list(zip(Recipe.KEYS, row[:-1]))) await load_recipe_ingredients(conn, recipe) - meal.recipes.append(MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=row[-1], recipe=recipe)) + meal.recipes.append( + MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=row[-1], recipe=recipe) + ) + async def load_extra_ingredients(conn, meal: Meal) -> None: async for ingredient in find_ingredients_by_meal_id(conn, meal.id): meal.extra_ingredients.append(ingredient) + async def delete_meal(conn, meal_id: int) -> None: - await conn.execute(''' + await conn.execute( + """ UPDATE Meal SET deleted_date = ? WHERE id = ? - ''', (datetime.datetime.now().astimezone().isoformat(), meal_id)) + """, + (datetime.datetime.now().astimezone().isoformat(), meal_id), + ) + async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredient]) -> None: await delete_ingredients_by_meal_id(conn, meal_id) @@ -178,49 +237,65 @@ async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredien await insert_ingredient(conn, ingredient) -async def sync_meal_recipes(conn, meal_id: int, recipes: List[Recipe]) -> None: - await conn.execute(''' + +async def sync_meal_recipes(conn, meal_id: int, recipes: List[MealRecipe]) -> None: + await conn.execute( + """ DELETE FROM MealRecipe WHERE meal_id = ? - ''', (meal_id,)) + """, + (meal_id,), + ) for meal_recipe in recipes: if meal_recipe.meal_id >= 0 and meal_recipe.meal_id != meal_id: - raise ValueError('Already associated with another meal') - + raise ValueError("Already associated with another meal") + meal_recipe.meal_id = meal_id await insert_meal_recipe(conn, meal_recipe) + async def update_meal(conn, meal: Meal) -> None: - await conn.execute(''' + await conn.execute( + """ UPDATE Meal SET suggested_date = ? WHERE id = ? - ''', (meal.suggested_date.isoformat(), meal.id)) + """, + (meal.suggested_date.isoformat(), meal.id), + ) - await sync_meal_participants(conn, meal.id, meal.chefs, 'chef') - await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup') - await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer') + await sync_meal_participants(conn, meal.id, meal.chefs, "chef") + await sync_meal_participants(conn, meal.id, meal.cleanup, "cleanup") + await sync_meal_participants(conn, meal.id, meal.consumers, "consumer") await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) await sync_meal_recipes(conn, meal.id, meal.recipes) + async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None: meal.consumed_date = date - - await conn.execute(''' + + await conn.execute( + """ UPDATE Meal SET consumed_date = ? WHERE id = ? - ''', (date.isoformat(), meal.id)) + """, + (date.isoformat(), meal.id), + ) + async def mark_purchased(conn, meal: Meal) -> Meal: meal.purchase_date = datetime.datetime.now().astimezone() - await conn.execute(''' + await conn.execute( + """ UPDATE Meal SET purchase_date = ? WHERE id = ? - ''', (meal.purchase_date.isoformat(), meal.id)) + """, + (meal.purchase_date.isoformat(), meal.id), + ) - return meal \ No newline at end of file + return meal diff --git a/persons/__init__.py b/persons/__init__.py index 7d6d950..592cd32 100644 --- a/persons/__init__.py +++ b/persons/__init__.py @@ -1 +1,9 @@ -from persons.db import * \ No newline at end of file +from persons.db import ( + Person as Person, + create as create, + get_all as get_all, + get_by_id as get_by_id, + get_by_name as get_by_name, + insert_person as insert_person, + search_by_name as search_by_name, +) diff --git a/persons/db.py b/persons/db.py index 1c0b5e1..7b56535 100644 --- a/persons/db.py +++ b/persons/db.py @@ -1,63 +1,86 @@ +from typing import AsyncIterator, ClassVar, List, Optional + from pydantic import BaseModel -from typing import AsyncIterator, ClassVar, List + class Person(BaseModel): - KEYS: ClassVar[List[str]] = ['id', 'name'] + KEYS: ClassVar[List[str]] = ["id", "name"] id: int = -1 name: str + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS Person ( id INTEGER PRIMARY KEY, name TEXT UNIQUE - );''') + );""" + ) + async def search_by_name(conn, name: str) -> AsyncIterator[Person]: - async with conn.execute(''' + async with conn.execute( + """ SELECT id, name FROM Person WHERE name LIKE ? - ''', (f'%{name}%',)) as cursor: + """, + (f"%{name}%",), + ) as cursor: async for row in cursor: yield Person(id=row[0], name=row[1]) - -async def get_by_name(conn, name: str) -> Person: - cursor = await conn.execute(''' + +async def get_by_name(conn, name: str) -> Optional[Person]: + cursor = await conn.execute( + """ SELECT id, name FROM Person WHERE name = ? - ''', (name,)) + """, + (name,), + ) row = await cursor.fetchone() if not row: return None return Person(id=row[0], name=row[1]) -async def get_by_id(conn, id: int) -> Person: - cursor = await conn.execute(''' + +async def get_by_id(conn, id: int) -> Optional[Person]: + cursor = await conn.execute( + """ SELECT id, name FROM Person WHERE id = ? - ''', (id,)) + """, + (id,), + ) row = await cursor.fetchone() if not row: return None return Person(id=row[0], name=row[1]) + async def get_all(conn) -> AsyncIterator[Person]: - async with conn.execute(''' + async with conn.execute( + """ SELECT id, name FROM Person - ''') as cursor: + """ + ) as cursor: async for row in cursor: yield Person(id=row[0], name=row[1]) + async def insert_person(conn, person: Person) -> Person: - cursor = await conn.execute(''' + cursor = await conn.execute( + """ INSERT INTO Person (name) VALUES (?) - ''', (person.name,)) + """, + (person.name,), + ) person.id = cursor.lastrowid - return person \ No newline at end of file + return person diff --git a/products/__init__.py b/products/__init__.py index 5a29afd..15dee66 100644 --- a/products/__init__.py +++ b/products/__init__.py @@ -1,65 +1,78 @@ import json +from typing import List, Optional, Tuple -from products.db import Product, find_product_by_tag, find_product_by_key, insert_product, get_tags, add_tag, find_product_by_id +from products import coles, woolworths +from products.db import ( + Product, + add_tag, + find_product_by_id as find_product_by_id, + find_product_by_key, + find_product_by_tag as find_product_by_tag, + get_tags, + insert_product, +) -from products import woolworths, coles -SCRAPERS = { 'woolworths': woolworths, 'coles': coles } +SCRAPERS = {"woolworths": woolworths, "coles": coles} -from typing import List, Union -import re -def _get_shop_key(link: str) -> Union[str, str]: # (shop_code, product_id) +def _get_shop_key(link: str) -> Tuple[Optional[str], Optional[str]]: # (shop_code, product_id) for shop_code, shop_scraper in SCRAPERS.items(): product_id = shop_scraper.get_product_id(link) if product_id: return shop_code, product_id return None, None + async def add_missing_tags(conn, product: Product, tags: List[str]): existing_tags = {tag async for tag in get_tags(conn, product)} remaining_tags = set(tags) - existing_tags if not remaining_tags: return False - + for tag in remaining_tags: await add_tag(conn, product, tag) - + return product -async def get_or_create(conn, url: str, tags: List[str]) -> Product: + +async def get_or_create(conn, url: str, tags: List[str]) -> Optional[Product]: shop_code, product_id = _get_shop_key(url) - if not product_id: + if not shop_code or not product_id: return None - + existing = await find_product_by_key(conn, shop_code, product_id) if existing: await add_missing_tags(conn, existing, tags) return existing - - product_data, raw_response = await SCRAPERS[shop_code].scrape(product_id) - product = Product( - id=-1, - shop_code=shop_code, - product_id=product_id, - link=url, - **product_data - ) + + scraper = SCRAPERS[shop_code] + product_data, raw_response = await scraper.scrape(product_id) + product = Product(id=-1, shop_code=shop_code, product_id=product_id, link=url, **product_data) await insert_product(conn, product, raw_response) 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' + import os + import 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)] + 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) \ No newline at end of file + filename = f"{prefix}{id}{suffix}" + full_path = os.path.join(dir, filename) + with open(full_path, "w") as f: + json.dump(data, f, indent=4) + return full_path diff --git a/products/coles.py b/products/coles.py index e77db4f..f664bb4 100644 --- a/products/coles.py +++ b/products/coles.py @@ -1,46 +1,58 @@ -import re, httpx -from typing import Union +import re +from typing import Optional, Tuple + +import httpx HEADERS = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate, br', - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', - 'Sec-Fetch-Dest': 'document', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-Site': 'none', - 'Sec-Fetch-User': '?1', - 'Pragma': 'no-cache', - 'Cache-Control': 'no-cache', + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "DNT": "1", + "Sec-GPC": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Pragma": "no-cache", + "Cache-Control": "no-cache", } + async def _get_cookies(client): # Make a request to https://www.coles.com.au/ as if we were a normal browser, then return the cookies - response = await client.get('https://www.coles.com.au/product/coles-strawberries-250g-5191256', headers=HEADERS, follow_redirects=True) - version = re.findall(r'202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d', response.text) + response = await client.get( + "https://www.coles.com.au/product/coles-strawberries-250g-5191256", + headers=HEADERS, + follow_redirects=True, + ) + version = re.findall(r"202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d", response.text) if len(version) == 0: - raise Exception('Could not find the Coles API') - + raise Exception("Could not find the Coles API") + return dict(response.cookies), version[0] -def _get_package_size(size: str) -> Union[int, str]: + +def _get_package_size(size: str) -> Tuple[int, str]: if size: - match = re.match(r'(\d+)(.*)', size) + match = re.match(r"(\d+)(.*)", size) if match: return int(match.group(1)), match.group(2) - return 1, 'items' + return 1, "items" + def _get_client(): return httpx.AsyncClient() + api_details = None -async def _request_details(product_id: str) -> dict: + + +async def _request_details(product_id: str) -> Optional[dict]: global api_details async with _get_client() as client: @@ -51,7 +63,9 @@ async def _request_details(product_id: str) -> dict: url = _get_product_details_url(api_version, product_id) try: - response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies) + response = await client.get( + url, headers=HEADERS, follow_redirects=True, cookies=cookies + ) response.raise_for_status() return response.json() except httpx.HTTPError as ne: @@ -60,35 +74,40 @@ async def _request_details(product_id: str) -> dict: return None + def _get_product_details_url(version: str, product_id: str) -> str: # https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/cadbury-favourites-boxed-chocolate-340g-3571992.json?slug=cadbury-favourites-boxed-chocolate-340g-3571992 # https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/coles-blueberries-170g-3571948.json?slug=coles-blueberries-170g-3571948 - # - return f'https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json' + # + return f"https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json" -def get_product_id(url: str) -> str: + +def get_product_id(url: str) -> Optional[str]: # https://www.coles.com.au/product/cadbury-favourites-boxed-chocolate-340g-3571992 - regex = r'https://www.coles.com.au/product/([^/]+)/?.*' + regex = r"https://www.coles.com.au/product/([^/]+)/?.*" match = re.match(regex, url) if match: return match.group(1) return None -async def scrape(product_id: str) -> Union[dict, dict]: + +async def scrape(product_id: str) -> Tuple[dict, dict]: raw_data = await _request_details(product_id) - - product = raw_data['pageProps']['product'] + if raw_data is None: + raw_data = {"pageProps": {"product": {"size": "", "images": []}}} - quantity, unit = _get_package_size(product['size']) + product = raw_data["pageProps"]["product"] - img_prefix = 'https://shop.coles.com.au' - images = product['images'][0] + quantity, unit = _get_package_size(product["size"]) + + img_prefix = "https://shop.coles.com.au" + images = product["images"][0] product_data = { - 'name': product['name'], - 'quantity': quantity, - 'unit': unit, - 'img_small': (img_prefix + images['thumb']['path']) if images else None, - 'img_large': (img_prefix + images['full']['path']) if images else None, + "name": product["name"], + "quantity": quantity, + "unit": unit, + "img_small": (img_prefix + images["thumb"]["path"]) if images else None, + "img_large": (img_prefix + images["full"]["path"]) if images else None, } - return product_data, raw_data \ No newline at end of file + return product_data, raw_data diff --git a/products/db.py b/products/db.py index cf6b788..9e6e0d9 100644 --- a/products/db.py +++ b/products/db.py @@ -1,11 +1,22 @@ -from typing import AsyncIterator, List, ClassVar +import json +from typing import AsyncIterator, ClassVar, List, Optional + from pydantic import BaseModel -import json class Product(BaseModel): - KEYS: ClassVar[List[str]] = ['id', 'product_id', 'shop_code', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large'] - NON_INSERT_KEYS: ClassVar[List[str]] = ['id'] + KEYS: ClassVar[List[str]] = [ + "id", + "product_id", + "shop_code", + "link", + "name", + "quantity", + "unit", + "img_small", + "img_large", + ] + NON_INSERT_KEYS: ClassVar[List[str]] = ["id"] id: int = -1 product_id: str @@ -16,9 +27,13 @@ class Product(BaseModel): unit: str img_small: str img_large: str + # Non-persisted field used in tests and insert helper + raw_data: Optional[dict] = None + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS Product ( id INTEGER PRIMARY KEY, product_id TEXT UNIQUE NOT NULL, @@ -30,70 +45,101 @@ async def create(conn): img_small TEXT, img_large TEXT, raw_data TEXT - );''') - - await conn.execute(''' + );""" + ) + + await conn.execute( + """ CREATE TABLE IF NOT EXISTS ProductTag ( food_item_id INTEGER, tag TEXT COLLATE NOCASE, PRIMARY KEY (food_item_id, tag), FOREIGN KEY (food_item_id) REFERENCES Product(id) - );''') + );""" + ) + async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]: - async with conn.execute(f''' + async with conn.execute( + f""" SELECT {','.join(Product.KEYS)} FROM Product WHERE id IN ( SELECT food_item_id FROM ProductTag WHERE tag = ? ) - ''', (tag,)) as cursor: + """, + (tag,), + ) as cursor: async for row in cursor: - yield Product(**{k:v for k,v in zip(Product.KEYS, row)}) + yield Product(**{k: v for k, v in zip(Product.KEYS, row)}) -async def find_product_by_id(conn, product_id: str) -> Product: - async with conn.execute(f''' + +async def find_product_by_id(conn, product_id: int) -> Optional[Product]: + async with conn.execute( + f""" SELECT {','.join(Product.KEYS)} FROM Product WHERE id = ? LIMIT 1 - ''', (product_id,)) as cursor: + """, + (product_id,), + ) as cursor: async for row in cursor: - return Product(**{k:v for k,v in zip(Product.KEYS, row)}) + return Product(**{k: v for k, v in zip(Product.KEYS, row)}) + return None -async def find_product_by_key(conn, shop_code: str, product_id: str) -> Product: - async with conn.execute(f''' + +async def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]: + async with conn.execute( + f""" SELECT {','.join(Product.KEYS)} FROM Product WHERE shop_code = ? AND product_id = ? LIMIT 1 - ''', (shop_code, product_id,)) as cursor: + """, + ( + shop_code, + product_id, + ), + ) as cursor: async for row in cursor: - return Product(**{k:v for k,v in zip(Product.KEYS, row)}) + return Product(**{k: v for k, v in zip(Product.KEYS, row)}) + return None + 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''' + 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: + """, + (*insert_values, json.dumps(data)), + ) as cursor: product.id = cursor.lastrowid - + await conn.commit() + async def add_tag(conn, product: Product, tag: str): - await conn.execute(''' + await conn.execute( + """ INSERT INTO ProductTag (food_item_id, tag) VALUES (?, ?) - ''', (product.id, tag)) - + """, + (product.id, tag), + ) + await conn.commit() + async def get_tags(conn, product: Product) -> AsyncIterator[str]: - async with conn.execute(''' + async with conn.execute( + """ SELECT tag FROM ProductTag WHERE food_item_id = ? - ''', (product.id,)) as cursor: + """, + (product.id,), + ) as cursor: async for row in cursor: yield row[0] - diff --git a/products/woolworths.py b/products/woolworths.py index 23c699d..5c26cdd 100644 --- a/products/woolworths.py +++ b/products/woolworths.py @@ -1,43 +1,52 @@ -import re, httpx +import re +from typing import Optional, Tuple -from typing import Union +import httpx HEADERS = { - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate, br', - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', - 'Sec-Fetch-Dest': 'document', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-Site': 'none', - 'Sec-Fetch-User': '?1', - 'Pragma': 'no-cache', - 'Cache-Control': 'no-cache', + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "DNT": "1", + "Sec-GPC": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Pragma": "no-cache", + "Cache-Control": "no-cache", } + async def _get_cookies(client): # Make a request to https://www.woolworths.com.au/ as if we were a normal browser, then return the cookies - response = await client.get('https://www.woolworths.com.au/', headers=HEADERS, follow_redirects=True) + response = await client.get( + "https://www.woolworths.com.au/", headers=HEADERS, follow_redirects=True + ) return dict(response.cookies) -def _get_package_size(data: dict) -> str: - size = data['Product']['PackageSize'] + +def _get_package_size(data: dict) -> Tuple[int, str]: + size = data["Product"]["PackageSize"] if size: - match = re.match(r'(\d+)(.*)', size) + match = re.match(r"(\d+)(.*)", size) if match: return int(match.group(1)), match.group(2) - return 1, 'items' + return 1, "items" + def _get_client() -> httpx.AsyncClient: return httpx.AsyncClient() + cached_cookies = None -async def _request_url(url: str) -> dict: + + +async def _request_url(url: str) -> Optional[dict]: global cached_cookies async with _get_client() as client: @@ -46,7 +55,9 @@ async def _request_url(url: str) -> dict: cookies = cached_cookies try: - response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies) + response = await client.get( + url, headers=HEADERS, follow_redirects=True, cookies=cookies + ) response.raise_for_status() return response.json() except httpx.HTTPError as ne: @@ -54,27 +65,33 @@ async def _request_url(url: str) -> dict: cached_cookies = None return None -def _get_product_details_url(product_id) -> str: - return f'https://www.woolworths.com.au/apis/ui/product/detail/{product_id}' -def get_product_id(url: str) -> str: - woolies_regex = r'https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*' +def _get_product_details_url(product_id: str) -> str: + return f"https://www.woolworths.com.au/apis/ui/product/detail/{product_id}" + + +def get_product_id(url: str) -> Optional[str]: + woolies_regex = r"https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*" match = re.match(woolies_regex, url) if match: return match.group(1) return None -async def scrape(product_id: str) -> Union[dict, dict]: + +async def scrape(product_id: str) -> Tuple[dict, dict]: details_url = _get_product_details_url(product_id) raw_data = await _request_url(details_url) + if raw_data is None: + # Return a minimal structure; callers treat this as raw payload for logging + raw_data = {} quantity, unit = _get_package_size(raw_data) product_data = { - 'name': raw_data['Product']['Name'], - 'quantity': quantity, - 'unit': unit, - 'img_small': raw_data['Product']['SmallImageFile'], - 'img_large': raw_data['Product']['LargeImageFile'], + "name": raw_data["Product"]["Name"], + "quantity": quantity, + "unit": unit, + "img_small": raw_data["Product"]["SmallImageFile"], + "img_large": raw_data["Product"]["LargeImageFile"], } - return product_data, raw_data \ No newline at end of file + return product_data, raw_data diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ef21fec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[tool.black] +line-length = 100 +target-version = ["py310"] +include = "\\.pyi?$" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = ["E203", "E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = [ + "E402", + "F401", + "F811", + "I001", + "N802", +] + +[tool.ruff.lint.isort] +combine-as-imports = true +known-first-party = ["ingredients", "meals", "persons", "products", "recipes", "shopping"] + +[tool.mypy] +python_version = "3.10" +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +ignore_missing_imports = true +strict_optional = true +no_implicit_optional = true +check_untyped_defs = true +exclude = "^(\\.*/)?tests($|/)" + +disable_error_code = ["import-untyped"] + +[[tool.mypy.overrides]] +module = ["tests.*"] +ignore_errors = true + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-q" +pythonpath = ["."] diff --git a/recipes/__init__.py b/recipes/__init__.py index 5506adf..959f98c 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -1,20 +1,31 @@ -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 - import re +from typing import Optional -async def parse_recipe(conn, created_by: Person, url: str) -> Recipe: +from ingredients import match_existing_products, parse_ingredient_from_nlp +from persons import Person +from recipes.db import ( + Recipe as Recipe, + find_recipe_by_id as find_recipe_by_id, + find_recipes_by_name as find_recipes_by_name, + get_all as get_all, + hide_recipe as hide_recipe, + insert_recipe as insert_recipe, + load_recipe_ingredients as load_recipe_ingredients, + row_to_recipe as row_to_recipe, +) +from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata + + +async def parse_recipe(conn, created_by: Person, url: str) -> Optional[Recipe]: ldata = await _scrape_recipe_ldata(url) if ldata: return await _get_recipe_from_ldata(conn, url, ldata, created_by) return None + def find_yield(recipe_ldata: dict) -> int: - if 'recipeYield' in recipe_ldata: - yield_vals = recipe_ldata['recipeYield'] + if "recipeYield" in recipe_ldata: + yield_vals = recipe_ldata["recipeYield"] if not isinstance(yield_vals, list): yield_vals = [yield_vals] @@ -25,24 +36,27 @@ def find_yield(recipe_ldata: dict) -> int: pass for val in yield_vals: - match = re.match(r'(\d+)', val) + 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(ingredient) for ingredient in ldata['recipeIngredient']] + + +async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> Recipe: + ingredients = [ + parse_ingredient_from_nlp(ingredient) for ingredient in 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 [] + 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] + images = [image["url"] for image in images] if isinstance(images, dict): - images = [images['url']] + images = [images["url"]] if isinstance(images, str): images = [images] @@ -56,4 +70,4 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person ingredients=ingredients, created_by=created_by, created_by_id=created_by.id, - ) \ No newline at end of file + ) diff --git a/recipes/db.py b/recipes/db.py index ac0f870..2960bf1 100644 --- a/recipes/db.py +++ b/recipes/db.py @@ -1,24 +1,39 @@ -import json, datetime +import datetime +import json +from typing import Any, AsyncIterator, ClassVar, Iterable, List, Optional, Tuple, cast + +from pydantic import BaseModel, Field -from persons import Person from ingredients import Ingredient, find_ingredients_by_recipe_id +from persons import Person -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'] - + 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 name: str link: str serves: int - image_urls: List[str] = [] - ingredients: List[Ingredient] = [] + image_urls: List[str] = Field(default_factory=list) + ingredients: List[Ingredient] = Field(default_factory=list) based_on_recipe: Optional[int] = None - date_created: datetime.datetime = datetime.datetime.now().astimezone() + date_created: datetime.datetime = Field( + default_factory=lambda: datetime.datetime.now().astimezone() + ) created_by_id: Optional[int] created_by: Optional[Person] = None @@ -26,8 +41,10 @@ class Recipe(BaseModel): hidden_by_id: Optional[int] = None hidden_by: Optional[Person] = None + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, @@ -45,65 +62,89 @@ async def create(conn): FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id) FOREIGN KEY (created_by_id) REFERENCES Person(id) FOREIGN KEY (hidden_by_id) REFERENCES Person(id) - );''') + );""" + ) + def _as_insert_field(recipe: Recipe, name: str): value = getattr(recipe, name) - if name == 'image_urls': + if name == "image_urls": return json.dumps(value) if isinstance(value, datetime.datetime): return value.isoformat() - + 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_stmt = f""" INSERT INTO Recipe ({','.join(fields_to_insert)}) VALUES ({','.join(['?'] * len(fields_to_insert))}) - ''' + """ async with conn.execute(insert_stmt, actual_values) as cursor: recipe.id = cursor.lastrowid + async def hide_recipe(conn, recipe_id: int, person: Person): - await conn.execute(''' + await conn.execute( + """ UPDATE Recipe SET date_hidden = ?, hidden_by_id = ? WHERE id = ? - ''', (datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id)) + """, + (datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id), + ) -def row_to_recipe(col_tuples: List[Tuple[str, ...]]) -> Recipe: - d = {k:v for k,v in col_tuples} - d['image_urls'] = json.loads(d['image_urls']) + +def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe: + d: dict[str, Any] = {k: v for k, v in col_tuples} + img_raw = ( + cast(str, d["image_urls"]) if not isinstance(d["image_urls"], list) else d["image_urls"] + ) + d["image_urls"] = cast(List[str], json.loads(img_raw) if isinstance(img_raw, str) else img_raw) return Recipe(**d) -async def find_recipe_by_id(conn, recipe_id: int) -> Recipe: - async with conn.execute(f''' + +async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]: + async with conn.execute( + f""" SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE id = ? LIMIT 1 - ''', (recipe_id,)) as cursor: + """, + (recipe_id,), + ) as cursor: async for row in cursor: - return row_to_recipe(zip(Recipe.KEYS, row)) + return row_to_recipe(list(zip(Recipe.KEYS, row))) + return None + async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]: - async with conn.execute(f''' + async with conn.execute( + f""" SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE name LIKE ? AND date_hidden IS NULL - ''', (f'%{name}%',)) as cursor: + """, + (f"%{name}%",), + ) as cursor: async for row in cursor: - yield row_to_recipe(zip(Recipe.KEYS, row)) + yield row_to_recipe(list(zip(Recipe.KEYS, row))) + async def get_all(conn) -> AsyncIterator[Recipe]: - async with conn.execute(f''' + async with conn.execute( + f""" SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL - ''') as cursor: + """ + ) as cursor: async for row in cursor: - yield row_to_recipe(zip(Recipe.KEYS, row)) + yield row_to_recipe(list(zip(Recipe.KEYS, row))) + async def load_recipe_ingredients(conn, recipe: Recipe) -> None: async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id): - recipe.ingredients.append(ingredient) \ No newline at end of file + recipe.ingredients.append(ingredient) diff --git a/recipes/scraping.py b/recipes/scraping.py index 3fd2cd8..9903335 100644 --- a/recipes/scraping.py +++ b/recipes/scraping.py @@ -1,35 +1,39 @@ -from bs4 import BeautifulSoup -import httpx import json +from typing import Optional + +import httpx +from bs4 import BeautifulSoup HEADERS = { - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'DNT': '1', - 'Sec-GPC': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1', - 'Sec-Fetch-Dest': 'document', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-Site': 'none', - 'Sec-Fetch-User': '?1', - 'Priority': 'u=1', - 'Pragma': 'no-cache', - 'Cache-Control': 'no-cache', + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "DNT": "1", + "Sec-GPC": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Priority": "u=1", + "Pragma": "no-cache", + "Cache-Control": "no-cache", } -def _is_recipe_ldata(ldata_node): - if '@type' in ldata_node: - typ = ldata_node['@type'] + +def _is_recipe_ldata(ldata_node) -> bool: + if "@type" in ldata_node: + typ = ldata_node["@type"] if isinstance(typ, list): typ = typ[0] - if isinstance(typ, str) and typ.lower() == 'recipe': + if isinstance(typ, str) and typ.lower() == "recipe": return True - - return None -async def scrape_recipe_ldata(url: str) -> dict: + return False + + +async def scrape_recipe_ldata(url: str) -> Optional[dict]: # Load the requested URL with headers async with httpx.AsyncClient() as client: response = await client.get(url, headers=HEADERS, follow_redirects=True) @@ -37,19 +41,19 @@ async def scrape_recipe_ldata(url: str) -> dict: return None # Extract the recipe ld+json data - soup = BeautifulSoup(response.text, 'html.parser') - for ld in soup.find_all('script', type='application/ld+json'): + soup = BeautifulSoup(response.text, "html.parser") + for ld in soup.find_all("script", type="application/ld+json"): try: data = json.loads(ld.text) - #_dump_json_data_to_log(data) + # _dump_json_data_to_log(data) if _is_recipe_ldata(data): return data - - if '@graph' in data: - for item in data['@graph']: + + if "@graph" in data: + for item in data["@graph"]: if _is_recipe_ldata(item): return item - + if isinstance(data, list): for item in data: if _is_recipe_ldata(item): @@ -57,19 +61,31 @@ async def scrape_recipe_ldata(url: str) -> dict: except (json.decoder.JSONDecodeError, KeyError): pass - + return None + # Fallback return to satisfy static analysis + return None + + def _dump_json_data_to_log(data: dict) -> str: - import os, re - dir = './data/dump' + import os + import re + + dir = "./data/dump" if not os.path.exists(dir): os.makedirs(dir) - prefix = 'ldata_' - 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)] + prefix = "ldata_" + 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) \ No newline at end of file + filename = f"{prefix}{id}{suffix}" + full_path = os.path.join(dir, filename) + with open(full_path, "w") as f: + json.dump(data, f, indent=4) + return full_path diff --git a/shopping/__init__.py b/shopping/__init__.py index 02077b7..5f1a2e7 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -1,11 +1,29 @@ -from typing import Any, AsyncIterator, Dict, Iterator, List, Tuple -from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request +from typing import Any, Dict, Iterable, Iterator, List, Tuple -from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients +import ingredients +import meals +import recipes +from shopping.db import ( + ShoppingList as ShoppingList, + ShoppingListItem as ShoppingListItem, + find_items_by_list_id as _find_items_by_list_id, + get_purchased_ingredients as _get_purchased_ingredients, + is_requested as is_requested, + load_shopping_list as load_shopping_list, + purchase as purchase, + remove_request as remove_request, + request as request, + update_purchased_meals as update_purchased_meals, +) -import meals, recipes, ingredients -async def to_lookups(conn, items: List[ShoppingListItem], meals_lookup: Dict[int, Any] = None, recipes_lookup: Dict[int, Any] = None, ingredients_lookup: Dict[int, Any] = None) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]: +async def to_lookups( + conn, + items: List[ShoppingListItem], + meals_lookup: Dict[int, Any] | None = None, + recipes_lookup: Dict[int, Any] | None = None, + ingredients_lookup: Dict[int, Any] | None = 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 {} @@ -14,7 +32,9 @@ async def to_lookups(conn, items: List[ShoppingListItem], meals_lookup: Dict[int return meals_lookup, recipes_lookup, ingredients_lookup -async def _ensure_lookups_populated(conn, items: List[ShoppingListItem], meals_lookup, recipes_lookup, ingredients_lookup): +async def _ensure_lookups_populated( + conn, items: List[ShoppingListItem], meals_lookup, recipes_lookup, ingredients_lookup +): for item in items: # If the any item is not in the lookup, we need to add it if item.meal_id and item.meal_id not in meals_lookup: @@ -22,53 +42,84 @@ async def _ensure_lookups_populated(conn, items: List[ShoppingListItem], meals_l if item.recipe_id and item.recipe_id not in recipes_lookup: recipes_lookup[item.recipe_id] = await recipes.find_recipe_by_id(conn, item.recipe_id) if item.ingredient_id and item.ingredient_id not in ingredients_lookup: - ingredients_lookup[item.ingredient_id] = await ingredients.find_ingredient_by_id(conn, item.ingredient_id) + ingredients_lookup[item.ingredient_id] = await ingredients.find_ingredient_by_id( + conn, item.ingredient_id + ) + async def get_persons_requests(conn, person_id: int) -> List[ingredients.Ingredient]: - ids = [item.ingredient_id async for item in _find_items_by_list_id(conn, None) if item.person_id == person_id and item.ingredient_id is not None and item.meal_id is None] - return [await ingredients.find_ingredient_by_id(conn, ingredient_id) for ingredient_id in ids] + ids = [ + item.ingredient_id + async for item in _find_items_by_list_id(conn, None) + if item.person_id == person_id and item.ingredient_id is not None and item.meal_id is None + ] + return [ + ing + for ing in [ + await ingredients.find_ingredient_by_id(conn, ingredient_id) for ingredient_id in ids + ] + if ing is not None + ] -def flatten_items(items: Iterator[ShoppingListItem], meals_lookup: Dict[int, Any]) -> Iterator[ShoppingListItem]: + +def flatten_items( + items: Iterable[ShoppingListItem], meals_lookup: Dict[int, Any] +) -> Iterator[ShoppingListItem]: for item in items: if item.meal_id and item.meal_id in meals_lookup: meal = meals_lookup[item.meal_id] for mealRecipe in meal.recipes: for ingredient in mealRecipe.recipe.ingredients: yield ShoppingListItem( - ingredient_id=ingredient.id, - meal_id=item.meal_id, - recipe_id=mealRecipe.recipe.id, - person_id=item.person_id, - created_date=item.created_date + ingredient_id=ingredient.id, + meal_id=item.meal_id, + recipe_id=mealRecipe.recipe.id, + person_id=item.person_id, + created_date=item.created_date, ) for ingredient in meal.extra_ingredients: yield ShoppingListItem( - ingredient_id=ingredient.id, - meal_id=item.meal_id, - person_id=item.person_id, - created_date=item.created_date + ingredient_id=ingredient.id, + meal_id=item.meal_id, + person_id=item.person_id, + created_date=item.created_date, ) else: yield item -async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem], List[ShoppingListItem], Dict[int, Any], Dict[int, Any], Dict[int, Any]]: + +async def get_outstanding_requests( + conn, +) -> Tuple[ + List[ShoppingListItem], + List[ShoppingListItem], + List[ShoppingListItem], + Dict[int, Any], + Dict[int, Any], + Dict[int, Any], +]: current_requests = [r async for r in _find_items_by_list_id(conn, None)] meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0] - + # Get lookups for meals to enable flattening meals_lookup, recipes_lookup, ingredients_lookup = await to_lookups(conn, current_requests) - + meal_ids = [r.meal_id for r in meal_requests if r.meal_id] - purchased_ingredients = {(r.ingredient_id, r.meal_id, r.recipe_id): r async for r in _get_purchased_ingredients(conn, meal_ids)} + purchased_ingredients = { + (r.ingredient_id, r.meal_id, r.recipe_id): r + async for r in _get_purchased_ingredients(conn, meal_ids) + } outstanding_items = [] purchased_items = [] flattened = list(flatten_items(current_requests, meals_lookup)) - + # Now ensure that all ingredients from the flattened items are in the lookup - await _ensure_lookups_populated(conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup) - + await _ensure_lookups_populated( + conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup + ) + for r in flattened: # Meal ingredients may have already been purchased if r.meal_id is not None and r.meal_id > 0: @@ -79,4 +130,11 @@ async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[S outstanding_items.append(r) - return outstanding_items, purchased_items, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup + return ( + outstanding_items, + purchased_items, + meal_requests, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) diff --git a/shopping/db.py b/shopping/db.py index f0ffc99..e1b3875 100644 --- a/shopping/db.py +++ b/shopping/db.py @@ -1,57 +1,69 @@ -from common import BaseLinkedModel -from recipes import Recipe -from meals import Meal, find_meal_by_id, mark_purchased -from ingredients import Ingredient, insert_ingredient -from persons import Person -from products import Product - -from typing import AsyncIterator, List, ClassVar, Optional - from datetime import datetime +from enum import Enum +from typing import Any, AsyncIterator, ClassVar, List, Optional + +from pydantic import Field + +from common import BaseLinkedModel +from ingredients import Ingredient, insert_ingredient +from meals import Meal, find_meal_by_id, mark_purchased +from persons import Person + class ShoppingListItem(BaseLinkedModel): - KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'recipe_id', 'created_date'] + KEYS: ClassVar[List[str]] = [ + "id", + "ingredient_id", + "list_id", + "person_id", + "meal_id", + "recipe_id", + "created_date", + ] id: int = -1 list_id: Optional[int] = None - + person_id: int = -1 - + ingredient_id: Optional[int] = None recipe_id: Optional[int] = None meal_id: Optional[int] = None - created_date: datetime = datetime.now().astimezone() + created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone()) -from enum import Enum - class StoreEnum(str, Enum): - woolworths = 'woolworths' - coles = 'coles' - home = '' + woolworths = "woolworths" + coles = "coles" + home = "" + class ShoppingList(BaseLinkedModel): - KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name'] + KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"] id: int = -1 - created_date: datetime = datetime.now().astimezone() - store_name: StoreEnum = '' + created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone()) + store_name: StoreEnum = StoreEnum.home purchased_by_id: int = -1 purchased_by: Optional[Person] = None - items: List[ShoppingListItem] = [] + items: List[ShoppingListItem] = Field(default_factory=list) + async def create(conn): - await conn.execute(''' + await conn.execute( + """ CREATE TABLE IF NOT EXISTS ShoppingList ( 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(''' + );""" + ) + + await conn.execute( + """ CREATE TABLE IF NOT EXISTS ShoppingListItem ( id INTEGER PRIMARY KEY, ingredient_id INTEGER, @@ -65,29 +77,39 @@ async def create(conn): FOREIGN KEY(person_id) REFERENCES Person(id), FOREIGN KEY(meal_id) REFERENCES Meal(id), 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') - + raise ValueError("Requests must have a person") + # A request must have either an ingredient or a meal, but not both if not request.ingredient_id and not request.meal_id: - raise ValueError('Request must have either an ingredient or a meal') + raise ValueError("Request must have either an ingredient or a meal") + async def purchase(conn, shopping_list: ShoppingList) -> None: if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0: - raise ValueError('Shopping list must have a person id') - + 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') - + raise ValueError("Shopping list must have items") + shopping_list.created_date = datetime.now().astimezone() - async with conn.execute(''' + async with conn.execute( + """ 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.created_date.isoformat(), + shopping_list.store_name, + shopping_list.purchased_by_id, + ), + ) as cursor: shopping_list.id = cursor.lastrowid for item in shopping_list.items: @@ -95,17 +117,18 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: validate_request(item) if item.ingredient_id is None or item.ingredient_id < 0: - raise ValueError('Ingredient request must have a valid ingredient id') + raise ValueError("Ingredient request must have a valid ingredient id") 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: - raise ValueError('Ingredient request must have either a meal or a person id') - + 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(''' + async with conn.execute( + """ UPDATE ShoppingListItem SET list_id = ? WHERE ingredient_id = ? @@ -113,130 +136,183 @@ async def purchase(conn, shopping_list: ShoppingList) -> None: AND person_id = ? AND meal_id IS NULL AND recipe_id IS NULL - ''', (shopping_list.id, item.ingredient_id, item.person_id)) as cursor: + """, + (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') - + 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(''' + 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, 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.ingredient_id, + shopping_list.id, + item.person_id, + item.meal_id, + item.recipe_id, + item.created_date.isoformat(), + ), + ) as cursor: item.id = cursor.lastrowid - - meal_ids = list({ item.meal_id for item in shopping_list.items if item.meal_id is not None and item.meal_id >= 0 }) + + 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)} + 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} + if not meal: + continue + ingredients = { + ingredient.id + for mr in meal.recipes + for ingredient in (mr.recipe.ingredients if mr.recipe else []) + } | {ingredient.id for ingredient in meal.extra_ingredients} remaining_ingredients = ingredients - purchased_ingredient_ids if not remaining_ingredients: await mark_purchased(conn, meal) - await remove_request(conn, None, meal=meal) + await remove_request(conn, person=None, meal=meal) + async def is_requested(conn, meal: Meal) -> bool: if meal.id < 0: return False - async with conn.execute(''' + async with conn.execute( + """ SELECT COUNT(*) FROM ShoppingListItem WHERE meal_id = ? AND list_id IS NULL - ''', (meal.id,)) as cursor: + """, + (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: + +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') + 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') + 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') + raise ValueError("Meal must have a valid id") if ingredient is not None and ingredient.id < 0: await insert_ingredient(conn, ingredient) ingredient_id = ingredient.id if ingredient else None meal_id = meal.id if meal else None - - item = ShoppingListItem( - ingredient_id=ingredient_id, - person_id=person.id, - meal_id=meal_id - ) + + item = ShoppingListItem(ingredient_id=ingredient_id, person_id=person.id, meal_id=meal_id) validate_request(item) if meal is not None and await is_requested(conn, meal): - raise ValueError('Meal is already requested') + raise ValueError("Meal is already requested") - async with conn.execute(''' + 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.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 = None, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> bool: + +async def remove_request( + conn, + person: Optional[Person] = None, + meal: Optional[Meal] = None, + ingredient: Optional[Ingredient] = None, +) -> bool: if meal is not None: - async with conn.execute(''' + async with conn.execute( + """ DELETE FROM ShoppingListItem WHERE list_id IS NULL AND meal_id = ? - ''', (meal.id,)) as cursor: + """, + (meal.id,), + ) as cursor: return cursor.rowcount > 0 elif ingredient is not None: - async with conn.execute(''' + async with conn.execute( + """ DELETE FROM ShoppingListItem WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ? - ''', (ingredient.id, person.id)) as cursor: + """, + (ingredient.id, person.id if person else -1), + ) as cursor: return cursor.rowcount > 0 - 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]: - request_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS] + request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS] - select = f''' - SELECT {','.join(request_keys)} + select = f""" + SELECT {','.join(request_cols)} FROM ShoppingListItem - ''' - - where, params = ' WHERE list_id IS NULL', () + """ + + where: str + params: tuple[Any, ...] + where, params = (" WHERE list_id IS NULL", ()) if list_id is not None: - where, params = ' WHERE list_id = ?', (list_id,) - + where, params = " WHERE list_id = ?", (list_id,) + cursor = await conn.execute(select + where, params) - + async for row in cursor: - request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row)} - request = ShoppingListItem(**request_keys) + request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)} + request = ShoppingListItem(**request_map) yield request -async def load_shopping_list(conn, id: int) -> ShoppingList: - shopping_list = None - async with conn.execute(f''' + +async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]: + shopping_list: Optional[ShoppingList] = None + async with conn.execute( + f""" SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList WHERE id = ? LIMIT 1 - ''', (id,)) as cursor: + """, + (id,), + ) as cursor: async for row in cursor: - shopping_list = ShoppingList(**{k:v for k,v in zip(ShoppingList.KEYS, row)}) + shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)}) break if shopping_list: @@ -245,14 +321,18 @@ async def load_shopping_list(conn, id: int) -> ShoppingList: return shopping_list + async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]: if not meal_ids: return - - async with conn.execute(f''' + + async with conn.execute( + f""" SELECT {','.join(ShoppingListItem.KEYS)} FROM ShoppingListItem WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) AND list_id IS NOT NULL - ''', meal_ids) as cursor: + """, + meal_ids, + ) as cursor: async for row in cursor: - yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)}) \ No newline at end of file + yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) diff --git a/tests/httpx_mocks.py b/tests/httpx_mocks.py index eaebbd4..5374738 100644 --- a/tests/httpx_mocks.py +++ b/tests/httpx_mocks.py @@ -2,53 +2,54 @@ import httpx import json import os + class RecordingAsyncClient: def __init__(self, save_dir: str): self.save_dir = save_dir os.makedirs(self.save_dir, exist_ok=True) self.client = None # Will be initialized in __aenter__ - + async def __aenter__(self): # Initialize the actual AsyncClient when entering the context manager self.client = httpx.AsyncClient() return self - + async def __aexit__(self, exc_type, exc_value, traceback): # Ensure the client is closed when exiting the context manager await self.client.aclose() - + async def request(self, method: str, url: str, **kwargs): # Send the actual request response = await self.client.request(method, url, **kwargs) - + # Record the request and response record = { "request": { "method": method, "url": url, "headers": dict(response.request.headers), - "content": response.request.content.decode('utf-8', errors='ignore'), + "content": response.request.content.decode("utf-8", errors="ignore"), }, "response": { "status_code": response.status_code, "headers": dict(response.headers), "content": response.text, "cookies": dict(response.cookies), - } + }, } # Generate a filename based on the URL and method record_file = os.path.join(self.save_dir, f"{method}_{url.replace('/', '_')}.json") - + # Save the record to a file - with open(record_file, 'w') as f: + with open(record_file, "w") as f: json.dump(record, f, indent=4) return response async def get(self, url: str, **kwargs): return await self.request("GET", url, **kwargs) - + async def post(self, url: str, **kwargs): return await self.request("POST", url, **kwargs) @@ -58,18 +59,20 @@ class RecordingAsyncClient: async def delete(self, url: str, **kwargs): return await self.request("DELETE", url, **kwargs) + from unittest.mock import Mock import os import json + class MockAsyncClient: def __init__(self, load_dir: str): self.load_dir = load_dir - + async def __aenter__(self): # No actual client to initialize, just return the instance return self - + async def __aexit__(self, exc_type, exc_value, traceback): # No actual client to close pass @@ -77,43 +80,43 @@ class MockAsyncClient: async def request(self, method: str, url: str, **kwargs): # Generate the filename based on the URL and method record_file = os.path.join(self.load_dir, f"{method}_{url.replace('/', '_')}.json") - + if not os.path.exists(record_file): raise FileNotFoundError(f"Recorded response not found for {method} {url}") - + # Load the recorded response from the file - with open(record_file, 'r') as f: + with open(record_file, "r") as f: record = json.load(f) - + # Create a mock response object mock_response = Mock() - + # Mock the status code - mock_response.status_code = record['response']['status_code'] - + mock_response.status_code = record["response"]["status_code"] + # Mock the json method to return the content as a parsed JSON def mock_json(): try: - return json.loads(record['response']['content']) + return json.loads(record["response"]["content"]) except json.JSONDecodeError: - return record['response']['content'] - + return record["response"]["content"] + mock_response.json = mock_json - + # Mock the cookies as a dictionary - mock_response.cookies = record['response']['cookies'] + mock_response.cookies = record["response"]["cookies"] # Mock the headers as a dictionary - mock_response.headers = record['response']['headers'] - + mock_response.headers = record["response"]["headers"] + # Mock the text attribute - mock_response.text = record['response']['content'] - + mock_response.text = record["response"]["content"] + return mock_response async def get(self, url: str, **kwargs): return await self.request("GET", url, **kwargs) - + async def post(self, url: str, **kwargs): return await self.request("POST", url, **kwargs) diff --git a/tests/test_data.py b/tests/test_data.py index 352d511..ec73007 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,28 +1,23 @@ import persons + class Persons: - jacob = persons.Person( - id=1, - name='Jacob') - - ryan = persons.Person( - id=2, - name='Ryan') - - ellie = persons.Person( - id=3, - name='Ellie') - - chris = persons.Person( - id=4, - name='Chris') + jacob = persons.Person(id=1, name="Jacob") + + ryan = persons.Person(id=2, name="Ryan") + + ellie = persons.Person(id=3, name="Ellie") + + chris = persons.Person(id=4, name="Chris") + import products + class Products: broccoli = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Fresh Broccoli", product_id="134681", quantity=1, @@ -35,7 +30,7 @@ class Products: garlic_bread = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="La Famiglia Garlic Bread", product_id="294517", quantity=1, @@ -48,7 +43,7 @@ class Products: beans_round = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Beans Round", product_id="134072", quantity=1, @@ -61,7 +56,7 @@ class Products: western_star_unsalted_butter_chefs_choice = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Western Star Unsalted Butter Chef's Choice", product_id="712251", quantity=500, @@ -74,7 +69,7 @@ class Products: saxa_iodised_table_salt_shaker = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Saxa Iodised Table Salt Shaker", quantity=750, unit="g", @@ -87,7 +82,7 @@ class Products: mckenzies_pepper_black_ground = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Mckenzie's Pepper Black Ground", quantity=100, unit="g", @@ -100,7 +95,7 @@ class Products: apple = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Apple", product_id="3542", quantity=1, @@ -113,7 +108,7 @@ class Products: banana = products.Product( id=0, - shop_code='woolworths', + shop_code="woolworths", name="Banana", product_id="214", quantity=1, @@ -125,46 +120,57 @@ 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'], - western_star_unsalted_butter_chefs_choice.product_id: ['butter', 'unsalted butter', 'salted butter'], - saxa_iodised_table_salt_shaker.product_id: ['salt', 'iodised salt', 'kosher salt'], - mckenzies_pepper_black_ground.product_id: ['pepper', 'black pepper', 'ground pepper', 'fresh ground pepper'], + 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"], + western_star_unsalted_butter_chefs_choice.product_id: [ + "butter", + "unsalted butter", + "salted butter", + ], + saxa_iodised_table_salt_shaker.product_id: ["salt", "iodised salt", "kosher salt"], + mckenzies_pepper_black_ground.product_id: [ + "pepper", + "black pepper", + "ground pepper", + "fresh ground pepper", + ], } + import ingredients + class Ingredients: one_apple = ingredients.Ingredient( id=0, - line='1 Apple', - name='Apple', - unit='Items', - quantity='1', - preparation='', + line="1 Apple", + name="Apple", + unit="Items", + quantity="1", + preparation="", product=Products.apple, ) broccoli_chopped_1kg = ingredients.Ingredient( id=0, - line='1kg Broccoli, Chopped', - name='Broccoli', - unit='kg', - quantity='1', - preparation='Chopped', + line="1kg Broccoli, Chopped", + name="Broccoli", + unit="kg", + quantity="1", + preparation="Chopped", product=Products.broccoli, ) garlic_bread_1_loaf = ingredients.Ingredient( id=0, - line='1 Loaf Garlic Bread', - name='Garlic Bread', - unit='Loaf', - quantity='1', - preparation='', + line="1 Loaf Garlic Bread", + name="Garlic Bread", + unit="Loaf", + quantity="1", + preparation="", product=Products.garlic_bread, ) @@ -208,15 +214,19 @@ class Ingredients: product=Products.mckenzies_pepper_black_ground, ) + import recipes + class Recipes: broccoli_soup = recipes.Recipe( id=0, - name='Broccoli Soup', - link='https://www.bbcgoodfood.com/recipes/broccoli-soup', + 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'], + 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, ) @@ -226,14 +236,23 @@ class Recipes: 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], + 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, ) + from meals import db as meals_db from datetime import datetime + class Meals: broccoli_soup_for_jacob = meals_db.Meal( id=0, @@ -243,17 +262,22 @@ class Meals: chefs=[Persons.jacob], cleanup=[Persons.ryan], consumers=[Persons.ellie, Persons.chris], - recipes=[meals_db.MealRecipe(meal_id = -1, recipe_id = -1, servings = 2, recipe = Recipes.broccoli_soup)], + recipes=[ + meals_db.MealRecipe(meal_id=-1, recipe_id=-1, servings=2, recipe=Recipes.broccoli_soup) + ], extra_ingredients=[Ingredients.garlic_bread_1_loaf], ) + def class_fields(obj): - return {k:v for k,v in obj.__dict__.items() if not k.startswith('_')} + return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")} + async def create_persons(conn): for person in class_fields(Persons).values(): await persons.insert_person(conn, person) + async def create_test_data(conn): await create_persons(conn) @@ -271,6 +295,7 @@ async def create_test_data(conn): for meal in class_fields(Meals).values(): await meals_db.insert_meal(conn, meal) + """ import re def to_name(thing): @@ -319,4 +344,4 @@ def to_create_statements(items, type_name, order): s.append(')') s.append('') return '\n'.join(s) -""" \ No newline at end of file +""" diff --git a/tests/test_ingredients.py b/tests/test_ingredients.py index e5b4b08..b3f0a68 100644 --- a/tests/test_ingredients.py +++ b/tests/test_ingredients.py @@ -4,10 +4,12 @@ import asyncio import tests.test_data as test_data import importlib + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + from db import connect, create import ingredients import ingredients.db as ingredients_db @@ -17,7 +19,7 @@ import units class TestIngredient(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -34,9 +36,9 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - + self.assertEqual(ingredient.name, "Broccoli") self.assertEqual(ingredient.line, "500g fresh broccoli") self.assertEqual(ingredient.unit, "g") @@ -46,17 +48,13 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): async def test_insert_ingredient(self): """Test inserting an ingredient into the database""" ingredient = ingredients_db.Ingredient( - name="Garlic", - line="2 cloves garlic", - unit="Items", - quantity=2.0, - preparation="minced" + name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced" ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + self.assertGreater(ingredient.id, 0) - + # Verify it was inserted correctly found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id) self.assertIsNotNone(found_ingredient) @@ -68,7 +66,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): # First create and insert a product product = test_data.Products.broccoli await products_db.insert_product(self.conn, product, {}) - + ingredient = ingredients_db.Ingredient( name="Fresh Broccoli", line="1 piece fresh broccoli", @@ -76,11 +74,11 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): quantity=1.0, preparation="", product_id=product.id, - product=product + product=product, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Verify the ingredient was inserted with the product reference found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id) self.assertIsNotNone(found_ingredient) @@ -97,33 +95,33 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): """Test finding ingredients by recipe ID""" # Create ingredients with the same recipe_id recipe_id = 1 - + ingredient1 = ingredients_db.Ingredient( name="Flour", line="2 cups flour", unit="cups", quantity=2.0, preparation="", - recipe_id=recipe_id + recipe_id=recipe_id, ) - + ingredient2 = ingredients_db.Ingredient( name="Sugar", line="1 cup sugar", unit="cups", quantity=1.0, preparation="", - recipe_id=recipe_id + recipe_id=recipe_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Find ingredients by recipe ID ingredients_list = [] async for ingredient in ingredients_db.find_ingredients_by_recipe_id(self.conn, recipe_id): ingredients_list.append(ingredient) - + self.assertEqual(len(ingredients_list), 2) names = [ing.name for ing in ingredients_list] self.assertIn("Flour", names) @@ -133,33 +131,33 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): """Test finding ingredients by meal ID""" # Create ingredients with the same meal_id meal_id = 1 - + ingredient1 = ingredients_db.Ingredient( name="Chicken", line="1 lb chicken breast", unit="lb", quantity=1.0, preparation="diced", - meal_id=meal_id + meal_id=meal_id, ) - + ingredient2 = ingredients_db.Ingredient( name="Rice", line="2 cups rice", unit="cups", quantity=2.0, preparation="", - meal_id=meal_id + meal_id=meal_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Find ingredients by meal ID ingredients_list = [] async for ingredient in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): ingredients_list.append(ingredient) - + self.assertEqual(len(ingredients_list), 2) names = [ing.name for ing in ingredients_list] self.assertIn("Chicken", names) @@ -168,27 +166,27 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): async def test_delete_ingredients_by_meal_id(self): """Test deleting ingredients by meal ID""" meal_id = 1 - + ingredient = ingredients_db.Ingredient( name="Tomato", line="2 tomatoes", unit="Items", quantity=2.0, preparation="sliced", - meal_id=meal_id + meal_id=meal_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Verify ingredient exists ingredients_list = [] async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): ingredients_list.append(ing) self.assertEqual(len(ingredients_list), 1) - + # Delete ingredients by meal ID await ingredients_db.delete_ingredients_by_meal_id(self.conn, meal_id) - + # Verify ingredients are deleted ingredients_list = [] async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): @@ -202,10 +200,12 @@ import asyncio import tests.test_data as test_data import importlib + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + from db import connect, create import ingredients import ingredients.db as ingredients_db @@ -215,7 +215,7 @@ import units class TestIngredient(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -232,9 +232,9 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - + self.assertEqual(ingredient.name, "Broccoli") self.assertEqual(ingredient.line, "500g fresh broccoli") self.assertEqual(ingredient.unit, "g") @@ -244,17 +244,13 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): async def test_insert_ingredient(self): """Test inserting an ingredient into the database""" ingredient = ingredients_db.Ingredient( - name="Garlic", - line="2 cloves garlic", - unit="Items", - quantity=2.0, - preparation="minced" + name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced" ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + self.assertGreater(ingredient.id, 0) - + # Verify it was inserted correctly found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id) self.assertIsNotNone(found_ingredient) @@ -266,7 +262,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): # First create and insert a product product = test_data.Products.broccoli await products_db.insert_product(self.conn, product, {}) - + ingredient = ingredients_db.Ingredient( name="Fresh Broccoli", line="1 piece fresh broccoli", @@ -274,11 +270,11 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): quantity=1.0, preparation="", product_id=product.id, - product=product + product=product, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Verify the ingredient was inserted with the product reference found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id) self.assertIsNotNone(found_ingredient) @@ -295,33 +291,33 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): """Test finding ingredients by recipe ID""" # Create ingredients with the same recipe_id recipe_id = 1 - + ingredient1 = ingredients_db.Ingredient( name="Flour", line="2 cups flour", unit="cups", quantity=2.0, preparation="", - recipe_id=recipe_id + recipe_id=recipe_id, ) - + ingredient2 = ingredients_db.Ingredient( name="Sugar", line="1 cup sugar", unit="cups", quantity=1.0, preparation="", - recipe_id=recipe_id + recipe_id=recipe_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Find ingredients by recipe ID ingredients_list = [] async for ingredient in ingredients_db.find_ingredients_by_recipe_id(self.conn, recipe_id): ingredients_list.append(ingredient) - + self.assertEqual(len(ingredients_list), 2) names = [ing.name for ing in ingredients_list] self.assertIn("Flour", names) @@ -331,33 +327,33 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): """Test finding ingredients by meal ID""" # Create ingredients with the same meal_id meal_id = 1 - + ingredient1 = ingredients_db.Ingredient( name="Chicken", line="1 lb chicken breast", unit="lb", quantity=1.0, preparation="diced", - meal_id=meal_id + meal_id=meal_id, ) - + ingredient2 = ingredients_db.Ingredient( name="Rice", line="2 cups rice", unit="cups", quantity=2.0, preparation="", - meal_id=meal_id + meal_id=meal_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Find ingredients by meal ID ingredients_list = [] async for ingredient in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): ingredients_list.append(ingredient) - + self.assertEqual(len(ingredients_list), 2) names = [ing.name for ing in ingredients_list] self.assertIn("Chicken", names) @@ -366,27 +362,27 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): async def test_delete_ingredients_by_meal_id(self): """Test deleting ingredients by meal ID""" meal_id = 1 - + ingredient = ingredients_db.Ingredient( name="Tomato", line="2 tomatoes", unit="Items", quantity=2.0, preparation="sliced", - meal_id=meal_id + meal_id=meal_id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Verify ingredient exists ingredients_list = [] async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): ingredients_list.append(ing) self.assertEqual(len(ingredients_list), 1) - + # Delete ingredients by meal ID await ingredients_db.delete_ingredients_by_meal_id(self.conn, meal_id) - + # Verify ingredients are deleted ingredients_list = [] async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id): @@ -401,18 +397,18 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase): unit="Items", quantity=1.0, preparation="", - product_id=-1 + product_id=-1, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient) - + found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id) self.assertIsNone(found_ingredient.product_id) class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -429,9 +425,9 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): "just a url https://example.com", "no quantity https://example.com", "", - "abc https://example.com" + "abc https://example.com", ] - + for invalid_link in invalid_links: result = await ingredients.parse_ingredient_from_link(self.conn, invalid_link) self.assertIsNone(result, f"Should return None for: {invalid_link}") @@ -439,9 +435,9 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): async def test_parse_ingredient_from_link_valid_format_no_quantity(self): """Test parsing ingredient from valid link format without explicit quantity""" link = "https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli" - + result = await ingredients.parse_ingredient_from_link(self.conn, link) - + # The scraper actually works for this URL, so we should get a result self.assertIsNotNone(result) self.assertEqual(result.quantity, 1.0) # Default quantity when none specified @@ -451,21 +447,25 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): async def test_parse_ingredient_from_link_regex_parsing(self): """Test that the regex correctly parses quantity and URL from valid links""" import re - + # Test the regex pattern used in parse_ingredient_from_link test_cases = [ ("2 https://example.com", "2", "https://example.com"), - ("10 https://www.woolworths.com.au/product", "10", "https://www.woolworths.com.au/product"), + ( + "10 https://www.woolworths.com.au/product", + "10", + "https://www.woolworths.com.au/product", + ), ("https://example.com", None, "https://example.com"), - ("1 https://test.com", "1", "https://test.com") + ("1 https://test.com", "1", "https://test.com"), ] - + for link, expected_qty, expected_url in test_cases: - match = re.match(r'^(\d+)?\s*(http.*)$', link) + match = re.match(r"^(\d+)?\s*(http.*)$", link) if match: quantity = int(match.group(1)) if match.group(1) else 1 url = match.group(2) - + if expected_qty: self.assertEqual(quantity, int(expected_qty)) else: @@ -476,7 +476,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): """Test parsing simple ingredient cases that don't require external dependencies""" # Test the basic structure without relying on ingredient_parser # Since ingredient_parser is an external dependency, we'll test what we can - + # We can test that the function exists and handles basic error cases try: result = ingredients.parse_ingredient_from_nlp("2 cups flour") @@ -494,7 +494,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -504,47 +504,41 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): await self.conn.close() return await super().asyncTearDown() - - async def test_match_existing_products_with_real_data(self): """Test matching ingredients to existing products using real operations""" # Setup: Create and insert a product with tags product = test_data.Products.broccoli await products_db.insert_product(self.conn, product, {}) await products_db.add_tag(self.conn, product, "broccoli") - + # Create ingredients without products ingredient1 = ingredients_db.Ingredient( - name="broccoli", - line="1 piece broccoli", - unit="Items", - quantity=1.0, - preparation="" + name="broccoli", line="1 piece broccoli", unit="Items", quantity=1.0, preparation="" ) - + ingredient2 = ingredients_db.Ingredient( name="unknown vegetable", line="1 piece unknown vegetable", unit="Items", quantity=1.0, - preparation="" + preparation="", ) - + ingredients_list = [ingredient1, ingredient2] result = await ingredients.match_existing_products(self.conn, ingredients_list) - + # Check that first ingredient got matched self.assertEqual(result[0].product_id, product.id) self.assertIsNotNone(result[0].product) self.assertEqual(result[0].product.name, product.name) - + # Check that second ingredient remained unmatched self.assertIsNone(result[1].product) async def test_match_existing_products_already_has_product(self): """Test that ingredients with existing products are not re-matched""" product = test_data.Products.broccoli - + ingredient = ingredients_db.Ingredient( name="broccoli", line="1 piece broccoli", @@ -552,12 +546,12 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): quantity=1.0, preparation="", product=product, - product_id=product.id + product_id=product.id, ) - + ingredients_list = [ingredient] result = await ingredients.match_existing_products(self.conn, ingredients_list) - + # Should remain unchanged self.assertEqual(result[0].product_id, product.id) self.assertEqual(result[0].product, product) @@ -569,19 +563,25 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): async def test_ingredient_keys_constant(self): """Test that the KEYS constant contains expected fields""" - expected_keys = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id'] + expected_keys = [ + "id", + "name", + "line", + "preparation", + "unit", + "quantity", + "product_id", + "recipe_id", + "meal_id", + ] self.assertEqual(ingredients_db.Ingredient.KEYS, expected_keys) async def test_ingredient_default_values(self): """Test ingredient default values""" ingredient = ingredients_db.Ingredient( - name="Test", - line="Test line", - unit="Items", - quantity=1.0, - preparation="" + name="Test", line="Test line", unit="Items", quantity=1.0, preparation="" ) - + self.assertEqual(ingredient.id, -1) self.assertIsNone(ingredient.product_id) self.assertIsNone(ingredient.recipe_id) @@ -589,5 +589,5 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): self.assertIsNone(ingredient.product) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py index 3bf0289..65f2324 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,10 +7,12 @@ from fastapi.testclient import TestClient import tests.test_data as test_data + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + from db import connect, create import main import meals @@ -25,26 +27,26 @@ import shopping class TestMainAPI(unittest.IsolatedAsyncioTestCase): """Test the main FastAPI application endpoints""" - + async def asyncSetUp(self): # Use in-memory database for testing - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() - + # Mock the database dependency async def override_get_db(): try: yield self.conn finally: pass # Don't close the connection in tests - + main.app.dependency_overrides[main.get_db] = override_get_db - + # Create test client self.client = TestClient(main.app) - + return await super().asyncSetUp() async def asyncTearDown(self) -> None: @@ -75,21 +77,23 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): response = self.client.get("/api/recipes") recipes_data = response.json() if recipes_data: - recipe_id = recipes_data[0]['id'] + recipe_id = recipes_data[0]["id"] response = self.client.get(f"/api/recipes/{recipe_id}") self.assertEqual(response.status_code, 200) recipe_data = response.json() - self.assertEqual(recipe_data['id'], recipe_id) + self.assertEqual(recipe_data["id"], recipe_id) def test_get_recipe_by_id_not_found(self): """Test getting a recipe that doesn't exist""" response = self.client.get("/api/recipes/99999") self.assertEqual(response.status_code, 404) - self.assertIn('Recipe not found', response.json()['message']) + self.assertIn("Recipe not found", response.json()["message"]) def test_parse_ingredients(self): """Test parsing ingredient strings""" - response = self.client.get("/api/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt") + response = self.client.get( + "/api/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt" + ) self.assertEqual(response.status_code, 200) ingredients_data = response.json() self.assertIsInstance(ingredients_data, list) @@ -100,7 +104,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): # Use a URL that would be recognized by the scrapers (woolworths format) product_data = { "url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product", - "tags": ["test", "product"] + "tags": ["test", "product"], } response = self.client.post("/api/products", json=product_data) # This might fail if the scraper can't actually scrape the URL @@ -120,7 +124,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): """Test getting a meal that doesn't exist""" response = self.client.get("/api/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn('Meal not found', response.json()['message']) + self.assertIn("Meal not found", response.json()["message"]) def test_create_meal_invalid_no_chefs(self): """Test creating a meal without chefs (should fail validation)""" @@ -131,11 +135,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 1, "name": "Ryan"}], "consumers": [{"id": 1, "name": "Ellie"}], "recipes": [], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Meal must have at least one chef', response.json()['message']) + self.assertIn("Meal must have at least one chef", response.json()["message"]) def test_create_meal_invalid_no_cleanup(self): """Test creating a meal without cleanup people (should fail validation)""" @@ -146,11 +150,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [], "consumers": [{"id": 1, "name": "Ellie"}], "recipes": [], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Meal must have at least one cleanup person', response.json()['message']) + self.assertIn("Meal must have at least one cleanup person", response.json()["message"]) def test_create_meal_invalid_no_consumers(self): """Test creating a meal without consumers (should fail validation)""" @@ -161,11 +165,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [], "recipes": [], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Meal must have at least one consumer', response.json()['message']) + self.assertIn("Meal must have at least one consumer", response.json()["message"]) def test_create_meal_invalid_no_recipes_or_ingredients(self): """Test creating a meal without recipes or ingredients (should fail validation)""" @@ -176,11 +180,13 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Meal must have at least one recipe or ingredient', response.json()['message']) + self.assertIn( + "Meal must have at least one recipe or ingredient", response.json()["message"] + ) def test_create_meal_invalid_duplicate_chefs(self): """Test creating a meal with duplicate chefs (should fail validation)""" @@ -191,11 +197,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Duplicate chef', response.json()['message']) + self.assertIn("Duplicate chef", response.json()["message"]) def test_create_meal_invalid_zero_servings(self): """Test creating a meal with zero servings (should fail validation)""" @@ -206,11 +212,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Recipe servings must be greater than 0', response.json()['message']) + self.assertIn("Recipe servings must be greater than 0", response.json()["message"]) def test_create_meal_valid(self): """Test creating a valid meal""" @@ -221,15 +227,15 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.post("/api/meals", json=meal_data) self.assertEqual(response.status_code, 200) created_meal = response.json() - self.assertGreater(created_meal['id'], 0) - self.assertEqual(len(created_meal['chefs']), 1) - self.assertEqual(len(created_meal['cleanup']), 1) - self.assertEqual(len(created_meal['consumers']), 1) + self.assertGreater(created_meal["id"], 0) + self.assertEqual(len(created_meal["chefs"]), 1) + self.assertEqual(len(created_meal["cleanup"]), 1) + self.assertEqual(len(created_meal["consumers"]), 1) def test_update_meal_id_mismatch(self): """Test updating a meal with mismatched IDs""" @@ -240,11 +246,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.put("/api/meals/123", json=meal_data) self.assertEqual(response.status_code, 400) - self.assertIn('Meal ID in URL does not match meal ID in body', response.json()['message']) + self.assertIn("Meal ID in URL does not match meal ID in body", response.json()["message"]) def test_update_meal_not_found(self): """Test updating a meal that doesn't exist""" @@ -255,24 +261,24 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}], - "extra_ingredients": [] + "extra_ingredients": [], } response = self.client.put("/api/meals/99999", json=meal_data) self.assertEqual(response.status_code, 404) - self.assertIn('Meal not found', response.json()['message']) + self.assertIn("Meal not found", response.json()["message"]) def test_delete_meal_not_found(self): """Test deleting a meal that doesn't exist""" # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: response = self.client.delete("/api/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn('Meal not found', response.json()['message']) + self.assertIn("Meal not found", response.json()["message"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -283,23 +289,23 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): response = self.client.get("/api/shopping/current") self.assertEqual(response.status_code, 200) shopping_data = response.json() - self.assertIn('outstanding_items', shopping_data) - self.assertIn('requested_meals', shopping_data) - self.assertIn('purchased_items', shopping_data) + self.assertIn("outstanding_items", shopping_data) + self.assertIn("requested_meals", shopping_data) + self.assertIn("purchased_items", shopping_data) def test_get_shopping_list_by_id(self): """Test getting a shopping list by ID that doesn't exist""" response = self.client.get("/api/shopping/1") # Should return 404 when shopping list is not found self.assertEqual(response.status_code, 404) - self.assertIn('Shopping list not found', response.json()['message']) + self.assertIn("Shopping list not found", response.json()["message"]) async def test_get_shopping_list_by_id_exists(self): """Test getting a shopping list that exists""" # First create a product and ingredient product = products.Product( id=-1, - shop_code='test', + shop_code="test", name="Test Product", product_id="test_123", quantity=1, @@ -310,7 +316,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): raw_data={}, ) await products.insert_product(self.conn, product, {}) - + ingredient = ingredients.Ingredient( id=-1, name="Test Product", @@ -318,35 +324,37 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): unit="item", quantity=1.0, preparation="", - product_id=product.id + product_id=product.id, ) await ingredients.insert_ingredient(self.conn, ingredient) - + # Create a request using the proper workflow - requested_item = await shopping.request(self.conn, test_data.Persons.jacob, ingredient=ingredient) - + requested_item = await shopping.request( + self.conn, test_data.Persons.jacob, ingredient=ingredient + ) + # Create a shopping list and purchase it (which will include the requested item) shopping_list = shopping.ShoppingList( id=-1, purchased_by=test_data.Persons.jacob, store_name="woolworths", - items=[requested_item] # Use the properly created item + items=[requested_item], # Use the properly created item ) - + # Purchase the shopping list (which creates it in the database) await shopping.purchase(self.conn, shopping_list) - + # Now test getting it via the API response = self.client.get(f"/api/shopping/{shopping_list.id}") self.assertEqual(response.status_code, 200) shopping_data = response.json() - self.assertIn('list', shopping_data) - self.assertEqual(shopping_data['list']['id'], shopping_list.id) - self.assertEqual(shopping_data['list']['store_name'], "woolworths") + self.assertIn("list", shopping_data) + self.assertEqual(shopping_data["list"]["id"], shopping_list.id) + self.assertEqual(shopping_data["list"]["store_name"], "woolworths") # Verify that lookup tables are present - self.assertIn('ingredients_lookup', shopping_data) - self.assertIn('meals_lookup', shopping_data) - self.assertIn('recipes_lookup', shopping_data) + self.assertIn("ingredients_lookup", shopping_data) + self.assertIn("meals_lookup", shopping_data) + self.assertIn("recipes_lookup", shopping_data) def test_get_persons_no_query(self): """Test getting all persons without search query""" @@ -365,15 +373,12 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): def test_create_person(self): """Test creating a new person""" - person_data = { - "id": -1, - "name": "Test Person" - } + person_data = {"id": -1, "name": "Test Person"} response = self.client.post("/api/persons", json=person_data) self.assertEqual(response.status_code, 200) created_person = response.json() - self.assertGreater(created_person['id'], 0) - self.assertEqual(created_person['name'], "Test Person") + self.assertGreater(created_person["id"], 0) + self.assertEqual(created_person["name"], "Test Person") def test_login_person_exists(self): """Test login with existing person""" @@ -381,14 +386,14 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase): response = self.client.post("/api/auth/login", json=login_data) self.assertEqual(response.status_code, 200) person_data = response.json() - self.assertEqual(person_data['name'], "Jacob") + self.assertEqual(person_data["name"], "Jacob") def test_login_person_not_found(self): """Test login with non-existent person""" login_data = {"username": "NonExistentUser"} response = self.client.post("/api/auth/login", json=login_data) self.assertEqual(response.status_code, 404) - self.assertIn('Person not found', response.json()['message']) + self.assertIn("Person not found", response.json()["message"]) class TestMainHelperFunctions(unittest.TestCase): @@ -399,7 +404,7 @@ class TestMainHelperFunctions(unittest.TestCase): persons_list = [ persons.Person(id=1, name="Jacob"), persons.Person(id=2, name="Ryan"), - persons.Person(id=3, name="Ellie") + persons.Person(id=3, name="Ellie"), ] duplicates = main.get_duplicates(persons_list) self.assertEqual(len(duplicates), 0) @@ -410,7 +415,7 @@ class TestMainHelperFunctions(unittest.TestCase): persons.Person(id=1, name="Jacob"), persons.Person(id=2, name="Ryan"), persons.Person(id=1, name="Jacob"), # Duplicate - persons.Person(id=3, name="Ellie") + persons.Person(id=3, name="Ellie"), ] duplicates = main.get_duplicates(persons_list) self.assertEqual(len(duplicates), 1) @@ -425,7 +430,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[persons.Person(id=2, name="Ryan")], consumers=[persons.Person(id=3, name="Ellie")], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNone(result) @@ -439,7 +444,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[persons.Person(id=2, name="Ryan")], consumers=[persons.Person(id=3, name="Ellie")], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -454,7 +459,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[], consumers=[persons.Person(id=3, name="Ellie")], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -469,7 +474,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[persons.Person(id=2, name="Ryan")], consumers=[], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -484,7 +489,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[persons.Person(id=2, name="Ryan")], consumers=[persons.Person(id=3, name="Ellie")], recipes=[], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -497,12 +502,12 @@ class TestMainHelperFunctions(unittest.TestCase): suggested_date=datetime(2024, 6, 1, 18, 0), chefs=[ persons.Person(id=1, name="Jacob"), - persons.Person(id=1, name="Jacob") # Duplicate + persons.Person(id=1, name="Jacob"), # Duplicate ], cleanup=[persons.Person(id=2, name="Ryan")], consumers=[persons.Person(id=3, name="Ellie")], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -517,7 +522,7 @@ class TestMainHelperFunctions(unittest.TestCase): cleanup=[persons.Person(id=2, name="Ryan")], consumers=[persons.Person(id=3, name="Ellie")], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=0)], # Zero servings - extra_ingredients=[] + extra_ingredients=[], ) result = main.validate_meal(meal) self.assertIsNotNone(result) @@ -526,26 +531,26 @@ class TestMainHelperFunctions(unittest.TestCase): class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): """Test endpoints that require authentication""" - + async def asyncSetUp(self): # Use in-memory database for testing - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() - + # Mock the database dependency async def override_get_db(): try: yield self.conn finally: pass # Don't close the connection in tests - + main.app.dependency_overrides[main.get_db] = override_get_db - + # Create test client self.client = TestClient(main.app) - + return await super().asyncSetUp() async def asyncTearDown(self) -> None: @@ -560,9 +565,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # This would require a more complex setup to properly mock FastAPI dependencies async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: recipe_data = { "id": -1, @@ -577,9 +582,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): "line": "1 cup test ingredient", "unit": "cup", "quantity": 1.0, - "preparation": "" + "preparation": "", } - ] + ], } response = self.client.post("/api/recipes", json=recipe_data) # Due to authentication dependency issues, this will likely return 422 @@ -595,9 +600,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # The authentication dependency injection isn't working properly in tests async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: recipe_data = { "id": -1, @@ -605,7 +610,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): "link": "https://example.com/test-recipe", "serves": 4, "created_by_id": 1, # Add required field - "ingredients": [] + "ingredients": [], } response = self.client.post("/api/recipes", json=recipe_data) # Due to authentication dependency issues, this will likely return 422 @@ -621,9 +626,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # First create a meal meal_data = { @@ -633,16 +638,17 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): "cleanup": [{"id": 2, "name": "Ryan"}], "consumers": [{"id": 3, "name": "Ellie"}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], - "extra_ingredients": [] + "extra_ingredients": [], } create_response = self.client.post("/api/meals", json=meal_data) - meal_id = create_response.json()['id'] - + meal_id = create_response.json()["id"] + # Try to mark as consumed with invalid timezone - response = self.client.post(f"/api/meals/{meal_id}/consumed", - params={"consumed_date": "2024-06-01T19:00:00"}) # No timezone + response = self.client.post( + f"/api/meals/{meal_id}/consumed", params={"consumed_date": "2024-06-01T19:00:00"} + ) # No timezone self.assertEqual(response.status_code, 400) - self.assertIn('Consumed date must include timezone', response.json()['message']) + self.assertIn("Consumed date must include timezone", response.json()["message"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -653,14 +659,14 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: request_data = {"meal_id": 99999} response = self.client.post("/api/shopping/current/meals/me", json=request_data) self.assertEqual(response.status_code, 404) - self.assertIn('Meal not found', response.json()['message']) + self.assertIn("Meal not found", response.json()["message"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -671,13 +677,13 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: response = self.client.delete("/api/shopping/current/meals/99999") self.assertEqual(response.status_code, 404) - self.assertIn('Meal not found', response.json()['message']) + self.assertIn("Meal not found", response.json()["message"]) finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -688,9 +694,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: response = self.client.get("/api/shopping/current/me/ingredients") self.assertEqual(response.status_code, 200) @@ -705,7 +711,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_get_my_shopping_list_with_items(self): """Test getting shopping list when items are already requested""" person = test_data.Persons.jacob - + # Create and insert an ingredient ingredient = ingredients.Ingredient( id=-1, @@ -713,27 +719,27 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): line="1 test ingredient", unit="item", quantity=1.0, - preparation="" + preparation="", ) await ingredients.insert_ingredient(self.conn, ingredient) - + # Request the ingredient for the person await shopping.request(self.conn, person, ingredient=ingredient) - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: response = self.client.get("/api/shopping/current/me/ingredients") self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 1) - self.assertEqual(shopping_list[0]['name'], "Test Ingredient") - self.assertEqual(shopping_list[0]['line'], "1 test ingredient") + self.assertEqual(shopping_list[0]["name"], "Test Ingredient") + self.assertEqual(shopping_list[0]["line"], "1 test ingredient") finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -744,9 +750,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency to return a test user async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: response = self.client.post("/api/shopping/current/me/ingredients", json=[]) self.assertEqual(response.status_code, 200) @@ -761,7 +767,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_sync_my_shopping_list_add_new_items(self): """Test syncing to add new items to empty shopping list""" person = test_data.Persons.jacob - + # Create ingredients to sync ingredient1 = ingredients.Ingredient( id=-1, @@ -769,59 +775,62 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): line="2 cups new ingredient 1", unit="cup", quantity=2.0, - preparation="" + preparation="", ) - + ingredient2 = ingredients.Ingredient( id=-1, - name="New Ingredient 2", + name="New Ingredient 2", line="1 tbsp new ingredient 2", unit="tbsp", quantity=1.0, - preparation="" + preparation="", ) - + # Insert ingredients to get valid IDs await ingredients.insert_ingredient(self.conn, ingredient1) await ingredients.insert_ingredient(self.conn, ingredient2) - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Sync the ingredients - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": ingredient1.id, - "name": ingredient1.name, - "line": ingredient1.line, - "unit": ingredient1.unit, - "quantity": ingredient1.quantity, - "preparation": ingredient1.preparation - }, - { - "id": ingredient2.id, - "name": ingredient2.name, - "line": ingredient2.line, - "unit": ingredient2.unit, - "quantity": ingredient2.quantity, - "preparation": ingredient2.preparation - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": ingredient1.id, + "name": ingredient1.name, + "line": ingredient1.line, + "unit": ingredient1.unit, + "quantity": ingredient1.quantity, + "preparation": ingredient1.preparation, + }, + { + "id": ingredient2.id, + "name": ingredient2.name, + "line": ingredient2.line, + "unit": ingredient2.unit, + "quantity": ingredient2.quantity, + "preparation": ingredient2.preparation, + }, + ], + ) + self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 2) - + # Check that both ingredients are now in the shopping list - ingredient_names = {item['name'] for item in shopping_list} + ingredient_names = {item["name"] for item in shopping_list} self.assertIn("New Ingredient 1", ingredient_names) self.assertIn("New Ingredient 2", ingredient_names) - + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -830,7 +839,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_sync_my_shopping_list_remove_items(self): """Test syncing to remove items from shopping list""" person = test_data.Persons.jacob - + # Create and insert ingredients ingredient1 = ingredients.Ingredient( id=-1, @@ -838,50 +847,53 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): line="1 cup existing ingredient 1", unit="cup", quantity=1.0, - preparation="" + preparation="", ) - + ingredient2 = ingredients.Ingredient( id=-1, name="Existing Ingredient 2", line="2 tbsp existing ingredient 2", unit="tbsp", quantity=2.0, - preparation="" + preparation="", ) - + await ingredients.insert_ingredient(self.conn, ingredient1) await ingredients.insert_ingredient(self.conn, ingredient2) - + # Request both ingredients await shopping.request(self.conn, person, ingredient=ingredient1) await shopping.request(self.conn, person, ingredient=ingredient2) - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Sync with only one ingredient (effectively removing the other) - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": ingredient1.id, - "name": ingredient1.name, - "line": ingredient1.line, - "unit": ingredient1.unit, - "quantity": ingredient1.quantity, - "preparation": ingredient1.preparation - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": ingredient1.id, + "name": ingredient1.name, + "line": ingredient1.line, + "unit": ingredient1.unit, + "quantity": ingredient1.quantity, + "preparation": ingredient1.preparation, + } + ], + ) + self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 1) - self.assertEqual(shopping_list[0]['name'], "Existing Ingredient 1") - + self.assertEqual(shopping_list[0]["name"], "Existing Ingredient 1") + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -890,7 +902,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_sync_my_shopping_list_mixed_operations(self): """Test syncing with both additions and removals""" person = test_data.Persons.jacob - + # Create existing ingredients existing_ingredient = ingredients.Ingredient( id=-1, @@ -898,73 +910,76 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): line="1 existing ingredient", unit="item", quantity=1.0, - preparation="" + preparation="", ) - + remove_ingredient = ingredients.Ingredient( id=-1, name="Remove This Ingredient", line="1 remove this ingredient", - unit="item", + unit="item", quantity=1.0, - preparation="" + preparation="", ) - + new_ingredient = ingredients.Ingredient( id=-1, name="New Ingredient", line="2 new ingredient", unit="item", quantity=2.0, - preparation="" + preparation="", ) - + # Insert all ingredients await ingredients.insert_ingredient(self.conn, existing_ingredient) await ingredients.insert_ingredient(self.conn, remove_ingredient) await ingredients.insert_ingredient(self.conn, new_ingredient) - + # Request the first two ingredients await shopping.request(self.conn, person, ingredient=existing_ingredient) await shopping.request(self.conn, person, ingredient=remove_ingredient) - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Sync to keep existing, remove remove_ingredient, add new_ingredient - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": existing_ingredient.id, - "name": existing_ingredient.name, - "line": existing_ingredient.line, - "unit": existing_ingredient.unit, - "quantity": existing_ingredient.quantity, - "preparation": existing_ingredient.preparation - }, - { - "id": new_ingredient.id, - "name": new_ingredient.name, - "line": new_ingredient.line, - "unit": new_ingredient.unit, - "quantity": new_ingredient.quantity, - "preparation": new_ingredient.preparation - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": existing_ingredient.id, + "name": existing_ingredient.name, + "line": existing_ingredient.line, + "unit": existing_ingredient.unit, + "quantity": existing_ingredient.quantity, + "preparation": existing_ingredient.preparation, + }, + { + "id": new_ingredient.id, + "name": new_ingredient.name, + "line": new_ingredient.line, + "unit": new_ingredient.unit, + "quantity": new_ingredient.quantity, + "preparation": new_ingredient.preparation, + }, + ], + ) + self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 2) - - ingredient_names = {item['name'] for item in shopping_list} + + ingredient_names = {item["name"] for item in shopping_list} self.assertIn("Existing Ingredient", ingredient_names) self.assertIn("New Ingredient", ingredient_names) self.assertNotIn("Remove This Ingredient", ingredient_names) - + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -973,37 +988,40 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_sync_my_shopping_list_with_new_ingredients(self): """Test syncing with ingredients that have negative IDs (need to be inserted)""" person = test_data.Persons.jacob - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Sync with new ingredients (negative IDs) - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": -1, - "name": "Brand New Ingredient", - "line": "3 cups brand new ingredient", - "unit": "cup", - "quantity": 3.0, - "preparation": "chopped" - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": -1, + "name": "Brand New Ingredient", + "line": "3 cups brand new ingredient", + "unit": "cup", + "quantity": 3.0, + "preparation": "chopped", + } + ], + ) + self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 1) - + # The ingredient should now have a positive ID - self.assertGreater(shopping_list[0]['id'], 0) - self.assertEqual(shopping_list[0]['name'], "Brand New Ingredient") - self.assertEqual(shopping_list[0]['line'], "3 cups brand new ingredient") - self.assertEqual(shopping_list[0]['preparation'], "chopped") - + self.assertGreater(shopping_list[0]["id"], 0) + self.assertEqual(shopping_list[0]["name"], "Brand New Ingredient") + self.assertEqual(shopping_list[0]["line"], "3 cups brand new ingredient") + self.assertEqual(shopping_list[0]["preparation"], "chopped") + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -1012,7 +1030,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): async def test_sync_my_shopping_list_match_by_line(self): """Test that ingredients are matched by line when IDs don't match""" person = test_data.Persons.jacob - + # Create an existing ingredient existing_ingredient = ingredients.Ingredient( id=-1, @@ -1020,40 +1038,43 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): line="1 special line match test", unit="item", quantity=1.0, - preparation="" + preparation="", ) - + await ingredients.insert_ingredient(self.conn, existing_ingredient) await shopping.request(self.conn, person, ingredient=existing_ingredient) - + # Override the cookie_person dependency async def override_cookie_person(): return person - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Sync with ingredient with different ID but same line - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": -99, # Different ID - "name": "Different Name", - "line": "1 special line match test", # Same line - "unit": "piece", - "quantity": 1.0, - "preparation": "different prep" - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": -99, # Different ID + "name": "Different Name", + "line": "1 special line match test", # Same line + "unit": "piece", + "quantity": 1.0, + "preparation": "different prep", + } + ], + ) + self.assertEqual(response.status_code, 200) shopping_list = response.json() self.assertIsInstance(shopping_list, list) self.assertEqual(len(shopping_list), 1) - + # Should keep the original ingredient since lines match - self.assertEqual(shopping_list[0]['name'], "Existing Item") - self.assertEqual(shopping_list[0]['line'], "1 special line match test") - + self.assertEqual(shopping_list[0]["name"], "Existing Item") + self.assertEqual(shopping_list[0]["line"], "1 special line match test") + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: @@ -1076,26 +1097,29 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase): # Override the cookie_person dependency async def override_cookie_person(): return test_data.Persons.jacob - + main.app.dependency_overrides[main.cookie_person] = override_cookie_person - + try: # Send invalid ingredient data - response = self.client.post("/api/shopping/current/me/ingredients", json=[ - { - "id": "not_a_number", # Invalid ID type - "name": "Test Ingredient" - # Missing required fields - } - ]) - + response = self.client.post( + "/api/shopping/current/me/ingredients", + json=[ + { + "id": "not_a_number", # Invalid ID type + "name": "Test Ingredient" + # Missing required fields + } + ], + ) + self.assertEqual(response.status_code, 422) # Validation error - + finally: # Clean up the override if main.cookie_person in main.app.dependency_overrides: del main.app.dependency_overrides[main.cookie_person] -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_meals.py b/tests/test_meals.py index 93aca05..013cf44 100644 --- a/tests/test_meals.py +++ b/tests/test_meals.py @@ -5,10 +5,12 @@ import importlib import tests.test_data as test_data + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + from db import connect, create import meals import meals.db as meals_db @@ -21,9 +23,9 @@ import products class TestMealsModels(unittest.IsolatedAsyncioTestCase): """Test the meals data models""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -39,9 +41,9 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 1, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie, test_data.Persons.chris] + consumers=[test_data.Persons.ellie, test_data.Persons.chris], ) - + self.assertEqual(meal.id, -1) # Default ID self.assertEqual(meal.suggested_date, datetime(2024, 1, 1, 18, 0)) self.assertIsNone(meal.consumed_date) @@ -53,12 +55,8 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase): def test_meal_recipe_creation(self): """Test basic MealRecipe creation""" - meal_recipe = MealRecipe( - meal_id=1, - recipe_id=2, - servings=4.0 - ) - + meal_recipe = MealRecipe(meal_id=1, recipe_id=2, servings=4.0) + self.assertEqual(meal_recipe.meal_id, 1) self.assertEqual(meal_recipe.recipe_id, 2) self.assertEqual(meal_recipe.servings, 4.0) @@ -67,9 +65,9 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase): class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): """Test meals CRUD operations""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() @@ -85,14 +83,14 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 1, 15, 19, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Verify meal was inserted and got an ID self.assertGreater(meal.id, 0) - + # Verify we can find it by ID found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(found_meal.suggested_date, meal.suggested_date) @@ -109,27 +107,22 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): recipe = test_data.Recipes.broccoli_soup recipe.id = -1 # Reset ID await recipes.insert_recipe(self.conn, recipe) - - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=3.0, - recipe=recipe - ) - + + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=3.0, recipe=recipe) + meal = Meal( suggested_date=datetime(2024, 2, 1, 18, 30), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie, test_data.Persons.chris], - recipes=[meal_recipe] + recipes=[meal_recipe], ) - + await meals_db.insert_meal(self.conn, meal) - + # Verify meal was inserted self.assertGreater(meal.id, 0) - + # Verify recipe was associated found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(len(found_meal.recipes), 1) @@ -143,7 +136,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): # Create a new product for testing product = products.Product( id=-1, - shop_code='woolworths', + shop_code="woolworths", name="Test Garlic Bread", product_id="test_294517", quantity=1, @@ -154,7 +147,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): raw_data={}, ) await products.insert_product(self.conn, product, {}) - + # Create an ingredient extra_ingredient = ingredients.Ingredient( id=-1, @@ -163,22 +156,22 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): unit="loaf", quantity=1.0, preparation="", - product_id=product.id + product_id=product.id, ) - + meal = Meal( suggested_date=datetime(2024, 3, 1, 19, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], - extra_ingredients=[extra_ingredient] + extra_ingredients=[extra_ingredient], ) - + await meals_db.insert_meal(self.conn, meal) - + # Verify meal was inserted self.assertGreater(meal.id, 0) - + # Verify extra ingredients were associated found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(len(found_meal.extra_ingredients), 1) @@ -196,20 +189,20 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 4, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) original_id = meal.id - + # Update the meal meal.suggested_date = datetime(2024, 4, 2, 19, 0) meal.chefs = [test_data.Persons.ryan] # Change chef meal.cleanup = [test_data.Persons.ellie] # Change cleanup meal.consumers = [test_data.Persons.jacob, test_data.Persons.chris] # Change consumers - + await meals_db.update_meal(self.conn, meal) - + # Verify updates found_meal = await meals_db.find_meal_by_id(self.conn, original_id) self.assertEqual(found_meal.suggested_date, datetime(2024, 4, 2, 19, 0)) @@ -228,18 +221,18 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 5, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Mark as consumed consumed_date = datetime(2024, 5, 1, 19, 30) await meals_db.mark_consumed(self.conn, meal, consumed_date) - + # Verify consumed date was set self.assertEqual(meal.consumed_date, consumed_date) - + # Verify in database found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(found_meal.consumed_date, consumed_date) @@ -250,18 +243,18 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 6, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Mark as purchased updated_meal = await meals_db.mark_purchased(self.conn, meal) - + # Verify purchase date was set self.assertIsNotNone(updated_meal.purchase_date) self.assertIsNotNone(meal.purchase_date) - + # Verify in database found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal.purchase_date) @@ -272,12 +265,12 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 7, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) meal_id = meal.id - + # Verify meal exists and is in upcoming meals before deletion start_date = datetime(2024, 7, 1) end_date = datetime(2024, 7, 31) @@ -286,10 +279,10 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): if m.id == meal_id: upcoming_meals_before.append(m) self.assertEqual(len(upcoming_meals_before), 1) - + # Delete the meal await meals_db.delete_meal(self.conn, meal_id) - + # Verify meal no longer appears in upcoming meals (soft deleted) upcoming_meals_after = [] async for m in meals_db.find_upcoming_meals_by_date_range(self.conn, start_date, end_date): @@ -304,48 +297,50 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 8, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + meal2 = Meal( suggested_date=datetime(2024, 8, 15, 18, 0), chefs=[test_data.Persons.ryan], cleanup=[test_data.Persons.jacob], - consumers=[test_data.Persons.chris] + consumers=[test_data.Persons.chris], ) - + meal3 = Meal( suggested_date=datetime(2024, 9, 1, 18, 0), chefs=[test_data.Persons.ellie], cleanup=[test_data.Persons.chris], - consumers=[test_data.Persons.jacob] + consumers=[test_data.Persons.jacob], ) - + # Create a consumed meal (should not appear in upcoming) consumed_meal = Meal( suggested_date=datetime(2024, 8, 10, 18, 0), consumed_date=datetime(2024, 8, 10, 19, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal1) await meals_db.insert_meal(self.conn, meal2) await meals_db.insert_meal(self.conn, meal3) await meals_db.insert_meal(self.conn, consumed_meal) - + # Mark consumed meal as consumed in DB await meals_db.mark_consumed(self.conn, consumed_meal, consumed_meal.consumed_date) - + # Find meals in August 2024 start_date = datetime(2024, 8, 1) end_date = datetime(2024, 8, 31) - + upcoming_meals = [] - async for meal in meals_db.find_upcoming_meals_by_date_range(self.conn, start_date, end_date): + async for meal in meals_db.find_upcoming_meals_by_date_range( + self.conn, start_date, end_date + ): upcoming_meals.append(meal) - + # Should find meal1 and meal2, but not meal3 (outside range) or consumed_meal (consumed) self.assertEqual(len(upcoming_meals), 2) meal_dates = [meal.suggested_date for meal in upcoming_meals] @@ -355,9 +350,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase): class TestMealParticipants(unittest.IsolatedAsyncioTestCase): """Test meal participant management""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() @@ -373,15 +368,15 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase): suggested_date=datetime(2024, 10, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Update participants new_chefs = [test_data.Persons.ryan, test_data.Persons.ellie] - await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, 'chef') - + await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, "chef") + # Verify participants were updated found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(len(found_meal.chefs), 2) @@ -389,7 +384,7 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase): self.assertIn("Ryan", chef_names) self.assertIn("Ellie", chef_names) self.assertNotIn("Jacob", chef_names) - + # Cleanup and consumers should remain unchanged self.assertEqual(len(found_meal.cleanup), 1) self.assertEqual(found_meal.cleanup[0].name, "Ryan") @@ -399,9 +394,9 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase): class TestMealRecipes(unittest.IsolatedAsyncioTestCase): """Test meal recipe management""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() @@ -415,7 +410,7 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase): """Test meal recipe validation during insertion""" # Try to insert meal recipe without valid meal_id meal_recipe = MealRecipe(meal_id=-1, recipe_id=1, servings=2.0) - + with self.assertRaises(ValueError) as context: await meals_db.insert_meal_recipe(self.conn, meal_recipe) self.assertIn("Meal must be inserted", str(context.exception)) @@ -426,23 +421,21 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase): recipe = test_data.Recipes.broccoli_soup recipe.id = -1 # Reset ID await recipes.insert_recipe(self.conn, recipe) - + meal = Meal( suggested_date=datetime(2024, 11, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Add recipes to meal - meal_recipes = [ - MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0) - ] - + meal_recipes = [MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0)] + await meals_db.sync_meal_recipes(self.conn, meal.id, meal_recipes) - + # Verify recipes were added found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(len(found_meal.recipes), 1) @@ -451,9 +444,9 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase): class TestMealIngredients(unittest.IsolatedAsyncioTestCase): """Test meal extra ingredients management""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_test_data(self.conn) reload_test_data() @@ -468,7 +461,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase): # Create a new product for testing product = products.Product( id=-1, - shop_code='woolworths', + shop_code="woolworths", name="Test Bread Roll", product_id="test_bread_123", quantity=1, @@ -479,16 +472,16 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase): raw_data={}, ) await products.insert_product(self.conn, product, {}) - + meal = Meal( suggested_date=datetime(2024, 12, 1, 18, 0), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie] + consumers=[test_data.Persons.ellie], ) - + await meals_db.insert_meal(self.conn, meal) - + # Add extra ingredients extra_ingredient = ingredients.Ingredient( id=-1, @@ -497,16 +490,16 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase): unit="roll", quantity=1.0, preparation="", - product_id=product.id + product_id=product.id, ) - + await meals_db.sync_extra_ingredients(self.conn, meal.id, [extra_ingredient]) - + # Verify ingredients were added found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) self.assertEqual(len(found_meal.extra_ingredients), 1) self.assertEqual(found_meal.extra_ingredients[0].name, "Test Bread Roll") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_products.py b/tests/test_products.py index 266445b..3a67bf5 100644 --- a/tests/test_products.py +++ b/tests/test_products.py @@ -6,13 +6,16 @@ import products.db as products_db from db import connect, create import importlib + + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + class TestProductsDb(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) reload_test_data() return await super().asyncSetUp() @@ -20,13 +23,13 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase): async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() - + async def testCreateAndFind(self) -> None: product = test_data.Products.broccoli await products_db.insert_product(self.conn, product, {}) self.assertIsNotNone(product) self.assertGreater(product.id, 0) - + product_by_id = await products_db.find_product_by_id(self.conn, product.id) self.assertIsNotNone(product_by_id) self.assertEqual(product_by_id.id, product.id) @@ -39,31 +42,35 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase): from . import httpx_mocks from products import woolworths + class TestWoolworths(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: - local_path = './tests/sample_files/woolworths' + local_path = "./tests/sample_files/woolworths" woolworths._get_client = lambda: httpx_mocks.MockAsyncClient(local_path) # woolworths._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path) return await super().asyncSetUp() - + async def test_get_product_id(self) -> None: params = [ - ('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', '144607'), - ('https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas', '133211'), - ('https://www.coles.com.au/product/coles-strawberries-250g-5191256', None), + ("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", "144607"), + ( + "https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas", + "133211", + ), + ("https://www.coles.com.au/product/coles-strawberries-250g-5191256", None), ] for url, id in params: self.assertEqual(woolworths.get_product_id(url), id) async def test_get_strawberries(self) -> None: - details, raw_data = await woolworths.scrape('144607') + details, raw_data = await woolworths.scrape("144607") expected = { - 'name': 'Strawberries', - 'quantity': 250, - 'unit': 'g Punnet', - 'img_small': 'https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg', - 'img_large': 'https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg' + "name": "Strawberries", + "quantity": 250, + "unit": "g Punnet", + "img_small": "https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg", + "img_large": "https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg", } for key, value in expected.items(): @@ -72,33 +79,39 @@ class TestWoolworths(unittest.IsolatedAsyncioTestCase): from products import coles + class TestColes(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: - local_path = './tests/sample_files/coles' + local_path = "./tests/sample_files/coles" coles._get_client = lambda: httpx_mocks.MockAsyncClient(local_path) # coles._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path) return await super().asyncSetUp() - + async def test_get_product_id(self) -> None: params = [ - ('https://www.coles.com.au/product/coles-strawberries-250g-5191256', 'coles-strawberries-250g-5191256'), - ('https://www.coles.com.au/product/coles-blueberries-170g-3571948', 'coles-blueberries-170g-3571948'), - ('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', None), + ( + "https://www.coles.com.au/product/coles-strawberries-250g-5191256", + "coles-strawberries-250g-5191256", + ), + ( + "https://www.coles.com.au/product/coles-blueberries-170g-3571948", + "coles-blueberries-170g-3571948", + ), + ("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", None), ] for url, id in params: self.assertEqual(coles.get_product_id(url), id) async def test_get_strawberries(self) -> None: - details, raw_data = await coles.scrape('coles-strawberries-250g-5191256') + details, raw_data = await coles.scrape("coles-strawberries-250g-5191256") expected = { - 'name': 'Strawberries', - 'quantity': 250, - 'unit': 'g', - 'img_small': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg', - 'img_large': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg' + "name": "Strawberries", + "quantity": 250, + "unit": "g", + "img_small": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg", + "img_large": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg", } for key, value in expected.items(): self.assertEqual(details[key], value, msg=key) - diff --git a/tests/test_shopping.py b/tests/test_shopping.py index 23e77e6..cdd6609 100644 --- a/tests/test_shopping.py +++ b/tests/test_shopping.py @@ -6,10 +6,12 @@ from unittest.mock import AsyncMock, Mock, patch import tests.test_data as test_data import importlib + def reload_test_data(): global test_data test_data = importlib.reload(test_data) + from db import connect, create import shopping import shopping.db as shopping_db @@ -24,9 +26,9 @@ import recipes class TestShoppingModels(unittest.IsolatedAsyncioTestCase): """Test the shopping data models""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -44,28 +46,21 @@ class TestShoppingModels(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - + item = ShoppingListItem( - id=1, - ingredient_id=ingredient.id, - person_id=1, - created_date=datetime.now() + id=1, ingredient_id=ingredient.id, person_id=1, created_date=datetime.now() ) - + self.assertEqual(item.id, 1) self.assertEqual(item.ingredient_id, 1) self.assertEqual(item.person_id, 1) def test_shopping_list_creation(self): """Test basic ShoppingList creation""" - shopping_list = ShoppingList( - id=1, - store_name=StoreEnum.woolworths, - purchased_by_id=1 - ) - + shopping_list = ShoppingList(id=1, store_name=StoreEnum.woolworths, purchased_by_id=1) + self.assertEqual(shopping_list.id, 1) self.assertEqual(shopping_list.store_name, StoreEnum.woolworths) self.assertEqual(shopping_list.purchased_by_id, 1) @@ -73,16 +68,16 @@ class TestShoppingModels(unittest.IsolatedAsyncioTestCase): def test_store_enum_values(self): """Test StoreEnum values""" - self.assertEqual(StoreEnum.woolworths, 'woolworths') - self.assertEqual(StoreEnum.coles, 'coles') - self.assertEqual(StoreEnum.home, '') + self.assertEqual(StoreEnum.woolworths, "woolworths") + self.assertEqual(StoreEnum.coles, "coles") + self.assertEqual(StoreEnum.home, "") class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): """Test shopping validation functions""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -95,51 +90,38 @@ class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): def test_validate_request_valid_ingredient(self): """Test validation of valid ingredient request""" ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - item = ShoppingListItem( - ingredient_id=ingredient.id, - person_id=1 - ) - + item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) + # Should not raise any exception shopping_db.validate_request(item) def test_validate_request_valid_meal(self): """Test validation of valid meal request""" - meal = meals.Meal( - id=1, - name="Dinner", - suggested_date=datetime.now() - ) - item = ShoppingListItem( - meal_id=meal.id, - person_id=1 - ) - + meal = meals.Meal(id=1, name="Dinner", suggested_date=datetime.now()) + item = ShoppingListItem(meal_id=meal.id, person_id=1) + # Should not raise any exception shopping_db.validate_request(item) def test_validate_request_no_person(self): """Test validation fails when no person is specified""" ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - item = ShoppingListItem( - ingredient_id=ingredient.id, - person_id=-1 # Invalid person id - ) - + item = ShoppingListItem(ingredient_id=ingredient.id, person_id=-1) # Invalid person id + with self.assertRaises(ValueError) as context: shopping_db.validate_request(item) self.assertIn("Requests must have a person", str(context.exception)) @@ -147,7 +129,7 @@ class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): def test_validate_request_no_ingredient_or_meal(self): """Test validation fails when neither ingredient nor meal is specified""" item = ShoppingListItem(person_id=1) - + with self.assertRaises(ValueError) as context: shopping_db.validate_request(item) self.assertIn("Request must have either an ingredient or a meal", str(context.exception)) @@ -155,9 +137,9 @@ class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): """Test shopping request functionality""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -175,14 +157,14 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Request the ingredient person = test_data.Persons.jacob item = await shopping_db.request(self.conn, person, ingredient=ingredient) - + self.assertIsNotNone(item.id) self.assertEqual(item.ingredient_id, ingredient.id) self.assertEqual(item.person_id, person.id) @@ -191,16 +173,12 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): async def test_request_meal(self): """Test requesting a meal""" # Create a mock meal with proper structure - meal = meals.Meal( - id=1, - name="Test Meal", - suggested_date=datetime.now() - ) - - with patch('shopping.db.is_requested', return_value=False): + meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) + + with patch("shopping.db.is_requested", return_value=False): person = test_data.Persons.jacob item = await shopping_db.request(self.conn, person, meal=meal) - + self.assertIsNotNone(item.id) self.assertEqual(item.meal_id, meal.id) self.assertEqual(item.person_id, person.id) @@ -209,20 +187,16 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): async def test_request_both_ingredient_and_meal_fails(self): """Test that requesting both ingredient and meal fails""" ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" - ) - meal = meals.Meal( - id=1, - name="Test Meal", - suggested_date=datetime.now() + preparation="chopped", ) + meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob - + with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person, ingredient=ingredient, meal=meal) self.assertIn("Cannot request both an ingredient and a meal", str(context.exception)) @@ -230,21 +204,19 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): async def test_request_neither_ingredient_nor_meal_fails(self): """Test that requesting neither ingredient nor meal fails""" person = test_data.Persons.jacob - + with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person) - self.assertIn("Must specify either an ingredient or a meal to request", str(context.exception)) + self.assertIn( + "Must specify either an ingredient or a meal to request", str(context.exception) + ) async def test_request_meal_already_requested_fails(self): """Test that requesting an already requested meal fails""" - meal = meals.Meal( - id=1, - name="Test Meal", - suggested_date=datetime.now() - ) + meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob - - with patch('shopping.db.is_requested', return_value=True): + + with patch("shopping.db.is_requested", return_value=True): with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person, meal=meal) self.assertIn("Meal is already requested", str(context.exception)) @@ -252,16 +224,12 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): async def test_remove_request_meal(self): """Test removing a meal request""" # First create a meal request - meal = meals.Meal( - id=1, - name="Test Meal", - suggested_date=datetime.now() - ) + meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob - - with patch('shopping.db.is_requested', return_value=False): + + with patch("shopping.db.is_requested", return_value=False): await shopping_db.request(self.conn, person, meal=meal) - + # Then remove it result = await shopping_db.remove_request(self.conn, meal=meal) self.assertTrue(result) @@ -274,13 +242,13 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) - + # Remove the request result = await shopping_db.remove_request(self.conn, person=person, ingredient=ingredient) self.assertTrue(result) @@ -288,17 +256,19 @@ class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): async def test_remove_request_invalid_parameters_fails(self): """Test that removing request with invalid parameters fails""" person = test_data.Persons.jacob - + with self.assertRaises(ValueError) as context: await shopping_db.remove_request(self.conn, person=person) - self.assertIn("Must specify either a meal or an ingredient to remove", str(context.exception)) + self.assertIn( + "Must specify either a meal or an ingredient to remove", str(context.exception) + ) class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): """Test shopping purchase functionality""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -316,29 +286,26 @@ class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + # First create a request for the ingredient person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) - + # Create shopping list item (this will reference the existing request) - item = ShoppingListItem( - ingredient=ingredient, - person_id=test_data.Persons.jacob.id - ) - + item = ShoppingListItem(ingredient=ingredient, person_id=test_data.Persons.jacob.id) + # Create shopping list shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, - items=[item] + items=[item], ) - + await shopping_db.purchase(self.conn, shopping_list) - + self.assertIsNotNone(shopping_list.id) self.assertIsNotNone(item.list_id) self.assertEqual(item.list_id, shopping_list.id) @@ -346,11 +313,9 @@ class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): async def test_purchase_no_person_fails(self): """Test that purchasing without a person fails""" shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=-1, # Invalid person id - items=[] + store_name=StoreEnum.woolworths, purchased_by_id=-1, items=[] # Invalid person id ) - + with self.assertRaises(ValueError) as context: await shopping_db.purchase(self.conn, shopping_list) self.assertIn("Shopping list must have a person id", str(context.exception)) @@ -358,11 +323,9 @@ class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): async def test_purchase_no_items_fails(self): """Test that purchasing with no items fails""" shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=test_data.Persons.jacob.id, - items=[] + store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, items=[] ) - + with self.assertRaises(ValueError) as context: await shopping_db.purchase(self.conn, shopping_list) self.assertIn("Shopping list must have items", str(context.exception)) @@ -370,9 +333,9 @@ class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): """Test shopping helper functions""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -386,50 +349,43 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): """Test to_lookups function""" # Create actual items with proper IDs - need to insert them first to get valid lookups ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) - + # Insert the ingredient to get a valid ID await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Insert test meal and recipe to get valid IDs - meal = meals.Meal( - id=1, - suggested_date=datetime.now() - ) + meal = meals.Meal(id=1, suggested_date=datetime.now()) await meals.insert_meal(self.conn, meal) - + recipe = recipes.Recipe( - id=1, - name="Test Recipe", - link="http://example.com", - serves=4, - created_by_id=1 + id=1, name="Test Recipe", link="http://example.com", serves=4, created_by_id=1 ) await recipes.insert_recipe(self.conn, recipe) - + items = [ ShoppingListItem(ingredient_id=ingredient.id, meal_id=meal.id, recipe_id=recipe.id) ] - + # Test the function - it should populate the lookups based on IDs meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( self.conn, items ) - + # Check that the lookups contain our objects self.assertEqual(len(meals_lookup), 1) - self.assertEqual(len(recipes_lookup), 1) + self.assertEqual(len(recipes_lookup), 1) self.assertEqual(len(ingredients_lookup), 1) self.assertEqual(meals_lookup[1].id, meal.id) self.assertEqual(recipes_lookup[1].id, recipe.id) self.assertEqual(ingredients_lookup[1].id, ingredient.id) - + # Items should still have their IDs self.assertEqual(items[0].meal_id, meal.id) self.assertEqual(items[0].recipe_id, recipe.id) @@ -439,50 +395,45 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): """Test flatten_items function with meal items""" # Create meal with recipes and ingredients recipe_ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Recipe Ingredient", line="500g recipe ingredient", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) extra_ingredient = ingredients_db.Ingredient( - id=2, + id=2, name="Extra Ingredient", line="200g extra ingredient", unit="g", quantity=200.0, - preparation="diced" + preparation="diced", ) - + recipe = recipes.Recipe( - id=1, - name="Test Recipe", + id=1, + name="Test Recipe", link="http://example.com", serves=4, created_by_id=1, - ingredients=[recipe_ingredient] + ingredients=[recipe_ingredient], ) - meal_recipe = MealRecipe( - meal_id=1, - recipe_id=1, - servings=2.0, - recipe=recipe - ) - + meal_recipe = MealRecipe(meal_id=1, recipe_id=1, servings=2.0, recipe=recipe) + meal = meals.Meal( - id=1, + id=1, suggested_date=datetime.now(), recipes=[meal_recipe], - extra_ingredients=[extra_ingredient] + extra_ingredients=[extra_ingredient], ) - + item = ShoppingListItem(meal_id=meal.id, person_id=1) - + # Create lookups for flatten_items meals_lookup = {meal.id: meal} flattened = list(shopping.flatten_items([item], meals_lookup)) - + # Should have 2 items: one for recipe ingredient, one for extra ingredient self.assertEqual(len(flattened), 2) self.assertEqual(flattened[0].ingredient_id, 1) @@ -491,15 +442,15 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): def test_flatten_items_without_meal(self): """Test flatten_items function with non-meal items""" ingredient = ingredients_db.Ingredient( - id=1, + id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) - + # Empty lookups since no meal/recipe is involved flattened = list(shopping.flatten_items([item], {})) @@ -509,24 +460,24 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): async def test_get_persons_requests(self): """Test get_persons_requests function""" person_id = 1 - + # Create actual ingredients and requests ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Create a request for person 1 person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) - + # Get the person's requests requests = await shopping.get_persons_requests(self.conn, person_id) - + # Should have one request for the ingredient self.assertEqual(len(requests), 1) self.assertEqual(requests[0].id, ingredient.id) @@ -539,17 +490,19 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + # Create requests person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) - + # Test the function - outstanding, purchased, meal_requests, _, _, _ = await shopping.get_outstanding_requests(self.conn) - + outstanding, purchased, meal_requests, _, _, _ = await shopping.get_outstanding_requests( + self.conn + ) + # Should have one outstanding ingredient request self.assertGreaterEqual(len(outstanding), 1) self.assertEqual(len(purchased), 0) @@ -557,31 +510,24 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): async def test_is_requested(self): """Test is_requested function""" - meal = meals.Meal( - id=1, - suggested_date=datetime.now() - ) - + meal = meals.Meal(id=1, suggested_date=datetime.now()) + # Test with a meal that hasn't been requested result = await shopping_db.is_requested(self.conn, meal) self.assertFalse(result) - + # Create a request for the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) - + # Now it should be requested result = await shopping_db.is_requested(self.conn, meal) self.assertTrue(result) async def test_is_requested_invalid_meal(self): """Test is_requested with invalid meal""" - meal = meals.Meal( - id=-1, - name="Invalid Meal", - suggested_date=datetime.now() - ) - + meal = meals.Meal(id=-1, name="Invalid Meal", suggested_date=datetime.now()) + result = await shopping_db.is_requested(self.conn, meal) self.assertFalse(result) @@ -593,30 +539,27 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): line="500g fresh broccoli", unit="g", quantity=500.0, - preparation="chopped" + preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) - + # First create a request for the ingredient person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) - - item = ShoppingListItem( - ingredient_id=ingredient.id, - person_id=test_data.Persons.jacob.id - ) - + + item = ShoppingListItem(ingredient_id=ingredient.id, person_id=test_data.Persons.jacob.id) + shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, - items=[item] + items=[item], ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Now load it back loaded_list = await shopping_db.load_shopping_list(self.conn, shopping_list.id) - + self.assertIsNotNone(loaded_list) self.assertEqual(loaded_list.id, shopping_list.id) self.assertEqual(loaded_list.store_name, shopping_list.store_name) @@ -625,9 +568,9 @@ class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): """Test shopping complex edge cases for meal requests and purchases""" - + async def asyncSetUp(self): - self.conn = await connect(':memory:') + self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() @@ -645,10 +588,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): name="Test Pasta Recipe", link="http://example.com/pasta", serves=4, - created_by_id=test_data.Persons.jacob.id + created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) - + # Create ingredients for the recipe pasta_ingredient = ingredients_db.Ingredient( name="Pasta", @@ -656,7 +599,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=500.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) tomato_ingredient = ingredients_db.Ingredient( name="Tomatoes", @@ -664,31 +607,26 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=400.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) - + await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) await ingredients_db.insert_ingredient(self.conn, tomato_ingredient) - + # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) - + # Create a meal with this recipe and extra ingredients extra_ingredient = ingredients_db.Ingredient( name="Garlic Bread", line="1 loaf garlic bread", unit="loaf", quantity=1.0, - preparation="" + preparation="", ) - - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=2.0, - recipe=recipe - ) - + + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) + meal = meals.Meal( id=-1, suggested_date=datetime.now(), @@ -696,32 +634,43 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], - extra_ingredients=[extra_ingredient] + extra_ingredients=[extra_ingredient], ) - + # Insert the meal await meals.insert_meal(self.conn, meal) - + # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) - + # Get outstanding requests - outstanding, purchased, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) - + ( + outstanding, + purchased, + meal_requests, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) + # Should have one meal request self.assertEqual(len(meal_requests), 1) self.assertEqual(meal_requests[0].meal_id, meal.id) - + # Should have 3 outstanding items: 2 from recipe + 1 extra ingredient self.assertEqual(len(outstanding), 3) - + # Check that all ingredients are included - ingredient_names = {ingredients_lookup[item.ingredient_id].name for item in outstanding if item.ingredient_id in ingredients_lookup} + ingredient_names = { + ingredients_lookup[item.ingredient_id].name + for item in outstanding + if item.ingredient_id in ingredients_lookup + } self.assertIn("Pasta", ingredient_names) self.assertIn("Tomatoes", ingredient_names) self.assertIn("Garlic Bread", ingredient_names) - + # All should be associated with the meal for item in outstanding: self.assertEqual(item.meal_id, meal.id) @@ -734,10 +683,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): name="Multi-Ingredient Recipe", link="http://example.com/multi", serves=4, - created_by_id=test_data.Persons.jacob.id + created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) - + # Create multiple ingredients for the recipe ingredient1 = ingredients_db.Ingredient( name="Rice", @@ -745,7 +694,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=200.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) ingredient2 = ingredients_db.Ingredient( name="Chicken", @@ -753,7 +702,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=300.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) ingredient3 = ingredients_db.Ingredient( name="Vegetables", @@ -761,81 +710,95 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=150.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) await ingredients_db.insert_ingredient(self.conn, ingredient3) - + # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) - + # Create and insert a meal - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=2.0, - recipe=recipe - ) - + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) + meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], - recipes=[meal_recipe] + recipes=[meal_recipe], ) - + await meals.insert_meal(self.conn, meal) - + # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) - + # Get initial outstanding requests - outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_before, + purchased_before, + meal_requests_before, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 3) # All 3 ingredients self.assertEqual(len(purchased_before), 0) # Nothing purchased yet - + # Purchase only one ingredient (Rice) from the meal rice_item = None for item in outstanding_before: - if item.ingredient_id in ingredients_lookup and ingredients_lookup[item.ingredient_id].name == "Rice": + if ( + item.ingredient_id in ingredients_lookup + and ingredients_lookup[item.ingredient_id].name == "Rice" + ): rice_item = ShoppingListItem( ingredient_id=item.ingredient_id, person_id=person.id, meal_id=meal.id, - recipe_id=recipe.id + recipe_id=recipe.id, ) break - + self.assertIsNotNone(rice_item) - + # Create and purchase a shopping list with just the rice shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=person.id, - items=[rice_item] + store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[rice_item] ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Get outstanding requests after purchase - outstanding_after, purchased_after, meal_requests_after, meals_lookup2, recipes_lookup2, ingredients_lookup2 = await shopping.get_outstanding_requests(self.conn) - + ( + outstanding_after, + purchased_after, + meal_requests_after, + meals_lookup2, + recipes_lookup2, + ingredients_lookup2, + ) = await shopping.get_outstanding_requests(self.conn) + # Should have 2 outstanding items (Chicken and Vegetables) self.assertEqual(len(outstanding_after), 2) - outstanding_names = {ingredients_lookup2[item.ingredient_id].name for item in outstanding_after if item.ingredient_id in ingredients_lookup2} + outstanding_names = { + ingredients_lookup2[item.ingredient_id].name + for item in outstanding_after + if item.ingredient_id in ingredients_lookup2 + } self.assertIn("Chicken", outstanding_names) self.assertIn("Vegetables", outstanding_names) self.assertNotIn("Rice", outstanding_names) - + # Should have 1 purchased item (Rice) self.assertEqual(len(purchased_after), 1) self.assertEqual(purchased_after[0].ingredient_id, ingredient1.id) - + # Meal should still be requested (not all ingredients purchased) self.assertEqual(len(meal_requests_after), 1) @@ -847,10 +810,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): name="Simple Recipe", link="http://example.com/simple", serves=2, - created_by_id=test_data.Persons.jacob.id + created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) - + # Create ingredients for the recipe ingredient1 = ingredients_db.Ingredient( name="Bread", @@ -858,7 +821,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="slices", quantity=2.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) ingredient2 = ingredients_db.Ingredient( name="Butter", @@ -866,86 +829,95 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=10.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) - + # Create and insert a meal - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=1.0, - recipe=recipe - ) - + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) + meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], - recipes=[meal_recipe] + recipes=[meal_recipe], ) - + await meals.insert_meal(self.conn, meal) - + # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) - + # Verify meal is requested is_requested_before = await shopping_db.is_requested(self.conn, meal) self.assertTrue(is_requested_before) - + # Get initial outstanding requests - outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_before, + purchased_before, + meal_requests_before, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Both ingredients self.assertEqual(len(meal_requests_before), 1) # Meal is requested - + # Verify the meal is not marked as purchased yet found_meal_before = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNone(found_meal_before.purchase_date) - + # Purchase all ingredients from the meal shopping_items = [] for item in outstanding_before: - shopping_items.append(ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id, - meal_id=meal.id, - recipe_id=recipe.id - )) - + shopping_items.append( + ShoppingListItem( + ingredient_id=item.ingredient_id, + person_id=person.id, + meal_id=meal.id, + recipe_id=recipe.id, + ) + ) + shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=person.id, - items=shopping_items + store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Verify meal is no longer requested is_requested_after = await shopping_db.is_requested(self.conn, meal) self.assertFalse(is_requested_after) - + # Verify meal is marked as purchased found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) - + # Get outstanding requests after complete purchase - outstanding_after, purchased_after, meal_requests_after, _, _, _ = await shopping.get_outstanding_requests(self.conn) - + ( + outstanding_after, + purchased_after, + meal_requests_after, + _, + _, + _, + ) = await shopping.get_outstanding_requests(self.conn) + # Should have no outstanding items from this meal self.assertEqual(len(outstanding_after), 0) - + # Should have no purchased items (meal is complete so ingredients don't appear) self.assertEqual(len(purchased_after), 0) - + # Should have no meal requests self.assertEqual(len(meal_requests_after), 0) @@ -957,10 +929,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): name="Recipe with Extra", link="http://example.com/extra", serves=2, - created_by_id=test_data.Persons.jacob.id + created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) - + # Create recipe ingredient recipe_ingredient = ingredients_db.Ingredient( name="Main Ingredient", @@ -968,29 +940,20 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=200.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) - + await ingredients_db.insert_ingredient(self.conn, recipe_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) - + # Create extra ingredient (not part of recipe) extra_ingredient = ingredients_db.Ingredient( - name="Side Dish", - line="1 side dish", - unit="item", - quantity=1.0, - preparation="" + name="Side Dish", line="1 side dish", unit="item", quantity=1.0, preparation="" ) - + # Create and insert a meal with both recipe and extra ingredients - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=1.0, - recipe=recipe - ) - + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) + meal = meals.Meal( id=-1, suggested_date=datetime.now(), @@ -998,47 +961,61 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], - extra_ingredients=[extra_ingredient] + extra_ingredients=[extra_ingredient], ) - + await meals.insert_meal(self.conn, meal) - + # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) - + # Get initial outstanding requests - outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_before, + purchased_before, + meal_requests_before, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Recipe ingredient + extra ingredient self.assertEqual(len(meal_requests_before), 1) - + # Purchase all ingredients shopping_items = [] for item in outstanding_before: - shopping_items.append(ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id, - meal_id=meal.id, - recipe_id=item.recipe_id - )) - + shopping_items.append( + ShoppingListItem( + ingredient_id=item.ingredient_id, + person_id=person.id, + meal_id=meal.id, + recipe_id=item.recipe_id, + ) + ) + shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=person.id, - items=shopping_items + store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Verify meal is unrequested and marked as purchased is_requested_after = await shopping_db.is_requested(self.conn, meal) self.assertFalse(is_requested_after) - + found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) - + # Get final state - outstanding_after, purchased_after, meal_requests_after, _, _, _ = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_after, + purchased_after, + meal_requests_after, + _, + _, + _, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 0) self.assertEqual(len(purchased_after), 0) # No purchased items since meal is complete self.assertEqual(len(meal_requests_after), 0) @@ -1047,90 +1024,104 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): """Test purchasing individual ingredients that are not part of a meal""" # Create individual ingredients ingredient1 = ingredients_db.Ingredient( - name="Milk", - line="1L milk", - unit="L", - quantity=1.0, - preparation="" + name="Milk", line="1L milk", unit="L", quantity=1.0, preparation="" ) ingredient2 = ingredients_db.Ingredient( - name="Eggs", - line="12 eggs", - unit="dozen", - quantity=1.0, - preparation="" + name="Eggs", line="12 eggs", unit="dozen", quantity=1.0, preparation="" ) - + await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) - + # Request individual ingredients (not part of any meal) person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient1) await shopping_db.request(self.conn, person, ingredient=ingredient2) - + # Verify both ingredients appear in outstanding requests - outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_before, + purchased_before, + meal_requests_before, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) self.assertEqual(len(purchased_before), 0) self.assertEqual(len(meal_requests_before), 0) # No meal requests - + # Verify the ingredients in outstanding requests - ingredient_names = {ingredients_lookup[item.ingredient_id].name for item in outstanding_before if item.ingredient_id in ingredients_lookup} + ingredient_names = { + ingredients_lookup[item.ingredient_id].name + for item in outstanding_before + if item.ingredient_id in ingredients_lookup + } self.assertIn("Milk", ingredient_names) self.assertIn("Eggs", ingredient_names) - + # All should be individual requests (no meal_id) for item in outstanding_before: self.assertIsNone(item.meal_id) self.assertEqual(item.person_id, person.id) - + # Purchase only one ingredient (Milk) milk_item = None for item in outstanding_before: - if item.ingredient_id in ingredients_lookup and ingredients_lookup[item.ingredient_id].name == "Milk": - milk_item = ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id - ) + if ( + item.ingredient_id in ingredients_lookup + and ingredients_lookup[item.ingredient_id].name == "Milk" + ): + milk_item = ShoppingListItem(ingredient_id=item.ingredient_id, person_id=person.id) break - + self.assertIsNotNone(milk_item) - + shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=person.id, - items=[milk_item] + store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[milk_item] ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Verify only eggs remains in outstanding, milk is purchased - outstanding_after, purchased_after, meal_requests_after, meals_lookup_after, recipes_lookup_after, ingredients_lookup_after = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_after, + purchased_after, + meal_requests_after, + meals_lookup_after, + recipes_lookup_after, + ingredients_lookup_after, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 1) - self.assertEqual(len(purchased_after), 0) # Individual purchases don't appear in purchased list + self.assertEqual( + len(purchased_after), 0 + ) # Individual purchases don't appear in purchased list self.assertEqual(len(meal_requests_after), 0) - + # Verify only eggs remains self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Eggs") self.assertIsNone(outstanding_after[0].meal_id) - + # Purchase the remaining ingredient (Eggs) eggs_item = ShoppingListItem( - ingredient_id=outstanding_after[0].ingredient_id, - person_id=person.id + ingredient_id=outstanding_after[0].ingredient_id, person_id=person.id ) - + shopping_list2 = ShoppingList( - store_name=StoreEnum.coles, - purchased_by_id=person.id, - items=[eggs_item] + store_name=StoreEnum.coles, purchased_by_id=person.id, items=[eggs_item] ) - + await shopping_db.purchase(self.conn, shopping_list2) - + # Verify no outstanding requests remain - outstanding_final, purchased_final, meal_requests_final, _, _, _ = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_final, + purchased_final, + meal_requests_final, + _, + _, + _, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_final), 0) self.assertEqual(len(purchased_final), 0) self.assertEqual(len(meal_requests_final), 0) @@ -1143,10 +1134,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): name="Simple Pasta", link="http://example.com/pasta", serves=2, - created_by_id=test_data.Persons.jacob.id + created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) - + # Create recipe ingredient pasta_ingredient = ingredients_db.Ingredient( name="Pasta", @@ -1154,57 +1145,59 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): unit="g", quantity=200.0, preparation="", - recipe_id=recipe.id + recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) - + # Create meal - meal_recipe = MealRecipe( - meal_id=-1, - recipe_id=recipe.id, - servings=1.0, - recipe=recipe - ) - + meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) + meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], - recipes=[meal_recipe] + recipes=[meal_recipe], ) - + await meals.insert_meal(self.conn, meal) - + # Create individual ingredient snack_ingredient = ingredients_db.Ingredient( - name="Chips", - line="1 bag chips", - unit="bag", - quantity=1.0, - preparation="" + name="Chips", line="1 bag chips", unit="bag", quantity=1.0, preparation="" ) await ingredients_db.insert_ingredient(self.conn, snack_ingredient) - + person = test_data.Persons.jacob - + # Request both meal and individual ingredient await shopping_db.request(self.conn, person, meal=meal) await shopping_db.request(self.conn, person, ingredient=snack_ingredient) - + # Verify we have both meal and individual requests - outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_before, + purchased_before, + meal_requests_before, + meals_lookup, + recipes_lookup, + ingredients_lookup, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Pasta from meal + Chips individual self.assertEqual(len(purchased_before), 0) self.assertEqual(len(meal_requests_before), 1) # One meal request - + # Verify the mix of ingredients - ingredient_names = {ingredients_lookup[item.ingredient_id].name for item in outstanding_before if item.ingredient_id in ingredients_lookup} + ingredient_names = { + ingredients_lookup[item.ingredient_id].name + for item in outstanding_before + if item.ingredient_id in ingredients_lookup + } self.assertIn("Pasta", ingredient_names) self.assertIn("Chips", ingredient_names) - + # Check that pasta is from meal, chips is individual pasta_item = None chips_item = None @@ -1215,35 +1208,39 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): pasta_item = item elif ingredient_name == "Chips": chips_item = item - + self.assertIsNotNone(pasta_item) self.assertIsNotNone(chips_item) self.assertEqual(pasta_item.meal_id, meal.id) self.assertIsNone(chips_item.meal_id) - + # Purchase the individual ingredient (Chips) chips_shopping_item = ShoppingListItem( - ingredient_id=chips_item.ingredient_id, - person_id=person.id + ingredient_id=chips_item.ingredient_id, person_id=person.id ) - + shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=person.id, - items=[chips_shopping_item] + store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[chips_shopping_item] ) - + await shopping_db.purchase(self.conn, shopping_list) - + # Verify only meal ingredient remains - outstanding_after, purchased_after, meal_requests_after, meals_lookup_after, recipes_lookup_after, ingredients_lookup_after = await shopping.get_outstanding_requests(self.conn) + ( + outstanding_after, + purchased_after, + meal_requests_after, + meals_lookup_after, + recipes_lookup_after, + ingredients_lookup_after, + ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 1) # Only pasta from meal self.assertEqual(len(purchased_after), 0) self.assertEqual(len(meal_requests_after), 1) # Meal still requested - + self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Pasta") self.assertEqual(outstanding_after[0].meal_id, meal.id) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/units.py b/units.py index 67dbb83..a763f32 100644 --- a/units.py +++ b/units.py @@ -1,5 +1,3 @@ -from typing import Union - class Unit: def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0): self.name = name @@ -15,6 +13,7 @@ class Unit: """Converts a quantity from the base unit to this unit.""" return quantity / self.conversion_to_base + # Define common base units in SI units ITEMS = Unit("Items", ["item", "items"], "count", 1) LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1) @@ -35,12 +34,29 @@ MILLIGRAM = Unit("Milligram", ["milligram", "milligrams", "mg"], "weight", 1) KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000) # Big list of units -ALL_UNITS = [ITEMS, LITRE, GRAM, CUP, TABLESPOON, TEASPOON, OUNCE, POUND, FLUID_OUNCE, PINT, QUART, GALLON, MILLILITRE, MILLIGRAM, KILOGRAM] +ALL_UNITS = [ + ITEMS, + LITRE, + GRAM, + CUP, + TABLESPOON, + TEASPOON, + OUNCE, + POUND, + FLUID_OUNCE, + PINT, + QUART, + GALLON, + MILLILITRE, + MILLIGRAM, + KILOGRAM, +] -def get_unit(alias: str) -> Union[Unit, None]: + +def get_unit(alias: str) -> Unit | None: """Returns the corresponding unit based on alias or abbreviation.""" alias_lower = alias.lower() for unit in ALL_UNITS: if alias_lower in unit.symbols or alias_lower == unit.name.lower(): return unit - return None \ No newline at end of file + return None