Added ruff, black and mypy

This commit is contained in:
jableader 2025-10-18 14:26:42 +11:00
parent 255ebd4613
commit 18f784665f
31 changed files with 2513 additions and 1630 deletions

12
.editorconfig Normal file
View file

@ -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

30
.github/workflows/ci.yml vendored Normal file
View file

@ -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

20
.gitignore vendored
View file

@ -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/ .venv/
__pycache__ __pycache__
data/ data/

View file

@ -1,11 +1,45 @@
Meal planner backend 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 Install packages
``` ```
pip install -r ./requirements.txt 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 .
``` ```

View file

@ -1,5 +1,7 @@
from pydantic import BaseModel, Field, model_validator from typing import Any
from typing import Optional, Any
from pydantic import BaseModel, model_validator
class BaseLinkedModel(BaseModel): class BaseLinkedModel(BaseModel):
model_config = dict(arbitrary_types_allowed=True) model_config = dict(arbitrary_types_allowed=True)

16
db.py
View file

@ -1,29 +1,39 @@
import asyncio
import aiosqlite 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) return await aiosqlite.connect(path)
async def create(conn: aiosqlite.Connection): async def create(conn: aiosqlite.Connection):
import products.db as product_db import products.db as product_db
await product_db.create(conn) await product_db.create(conn)
import ingredients.db as ingredient_db import ingredients.db as ingredient_db
await ingredient_db.create(conn) await ingredient_db.create(conn)
import recipes.db as recipe_db import recipes.db as recipe_db
await recipe_db.create(conn) await recipe_db.create(conn)
import persons.db as person_db import persons.db as person_db
await person_db.create(conn) await person_db.create(conn)
import meals.db as meals_db import meals.db as meals_db
await meals_db.create(conn) await meals_db.create(conn)
import shopping.db as shopping_db import shopping.db as shopping_db
await shopping_db.create(conn) await shopping_db.create(conn)
if __name__ == '__main__':
import asyncio if __name__ == "__main__":
from tests.test_data import create_test_data from tests.test_data import create_test_data
async def main(): async def main():

View file

@ -1,15 +1,22 @@
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 re
from typing import List, Optional
import units
from products import Product, find_product_by_tag, get_or_create, add_missing_tags
from ingredient_parser import parse_ingredient from ingredient_parser import parse_ingredient
import re import units
from typing import List 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: if not match:
return None return None
@ -19,19 +26,22 @@ async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
if product: if product:
await add_missing_tags(conn, product, [product.name]) await add_missing_tags(conn, product, [product.name])
return Ingredient(id=-1, return Ingredient(
id=-1,
name=product.name, name=product.name,
line=f"{quantity}x {product.name}", line=f"{quantity}x {product.name}",
unit=units.ITEMS.name, unit=units.ITEMS.name,
quantity=quantity, quantity=quantity,
preparation='', preparation="",
product_id=product.id, product_id=product.id,
product=product product=product,
) )
return None
def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient: def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
ingredient = parse_ingredient(ingredient_string) 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 quantity, unit = None, None
for amount in ingredient.amount: for amount in ingredient.amount:
@ -55,24 +65,21 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
if unit is None: if unit is None:
unit = units.ITEMS.name unit = units.ITEMS.name
return Ingredient(id=-1, return Ingredient(
id=-1,
line=ingredient.sentence, line=ingredient.sentence,
name=name, name=name,
quantity=quantity, quantity=quantity,
unit=unit, unit=unit,
preparation=ingredient.preparation.text if ingredient.preparation else '', preparation=ingredient.preparation.text if ingredient.preparation else "",
product_id=-1 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]: async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[Ingredient]:
for ingredient in ingredients: for ingredient in ingredients:

View file

@ -1,23 +1,48 @@
from typing import Any, AsyncIterator, ClassVar, List, Optional
from pydantic import BaseModel, field_validator
from products import Product from products import Product
from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Optional
class Ingredient(BaseModel): 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 id: int = -1
name: str name: str
line: str line: str
unit: str unit: str
quantity: float quantity: float | str
preparation: str preparation: str
product_id: Optional[int] = None product_id: Optional[int] = None
recipe_id: Optional[int] = None recipe_id: Optional[int] = None
meal_id: Optional[int] = None meal_id: Optional[int] = None
product: Optional[Product] = 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): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Ingredient ( CREATE TABLE IF NOT EXISTS Ingredient (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT, name TEXT,
@ -31,7 +56,9 @@ async def create(conn):
FOREIGN KEY (product_id) REFERENCES Product(id), FOREIGN KEY (product_id) REFERENCES Product(id),
FOREIGN KEY (recipe_id) REFERENCES Recipe(id), FOREIGN KEY (recipe_id) REFERENCES Recipe(id),
FOREIGN KEY (meal_id) REFERENCES Meal(id) FOREIGN KEY (meal_id) REFERENCES Meal(id)
);''') );"""
)
async def insert_ingredient(conn, ingredient: Ingredient): async def insert_ingredient(conn, ingredient: Ingredient):
if ingredient.product: 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: if ingredient.product_id is None or ingredient.product_id < 0:
ingredient.product_id = None 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) INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) 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 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''' async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient]:
SELECT {','.join(ingredient_keys + product_keys)} FROM 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 LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE Ingredient.id = ? WHERE Ingredient.id = ?
''', (ingredient_id,)) as cursor: """,
(ingredient_id,),
) as cursor:
async for row in cursor: async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
product = Product(**product_keys) if product_keys['id'] else None 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 Ingredient(
**{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])},
product=product,
)
return None 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''' async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[Ingredient]:
SELECT {','.join(ingredient_keys + product_keys)} FROM 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 LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE recipe_id = ? WHERE recipe_id = ?
''', (recipe_id,)) as cursor: """,
(recipe_id,),
) as cursor:
async for row in cursor: async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
product = Product(**product_keys) if product_keys['id'] else None 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) 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]: async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingredient]:
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS] ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
product_keys = [f'product.{key}' for key in Product.KEYS] product_cols = [f"product.{key}" for key in Product.KEYS]
async with conn.execute(f''' async with conn.execute(
SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient f"""
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
LEFT JOIN Product ON Ingredient.product_id = Product.id LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE meal_id = ? WHERE meal_id = ?
''', (meal_id,)) as cursor: """,
(meal_id,),
) as cursor:
async for row in cursor: async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])} product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
product = Product(**product_keys) if product_keys['id'] else None 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) 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): async def delete_ingredients_by_meal_id(conn, meal_id: int):
await conn.execute(''' await conn.execute(
"""
DELETE FROM Ingredient DELETE FROM Ingredient
WHERE meal_id = ? WHERE meal_id = ?
''', (meal_id,)) """,
(meal_id,),
)

360
main.py
View file

@ -1,17 +1,23 @@
import sqlite3
import products, recipes, db, meals, persons, ingredients, shopping
import datetime import datetime
import os
from typing import Annotated, Dict, List, Optional
from pydantic import BaseModel import aiosqlite
from typing import Dict, List, Annotated, Optional, Union from fastapi import Cookie, Depends, FastAPI, Query
from fastapi import FastAPI, Depends, Query, Cookie
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder 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() app = FastAPI()
DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite")
import os
DATABASE_PATH = os.environ.get('DOOF_DB', './data/doof.sqlite')
# Dependency to create SQLite connection # Dependency to create SQLite connection
async def get_db(): async def get_db():
@ -21,22 +27,28 @@ async def get_db():
finally: finally:
await sql_db.close() 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) 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) parsed = await recipes.parse_recipe(conn, person, url)
if not parsed: 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 return parsed
@app.get("/api/recipes/ingredients/parse") @app.get("/api/recipes/ingredients/parse")
async def parse_ingredients(lines: Annotated[ async def parse_ingredients(
List[str], lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
Query(alias="ingredients", conn: aiosqlite.Connection = Depends(get_db),
title="Array of ingredients to parse")], ) -> List[ingredients.Ingredient]:
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]:
had_links = False had_links = False
result = [] result = []
@ -58,15 +70,20 @@ async def parse_ingredients(lines: Annotated[
await ingredients.match_existing_products(conn, result) await ingredients.match_existing_products(conn, result)
return result return result
class ProductUrl(BaseModel): class ProductUrl(BaseModel):
url: str url: str
tags: List[str] = [] tags: List[str] = Field(default_factory=list)
@app.post("/api/products") @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) 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) r = await recipes.find_recipe_by_id(conn, id)
if not r: if not r:
return None return None
@ -75,12 +92,16 @@ async def load_full_recipe(conn: sqlite3.Connection, id: int) -> recipes.Recipe:
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
r.ingredients.append(ingredient) r.ingredients.append(ingredient)
if r.created_by_id is not None:
r.created_by = await persons.get_by_id(conn, r.created_by_id) r.created_by = await persons.get_by_id(conn, r.created_by_id)
return r return r
@app.get("/api/recipes") @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 = [] result = []
if q: if q:
async for recipe in recipes.find_recipes_by_name(conn, q): async for recipe in recipes.find_recipes_by_name(conn, q):
@ -96,18 +117,28 @@ async def get_recipes(q: str | None = None, conn: sqlite3.Connection = Depends(g
return result 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) r = await load_full_recipe(conn, recipe_id)
if not r: 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 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: 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: if recipe.id >= 0:
await recipes.hide_recipe(conn, recipe.id, user) await recipes.hide_recipe(conn, recipe.id, user)
@ -127,18 +158,28 @@ async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depen
return recipe 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) recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe: 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 recipes.hide_recipe(conn, recipe_id, user)
await conn.commit() await conn.commit()
return recipe return recipe
@app.get("/api/meals/upcoming") @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 = [] result = []
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
await meals.load_recipes(conn, meal) await meals.load_recipes(conn, meal)
@ -148,14 +189,18 @@ async def get_upcoming_meals(date_from: Annotated[datetime.datetime, Query(alias
return result 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) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: 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 return meal
def get_duplicates(items: List[meals.Person]) -> set[str]: def get_duplicates(items: List[meals.Person]) -> set[str]:
seen: set[int] = set() seen: set[int] = set()
duplicates: set[str] = set() duplicates: set[str] = set()
@ -165,39 +210,60 @@ def get_duplicates(items: List[meals.Person]) -> set[str]:
seen.add(item.id) seen.add(item.id)
return duplicates return duplicates
def validate_meal(meal : meals.Meal) -> JSONResponse | None:
def validate_meal(meal: meals.Meal) -> Optional[JSONResponse]:
if not meal.chefs: 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: 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: 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: 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) duplicates = get_duplicates(meal.chefs)
if duplicates: 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) duplicates = get_duplicates(meal.cleanup)
if duplicates: 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) duplicates = get_duplicates(meal.consumers)
if duplicates: 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] zero_servings = [r for r in meal.recipes if r.servings == 0]
if zero_servings: 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 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) validation_response = validate_meal(meal)
if validation_response: if validation_response:
return validation_response return validation_response
@ -206,14 +272,19 @@ async def create_meal(meal: meals.Meal, conn: sqlite3.Connection = Depends(get_d
await conn.commit() await conn.commit()
return meal 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: 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) existing = await meals.find_meal_by_id(conn, meal_id)
if not existing: 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) validation_response = validate_meal(meal)
if validation_response: if validation_response:
@ -224,14 +295,22 @@ async def update_meal(meal_id: int, meal: meals.Meal, conn: sqlite3.Connection =
return await get_meal(meal_id, conn) 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: 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) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: 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 meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
await shopping.remove_request(conn, person, meal=meal) await shopping.remove_request(conn, person, meal=meal)
@ -239,11 +318,16 @@ async def mark_consumed(meal_id: int, consumed_date: Optional[datetime.datetime]
await conn.commit() await conn.commit()
return meal 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) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: 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 shopping.remove_request(conn, person, meal=meal)
await meals.delete_meal(conn, meal.id) 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() await conn.commit()
return meal return meal
class CurrentShoppingList(BaseModel): class CurrentShoppingList(BaseModel):
outstanding_items: List[shopping.ShoppingListItem] outstanding_items: List[shopping.ShoppingListItem]
requested_meals: 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") @app.get("/api/shopping/current")
async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList: async def get_current_shopping_list(
outstanding_requests, purchased_requests, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(conn) 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} 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 # 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] additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
if additional_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( return CurrentShoppingList(
outstanding_items=outstanding_requests, outstanding_items=outstanding_requests,
@ -280,41 +382,73 @@ async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db))
meals_lookup=meals_lookup, meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup, shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup, ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup recipes_lookup=recipes_lookup,
) )
class PurchasedShoppingList(BaseModel): class PurchasedShoppingList(BaseModel):
list: shopping.ShoppingList list: shopping.ShoppingList
meals_lookup: Dict[int, meals.Meal] = {} meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
ingredients_lookup: Dict[int, ingredients.Ingredient] = {} ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
recipes_lookup: Dict[int, recipes.Recipe] = {} 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) shopping_list = await shopping.load_shopping_list(conn, list_id)
if not shopping_list: if not shopping_list:
return JSONResponse(status_code=404, content={'message': 'Shopping list not found'}) 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,
)
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/") @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: async def purchase_ingredients(
shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name) 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 shopping.purchase(conn, shopping_list)
await conn.commit() await conn.commit()
result = PurchasedShoppingList(list=shopping_list) 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 return result
@app.get("/api/shopping/current/me/ingredients") @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) return await shopping.get_persons_requests(conn, person.id)
@app.post("/api/shopping/current/me/ingredients") @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: def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
return a.id == b.id or a.line == b.line return a.id == b.id or a.line == b.line
@ -333,31 +467,45 @@ async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sq
await conn.commit() await conn.commit()
return await get_my_shopping_list(conn, person) return await get_my_shopping_list(conn, person)
class MealIdWrapper(BaseModel): class MealIdWrapper(BaseModel):
meal_id: int 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) meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal: 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) response = await shopping.request(conn, person, meal=meal)
await conn.commit() await conn.commit()
return response 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) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: 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 shopping.remove_request(conn, person, meal=meal)
await conn.commit() await conn.commit()
return {} return {}
@app.get("/api/persons") @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) query = persons.search_by_name(conn, q) if q else persons.get_all(conn)
result = [] result = []
async for person in query: async for person in query:
@ -365,48 +513,56 @@ async def get_persons(q: str = None, conn: sqlite3.Connection = Depends(get_db))
return result return result
@app.post("/api/persons") @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 persons.insert_person(conn, person)
await conn.commit() await conn.commit()
return person return person
class LoginBody(BaseModel): class LoginBody(BaseModel):
username: str 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) person = await persons.get_by_name(conn, data.username)
if not person: 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 = 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 return response
@app.post('/api/auth/refresh')
@app.post("/api/auth/refresh")
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
return user return user
if os.environ.get('DOOF_PROD', False):
if os.environ.get("DOOF_PROD", False):
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
else: else:
# Proxy the request to the frontend development server # Proxy the request to the frontend development server
import httpx
from starlette.background import BackgroundTask
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from starlette.background import BackgroundTask
import httpx
client = httpx.AsyncClient(base_url="http://localhost:8080/") client = httpx.AsyncClient(base_url="http://localhost:8080/")
async def _reverse_proxy(request: Request): async def _reverse_proxy(request: Request):
url = httpx.URL(path=request.url.path, url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
query=request.url.query.encode("utf-8")) rp_req = client.build_request(
rp_req = client.build_request(request.method, url, request.method, url, headers=request.headers.raw, content=request.stream()
headers=request.headers.raw, )
content=request.stream())
rp_resp = await client.send(rp_req, stream=True) rp_resp = await client.send(rp_req, stream=True)
return StreamingResponse( return StreamingResponse(
rp_resp.aiter_raw(), rp_resp.aiter_raw(),

View file

@ -1 +1,21 @@
from meals.db import * 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

View file

@ -1,13 +1,18 @@
from typing import AsyncIterator, List, ClassVar, Optional import datetime
from pydantic import BaseModel from typing import AsyncIterator, ClassVar, List, Optional
from ingredients import Ingredient, insert_ingredient, find_ingredients_by_meal_id, delete_ingredients_by_meal_id
from recipes import Recipe, row_to_recipe, load_recipe_ingredients from pydantic import BaseModel, Field
import persons import persons
from ingredients import (
Ingredient,
delete_ingredients_by_meal_id,
find_ingredients_by_meal_id,
insert_ingredient,
)
from persons import Person from persons import Person
from recipes import Recipe, load_recipe_ingredients, row_to_recipe
import datetime
class MealRecipe(BaseModel): class MealRecipe(BaseModel):
meal_id: int meal_id: int
@ -16,89 +21,113 @@ class MealRecipe(BaseModel):
recipe: Optional[Recipe] = None recipe: Optional[Recipe] = None
class Meal(BaseModel): 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 id: int = -1
suggested_date: datetime.datetime suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None consumed_date: Optional[datetime.datetime] = None
chefs: List[Person] = [] chefs: List[Person] = Field(default_factory=list)
cleanup: List[Person] = [] cleanup: List[Person] = Field(default_factory=list)
consumers: List[Person] = [] consumers: List[Person] = Field(default_factory=list)
recipes: List[MealRecipe] = [] recipes: List[MealRecipe] = Field(default_factory=list)
extra_ingredients: List[Ingredient] = [] extra_ingredients: List[Ingredient] = Field(default_factory=list)
# Set from shopping list # Set from shopping list
purchase_date: Optional[datetime.datetime] = None purchase_date: Optional[datetime.datetime] = None
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Meal ( CREATE TABLE IF NOT EXISTS Meal (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
suggested_date DATETIME, suggested_date DATETIME,
consumed_date DATETIME DEFAULT NULL, consumed_date DATETIME DEFAULT NULL,
deleted_date DATETIME DEFAULT NULL, deleted_date DATETIME DEFAULT NULL,
purchase_date DATETIME DEFAULT NULL purchase_date DATETIME DEFAULT NULL
);''') );"""
)
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS MealParticipant ( CREATE TABLE IF NOT EXISTS MealParticipant (
meal_id INTEGER, meal_id INTEGER,
person_id INTEGER, person_id INTEGER,
role TEXT, role TEXT,
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(person_id) REFERENCES Person(id) FOREIGN KEY(person_id) REFERENCES Person(id)
);''') );"""
)
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS MealRecipe ( CREATE TABLE IF NOT EXISTS MealRecipe (
meal_id INTEGER, meal_id INTEGER,
recipe_id INTEGER, recipe_id INTEGER,
servings REAL, servings REAL,
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(recipe_id) REFERENCES Recipe(id) FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
);''') );"""
)
async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str): 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) INSERT INTO MealParticipant (meal_id, person_id, role)
VALUES (?, ?, ?) 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): async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str):
await conn.execute(''' await conn.execute(
"""
DELETE FROM MealParticipant DELETE FROM MealParticipant
WHERE meal_id = ? AND role = ? WHERE meal_id = ? AND role = ?
''', (meal_id, role)) """,
(meal_id, role),
)
for person in participants: for person in participants:
await insert_meal_participant(conn, meal_id, person.id, role) await insert_meal_participant(conn, meal_id, person.id, role)
async def insert_meal_recipe(conn, r: MealRecipe): async def insert_meal_recipe(conn, r: MealRecipe):
if r.meal_id < 0: 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: if r.recipe_id < 0 and r.recipe:
r.recipe_id = r.recipe.id r.recipe_id = r.recipe.id
if r.recipe_id < 0: if r.recipe_id < 0:
raise ValueError('Recipe must be inserted before meal') raise ValueError("Recipe must be inserted before meal")
await conn.execute(''' await conn.execute(
"""
INSERT INTO MealRecipe (meal_id, recipe_id, servings) INSERT INTO MealRecipe (meal_id, recipe_id, servings)
VALUES (?, ?, ?) 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 def insert_meal(conn, meal: Meal):
async with conn.execute(''' async with conn.execute(
"""
INSERT INTO Meal (suggested_date) INSERT INTO Meal (suggested_date)
VALUES (?) VALUES (?)
''', (meal.suggested_date.isoformat(),)) as cursor: """,
(meal.suggested_date.isoformat(),),
) as cursor:
meal.id = cursor.lastrowid meal.id = cursor.lastrowid
await sync_meal_participants(conn, meal.id, meal.chefs, 'chef') 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.cleanup, "cleanup")
await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer') await sync_meal_participants(conn, meal.id, meal.consumers, "consumer")
for meal_recipe in meal.recipes: for meal_recipe in meal.recipes:
meal_recipe.meal_id = meal.id meal_recipe.meal_id = meal.id
@ -107,12 +136,16 @@ async def insert_meal(conn, meal: Meal):
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) 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 SELECT {','.join(Meal.KEYS)} FROM Meal
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
''', (meal_id,)) as cursor: """,
(meal_id,),
) as cursor:
async for row in 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)})
@ -120,54 +153,80 @@ async def find_meal_by_id(conn, meal_id: int) -> Meal:
await load_recipes(conn, meal) await load_recipes(conn, meal)
await load_extra_ingredients(conn, meal) await load_extra_ingredients(conn, meal)
return 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 SELECT {','.join(Meal.KEYS)} FROM Meal
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL 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: 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 def load_participants(conn, meal: Meal) -> None:
async with conn.execute(f''' async with conn.execute(
"""
SELECT person_id, role FROM MealParticipant SELECT person_id, role FROM MealParticipant
WHERE meal_id = ? WHERE meal_id = ?
''', (meal.id,)) as cursor: """,
(meal.id,),
) as cursor:
async for row in cursor: async for row in cursor:
person = await persons.get_by_id(conn, row[0]) person = await persons.get_by_id(conn, row[0])
if row[1] == 'chef': if row[1] == "chef":
if person:
meal.chefs.append(person) meal.chefs.append(person)
elif row[1] == 'cleanup': elif row[1] == "cleanup":
if person:
meal.cleanup.append(person) meal.cleanup.append(person)
elif row[1] == 'consumer': elif row[1] == "consumer":
if person:
meal.consumers.append(person) meal.consumers.append(person)
else: else:
raise Exception(f'Unknown role: {row[1]}') raise Exception(f"Unknown role: {row[1]}")
async def load_recipes(conn, meal: Meal) -> None: 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 SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
FROM Recipe FROM Recipe
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
WHERE MealRecipe.meal_id = ? WHERE MealRecipe.meal_id = ?
''', (meal.id,)) as cursor: """,
(meal.id,),
) as cursor:
async for row in 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) 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 def load_extra_ingredients(conn, meal: Meal) -> None:
async for ingredient in find_ingredients_by_meal_id(conn, meal.id): async for ingredient in find_ingredients_by_meal_id(conn, meal.id):
meal.extra_ingredients.append(ingredient) meal.extra_ingredients.append(ingredient)
async def delete_meal(conn, meal_id: int) -> None: async def delete_meal(conn, meal_id: int) -> None:
await conn.execute(''' await conn.execute(
"""
UPDATE Meal UPDATE Meal
SET deleted_date = ? SET deleted_date = ?
WHERE id = ? 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: async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredient]) -> None:
await delete_ingredients_by_meal_id(conn, meal_id) 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) 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 DELETE FROM MealRecipe
WHERE meal_id = ? WHERE meal_id = ?
''', (meal_id,)) """,
(meal_id,),
)
for meal_recipe in recipes: for meal_recipe in recipes:
if meal_recipe.meal_id >= 0 and meal_recipe.meal_id != meal_id: 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 meal_recipe.meal_id = meal_id
await insert_meal_recipe(conn, meal_recipe) await insert_meal_recipe(conn, meal_recipe)
async def update_meal(conn, meal: Meal) -> None: async def update_meal(conn, meal: Meal) -> None:
await conn.execute(''' await conn.execute(
"""
UPDATE Meal UPDATE Meal
SET suggested_date = ? SET suggested_date = ?
WHERE id = ? 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.chefs, "chef")
await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup') 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.consumers, "consumer")
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients) await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
await sync_meal_recipes(conn, meal.id, meal.recipes) await sync_meal_recipes(conn, meal.id, meal.recipes)
async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None: async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None:
meal.consumed_date = date meal.consumed_date = date
await conn.execute(''' await conn.execute(
"""
UPDATE Meal UPDATE Meal
SET consumed_date = ? SET consumed_date = ?
WHERE id = ? WHERE id = ?
''', (date.isoformat(), meal.id)) """,
(date.isoformat(), meal.id),
)
async def mark_purchased(conn, meal: Meal) -> Meal: async def mark_purchased(conn, meal: Meal) -> Meal:
meal.purchase_date = datetime.datetime.now().astimezone() meal.purchase_date = datetime.datetime.now().astimezone()
await conn.execute(''' await conn.execute(
"""
UPDATE Meal UPDATE Meal
SET purchase_date = ? SET purchase_date = ?
WHERE id = ? WHERE id = ?
''', (meal.purchase_date.isoformat(), meal.id)) """,
(meal.purchase_date.isoformat(), meal.id),
)
return meal return meal

View file

@ -1 +1,9 @@
from persons.db import * 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,
)

View file

@ -1,63 +1,86 @@
from typing import AsyncIterator, ClassVar, List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from typing import AsyncIterator, ClassVar, List
class Person(BaseModel): class Person(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'name'] KEYS: ClassVar[List[str]] = ["id", "name"]
id: int = -1 id: int = -1
name: str name: str
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Person ( CREATE TABLE IF NOT EXISTS Person (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT UNIQUE name TEXT UNIQUE
);''') );"""
)
async def search_by_name(conn, name: str) -> AsyncIterator[Person]: async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
async with conn.execute(''' async with conn.execute(
"""
SELECT id, name SELECT id, name
FROM Person FROM Person
WHERE name LIKE ? WHERE name LIKE ?
''', (f'%{name}%',)) as cursor: """,
(f"%{name}%",),
) as cursor:
async for row in cursor: async for row in cursor:
yield Person(id=row[0], name=row[1]) yield Person(id=row[0], name=row[1])
async def get_by_name(conn, name: str) -> Person: async def get_by_name(conn, name: str) -> Optional[Person]:
cursor = await conn.execute(''' cursor = await conn.execute(
"""
SELECT id, name SELECT id, name
FROM Person FROM Person
WHERE name = ? WHERE name = ?
''', (name,)) """,
(name,),
)
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: if not row:
return None return None
return Person(id=row[0], name=row[1]) 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 SELECT id, name
FROM Person FROM Person
WHERE id = ? WHERE id = ?
''', (id,)) """,
(id,),
)
row = await cursor.fetchone() row = await cursor.fetchone()
if not row: if not row:
return None return None
return Person(id=row[0], name=row[1]) return Person(id=row[0], name=row[1])
async def get_all(conn) -> AsyncIterator[Person]: async def get_all(conn) -> AsyncIterator[Person]:
async with conn.execute(''' async with conn.execute(
"""
SELECT id, name SELECT id, name
FROM Person FROM Person
''') as cursor: """
) as cursor:
async for row in cursor: async for row in cursor:
yield Person(id=row[0], name=row[1]) yield Person(id=row[0], name=row[1])
async def insert_person(conn, person: Person) -> Person: async def insert_person(conn, person: Person) -> Person:
cursor = await conn.execute(''' cursor = await conn.execute(
"""
INSERT INTO Person (name) INSERT INTO Person (name)
VALUES (?) VALUES (?)
''', (person.name,)) """,
(person.name,),
)
person.id = cursor.lastrowid person.id = cursor.lastrowid
return person return person

View file

@ -1,20 +1,28 @@
import json 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(): for shop_code, shop_scraper in SCRAPERS.items():
product_id = shop_scraper.get_product_id(link) product_id = shop_scraper.get_product_id(link)
if product_id: if product_id:
return shop_code, product_id return shop_code, product_id
return None, None return None, None
async def add_missing_tags(conn, product: Product, tags: List[str]): async def add_missing_tags(conn, product: Product, tags: List[str]):
existing_tags = {tag async for tag in get_tags(conn, product)} existing_tags = {tag async for tag in get_tags(conn, product)}
remaining_tags = set(tags) - existing_tags remaining_tags = set(tags) - existing_tags
@ -26,9 +34,10 @@ async def add_missing_tags(conn, product: Product, tags: List[str]):
return product 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) shop_code, product_id = _get_shop_key(url)
if not product_id: if not shop_code or not product_id:
return None return None
existing = await find_product_by_key(conn, shop_code, product_id) existing = await find_product_by_key(conn, shop_code, product_id)
@ -36,30 +45,34 @@ async def get_or_create(conn, url: str, tags: List[str]) -> Product:
await add_missing_tags(conn, existing, tags) await add_missing_tags(conn, existing, tags)
return existing return existing
product_data, raw_response = await SCRAPERS[shop_code].scrape(product_id) scraper = SCRAPERS[shop_code]
product = Product( product_data, raw_response = await scraper.scrape(product_id)
id=-1, product = Product(id=-1, shop_code=shop_code, product_id=product_id, link=url, **product_data)
shop_code=shop_code,
product_id=product_id,
link=url,
**product_data
)
await insert_product(conn, product, raw_response) await insert_product(conn, product, raw_response)
await add_missing_tags(conn, product, tags) await add_missing_tags(conn, product, tags)
return product return product
def _dump_json_data_to_log(data: dict, product_id: str) -> str: def _dump_json_data_to_log(data: dict, product_id: str) -> str:
import os, re import os
dir = './data/dump' import re
dir = "./data/dump"
if not os.path.exists(dir): if not os.path.exists(dir):
os.makedirs(dir) os.makedirs(dir)
prefix = f'product_{product_id}' prefix = f"product_{product_id}"
suffix = '.json' 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)] 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 id = max(file_ids) + 1 if file_ids else 0
filename = f'{prefix}{id}{suffix}' filename = f"{prefix}{id}{suffix}"
with open(os.path.join(dir, filename), 'w') as f: full_path = os.path.join(dir, filename)
with open(full_path, "w") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
return full_path

View file

@ -1,46 +1,58 @@
import re, httpx import re
from typing import Union from typing import Optional, Tuple
import httpx
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', "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": "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-Language": "en-US,en;q=0.5",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'DNT': '1', "DNT": "1",
'Sec-GPC': '1', "Sec-GPC": "1",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Upgrade-Insecure-Requests': '1', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Dest': 'document', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-Site': 'none', "Sec-Fetch-Site": "none",
'Sec-Fetch-User': '?1', "Sec-Fetch-User": "?1",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
} }
async def _get_cookies(client): 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 # 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) response = await client.get(
version = re.findall(r'202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d', response.text) "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: 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] 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: if size:
match = re.match(r'(\d+)(.*)', size) match = re.match(r"(\d+)(.*)", size)
if match: if match:
return int(match.group(1)), match.group(2) return int(match.group(1)), match.group(2)
return 1, 'items' return 1, "items"
def _get_client(): def _get_client():
return httpx.AsyncClient() return httpx.AsyncClient()
api_details = None api_details = None
async def _request_details(product_id: str) -> dict:
async def _request_details(product_id: str) -> Optional[dict]:
global api_details global api_details
async with _get_client() as client: 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) url = _get_product_details_url(api_version, product_id)
try: 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() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPError as ne: except httpx.HTTPError as ne:
@ -60,35 +74,40 @@ async def _request_details(product_id: str) -> dict:
return None return None
def _get_product_details_url(version: str, product_id: str) -> str: 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/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 # 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 # 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) match = re.match(regex, url)
if match: if match:
return match.group(1) return match.group(1)
return None 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) raw_data = await _request_details(product_id)
if raw_data is None:
raw_data = {"pageProps": {"product": {"size": "", "images": []}}}
product = raw_data['pageProps']['product'] product = raw_data["pageProps"]["product"]
quantity, unit = _get_package_size(product['size']) quantity, unit = _get_package_size(product["size"])
img_prefix = 'https://shop.coles.com.au' img_prefix = "https://shop.coles.com.au"
images = product['images'][0] images = product["images"][0]
product_data = { product_data = {
'name': product['name'], "name": product["name"],
'quantity': quantity, "quantity": quantity,
'unit': unit, "unit": unit,
'img_small': (img_prefix + images['thumb']['path']) if images else None, "img_small": (img_prefix + images["thumb"]["path"]) if images else None,
'img_large': (img_prefix + images['full']['path']) if images else None, "img_large": (img_prefix + images["full"]["path"]) if images else None,
} }
return product_data, raw_data return product_data, raw_data

View file

@ -1,11 +1,22 @@
from typing import AsyncIterator, List, ClassVar import json
from typing import AsyncIterator, ClassVar, List, Optional
from pydantic import BaseModel from pydantic import BaseModel
import json
class Product(BaseModel): class Product(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'shop_code', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large'] KEYS: ClassVar[List[str]] = [
NON_INSERT_KEYS: ClassVar[List[str]] = ['id'] "id",
"product_id",
"shop_code",
"link",
"name",
"quantity",
"unit",
"img_small",
"img_large",
]
NON_INSERT_KEYS: ClassVar[List[str]] = ["id"]
id: int = -1 id: int = -1
product_id: str product_id: str
@ -16,9 +27,13 @@ class Product(BaseModel):
unit: str unit: str
img_small: str img_small: str
img_large: str img_large: str
# Non-persisted field used in tests and insert helper
raw_data: Optional[dict] = None
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Product ( CREATE TABLE IF NOT EXISTS Product (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
product_id TEXT UNIQUE NOT NULL, product_id TEXT UNIQUE NOT NULL,
@ -30,70 +45,101 @@ async def create(conn):
img_small TEXT, img_small TEXT,
img_large TEXT, img_large TEXT,
raw_data TEXT raw_data TEXT
);''') );"""
)
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS ProductTag ( CREATE TABLE IF NOT EXISTS ProductTag (
food_item_id INTEGER, food_item_id INTEGER,
tag TEXT COLLATE NOCASE, tag TEXT COLLATE NOCASE,
PRIMARY KEY (food_item_id, tag), PRIMARY KEY (food_item_id, tag),
FOREIGN KEY (food_item_id) REFERENCES Product(id) FOREIGN KEY (food_item_id) REFERENCES Product(id)
);''') );"""
)
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]: 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 SELECT {','.join(Product.KEYS)} FROM Product
WHERE id IN ( WHERE id IN (
SELECT food_item_id FROM ProductTag SELECT food_item_id FROM ProductTag
WHERE tag = ? WHERE tag = ?
) )
''', (tag,)) as cursor: """,
(tag,),
) as cursor:
async for row in 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 SELECT {','.join(Product.KEYS)} FROM Product
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
''', (product_id,)) as cursor: """,
(product_id,),
) as cursor:
async for row in 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 SELECT {','.join(Product.KEYS)} FROM Product
WHERE shop_code = ? AND product_id = ? WHERE shop_code = ? AND product_id = ?
LIMIT 1 LIMIT 1
''', (shop_code, product_id,)) as cursor: """,
(
shop_code,
product_id,
),
) as cursor:
async for row in 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): 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_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] 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) INSERT INTO Product ({','.join(insert_keys)}, raw_data)
VALUES ({','.join(['?'] * len(insert_keys))}, ?) VALUES ({','.join(['?'] * len(insert_keys))}, ?)
''', (*insert_values, json.dumps(data))) as cursor: """,
(*insert_values, json.dumps(data)),
) as cursor:
product.id = cursor.lastrowid product.id = cursor.lastrowid
await conn.commit() await conn.commit()
async def add_tag(conn, product: Product, tag: str): async def add_tag(conn, product: Product, tag: str):
await conn.execute(''' await conn.execute(
"""
INSERT INTO ProductTag (food_item_id, tag) INSERT INTO ProductTag (food_item_id, tag)
VALUES (?, ?) VALUES (?, ?)
''', (product.id, tag)) """,
(product.id, tag),
)
await conn.commit() await conn.commit()
async def get_tags(conn, product: Product) -> AsyncIterator[str]: async def get_tags(conn, product: Product) -> AsyncIterator[str]:
async with conn.execute(''' async with conn.execute(
"""
SELECT tag FROM ProductTag SELECT tag FROM ProductTag
WHERE food_item_id = ? WHERE food_item_id = ?
''', (product.id,)) as cursor: """,
(product.id,),
) as cursor:
async for row in cursor: async for row in cursor:
yield row[0] yield row[0]

View file

@ -1,43 +1,52 @@
import re, httpx import re
from typing import Optional, Tuple
from typing import Union import httpx
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', "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": "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-Language": "en-US,en;q=0.5",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'DNT': '1', "DNT": "1",
'Sec-GPC': '1', "Sec-GPC": "1",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Upgrade-Insecure-Requests': '1', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Dest': 'document', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-Site': 'none', "Sec-Fetch-Site": "none",
'Sec-Fetch-User': '?1', "Sec-Fetch-User": "?1",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
} }
async def _get_cookies(client): 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 # 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) 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: if size:
match = re.match(r'(\d+)(.*)', size) match = re.match(r"(\d+)(.*)", size)
if match: if match:
return int(match.group(1)), match.group(2) return int(match.group(1)), match.group(2)
return 1, 'items' return 1, "items"
def _get_client() -> httpx.AsyncClient: def _get_client() -> httpx.AsyncClient:
return httpx.AsyncClient() return httpx.AsyncClient()
cached_cookies = None cached_cookies = None
async def _request_url(url: str) -> dict:
async def _request_url(url: str) -> Optional[dict]:
global cached_cookies global cached_cookies
async with _get_client() as client: async with _get_client() as client:
@ -46,7 +55,9 @@ async def _request_url(url: str) -> dict:
cookies = cached_cookies cookies = cached_cookies
try: 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() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPError as ne: except httpx.HTTPError as ne:
@ -54,27 +65,33 @@ async def _request_url(url: str) -> dict:
cached_cookies = None cached_cookies = None
return 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: def _get_product_details_url(product_id: str) -> str:
woolies_regex = r'https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*' 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) match = re.match(woolies_regex, url)
if match: if match:
return match.group(1) return match.group(1)
return None 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) details_url = _get_product_details_url(product_id)
raw_data = await _request_url(details_url) 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) quantity, unit = _get_package_size(raw_data)
product_data = { product_data = {
'name': raw_data['Product']['Name'], "name": raw_data["Product"]["Name"],
'quantity': quantity, "quantity": quantity,
'unit': unit, "unit": unit,
'img_small': raw_data['Product']['SmallImageFile'], "img_small": raw_data["Product"]["SmallImageFile"],
'img_large': raw_data['Product']['LargeImageFile'], "img_large": raw_data["Product"]["LargeImageFile"],
} }
return product_data, raw_data return product_data, raw_data

47
pyproject.toml Normal file
View file

@ -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 = ["."]

View file

@ -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 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) ldata = await _scrape_recipe_ldata(url)
if ldata: if ldata:
return await _get_recipe_from_ldata(conn, url, ldata, created_by) return await _get_recipe_from_ldata(conn, url, ldata, created_by)
return None return None
def find_yield(recipe_ldata: dict) -> int: def find_yield(recipe_ldata: dict) -> int:
if 'recipeYield' in recipe_ldata: if "recipeYield" in recipe_ldata:
yield_vals = recipe_ldata['recipeYield'] yield_vals = recipe_ldata["recipeYield"]
if not isinstance(yield_vals, list): if not isinstance(yield_vals, list):
yield_vals = [yield_vals] yield_vals = [yield_vals]
@ -25,24 +36,27 @@ def find_yield(recipe_ldata: dict) -> int:
pass pass
for val in yield_vals: for val in yield_vals:
match = re.match(r'(\d+)', val) match = re.match(r"(\d+)", val)
if match: if match:
return int(match.group(1)) return int(match.group(1))
return 4 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) ingredients = await match_existing_products(conn, ingredients)
name = ldata['name'] if 'name' in ldata else url name = ldata["name"] if "name" in ldata else url
images = ldata['image'] if 'image' in ldata else [] images = ldata["image"] if "image" in ldata else []
serves = find_yield(ldata) serves = find_yield(ldata)
if isinstance(images, list) and len(images) > 0 and isinstance(images[0], dict): 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): if isinstance(images, dict):
images = [images['url']] images = [images["url"]]
if isinstance(images, str): if isinstance(images, str):
images = [images] images = [images]

View file

@ -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 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): 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'] KEYS: ClassVar[List[str]] = [
NON_INSERT_KEYS: ClassVar[List[str]] = ['id', 'created_date', 'hidden_by_id', 'date_hidden'] "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 id: int = -1
name: str name: str
link: str link: str
serves: int serves: int
image_urls: List[str] = [] image_urls: List[str] = Field(default_factory=list)
ingredients: List[Ingredient] = [] ingredients: List[Ingredient] = Field(default_factory=list)
based_on_recipe: Optional[int] = None 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_id: Optional[int]
created_by: Optional[Person] = None created_by: Optional[Person] = None
@ -26,8 +41,10 @@ class Recipe(BaseModel):
hidden_by_id: Optional[int] = None hidden_by_id: Optional[int] = None
hidden_by: Optional[Person] = None hidden_by: Optional[Person] = None
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Recipe ( CREATE TABLE IF NOT EXISTS Recipe (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -45,64 +62,88 @@ async def create(conn):
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id) FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
FOREIGN KEY (created_by_id) REFERENCES Person(id) FOREIGN KEY (created_by_id) REFERENCES Person(id)
FOREIGN KEY (hidden_by_id) REFERENCES Person(id) FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
);''') );"""
)
def _as_insert_field(recipe: Recipe, name: str): def _as_insert_field(recipe: Recipe, name: str):
value = getattr(recipe, name) value = getattr(recipe, name)
if name == 'image_urls': if name == "image_urls":
return json.dumps(value) return json.dumps(value)
if isinstance(value, datetime.datetime): if isinstance(value, datetime.datetime):
return value.isoformat() return value.isoformat()
return value return value
async def insert_recipe(conn, recipe: Recipe): async def insert_recipe(conn, recipe: Recipe):
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS] 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] 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)}) INSERT INTO Recipe ({','.join(fields_to_insert)})
VALUES ({','.join(['?'] * len(fields_to_insert))}) VALUES ({','.join(['?'] * len(fields_to_insert))})
''' """
async with conn.execute(insert_stmt, actual_values) as cursor: async with conn.execute(insert_stmt, actual_values) as cursor:
recipe.id = cursor.lastrowid recipe.id = cursor.lastrowid
async def hide_recipe(conn, recipe_id: int, person: Person): async def hide_recipe(conn, recipe_id: int, person: Person):
await conn.execute(''' await conn.execute(
"""
UPDATE Recipe UPDATE Recipe
SET date_hidden = ?, hidden_by_id = ? SET date_hidden = ?, hidden_by_id = ?
WHERE 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} def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
d['image_urls'] = json.loads(d['image_urls']) 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) 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 SELECT {','.join(Recipe.KEYS)} FROM Recipe
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
''', (recipe_id,)) as cursor: """,
(recipe_id,),
) as cursor:
async for row in 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 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 SELECT {','.join(Recipe.KEYS)} FROM Recipe
WHERE name LIKE ? AND date_hidden IS NULL WHERE name LIKE ? AND date_hidden IS NULL
''', (f'%{name}%',)) as cursor: """,
(f"%{name}%",),
) as cursor:
async for row in 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 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 SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
''') as cursor: """
) as cursor:
async for row in 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 def load_recipe_ingredients(conn, recipe: Recipe) -> None:
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id): async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):

View file

@ -1,35 +1,39 @@
from bs4 import BeautifulSoup
import httpx
import json import json
from typing import Optional
import httpx
from bs4 import BeautifulSoup
HEADERS = { HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', "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-Language": "en-US,en;q=0.5",
'DNT': '1', "DNT": "1",
'Sec-GPC': '1', "Sec-GPC": "1",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Upgrade-Insecure-Requests': '1', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Dest': 'document', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-Site': 'none', "Sec-Fetch-Site": "none",
'Sec-Fetch-User': '?1', "Sec-Fetch-User": "?1",
'Priority': 'u=1', "Priority": "u=1",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
} }
def _is_recipe_ldata(ldata_node):
if '@type' in ldata_node: def _is_recipe_ldata(ldata_node) -> bool:
typ = ldata_node['@type'] if "@type" in ldata_node:
typ = ldata_node["@type"]
if isinstance(typ, list): if isinstance(typ, list):
typ = typ[0] typ = typ[0]
if isinstance(typ, str) and typ.lower() == 'recipe': if isinstance(typ, str) and typ.lower() == "recipe":
return True return True
return None return False
async def scrape_recipe_ldata(url: str) -> dict:
async def scrape_recipe_ldata(url: str) -> Optional[dict]:
# Load the requested URL with headers # Load the requested URL with headers
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url, headers=HEADERS, follow_redirects=True) response = await client.get(url, headers=HEADERS, follow_redirects=True)
@ -37,16 +41,16 @@ async def scrape_recipe_ldata(url: str) -> dict:
return None return None
# Extract the recipe ld+json data # Extract the recipe ld+json data
soup = BeautifulSoup(response.text, 'html.parser') soup = BeautifulSoup(response.text, "html.parser")
for ld in soup.find_all('script', type='application/ld+json'): for ld in soup.find_all("script", type="application/ld+json"):
try: try:
data = json.loads(ld.text) data = json.loads(ld.text)
# _dump_json_data_to_log(data) # _dump_json_data_to_log(data)
if _is_recipe_ldata(data): if _is_recipe_ldata(data):
return data return data
if '@graph' in data: if "@graph" in data:
for item in data['@graph']: for item in data["@graph"]:
if _is_recipe_ldata(item): if _is_recipe_ldata(item):
return item return item
@ -60,16 +64,28 @@ async def scrape_recipe_ldata(url: str) -> dict:
return None return None
# Fallback return to satisfy static analysis
return None
def _dump_json_data_to_log(data: dict) -> str: def _dump_json_data_to_log(data: dict) -> str:
import os, re import os
dir = './data/dump' import re
dir = "./data/dump"
if not os.path.exists(dir): if not os.path.exists(dir):
os.makedirs(dir) os.makedirs(dir)
prefix = 'ldata_' prefix = "ldata_"
suffix = '.json' 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)] 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 id = max(file_ids) + 1 if file_ids else 0
filename = f'{prefix}{id}{suffix}' filename = f"{prefix}{id}{suffix}"
with open(os.path.join(dir, filename), 'w') as f: full_path = os.path.join(dir, filename)
with open(full_path, "w") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
return full_path

View file

@ -1,11 +1,29 @@
from typing import Any, AsyncIterator, Dict, Iterator, List, Tuple from typing import Any, Dict, Iterable, Iterator, List, Tuple
from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request
from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients 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 {} meals_lookup = meals_lookup or {}
recipes_lookup = recipes_lookup or {} recipes_lookup = recipes_lookup or {}
ingredients_lookup = ingredients_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 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: for item in items:
# If the any item is not in the lookup, we need to add it # 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: if item.meal_id and item.meal_id not in meals_lookup:
@ -22,13 +42,29 @@ async def _ensure_lookups_populated(conn, items: List[ShoppingListItem], meals_l
if item.recipe_id and item.recipe_id not in recipes_lookup: 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) 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: 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]: 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] ids = [
return [await ingredients.find_ingredient_by_id(conn, ingredient_id) for ingredient_id in 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: for item in items:
if item.meal_id and item.meal_id in meals_lookup: if item.meal_id and item.meal_id in meals_lookup:
meal = meals_lookup[item.meal_id] meal = meals_lookup[item.meal_id]
@ -39,7 +75,7 @@ def flatten_items(items: Iterator[ShoppingListItem], meals_lookup: Dict[int, Any
meal_id=item.meal_id, meal_id=item.meal_id,
recipe_id=mealRecipe.recipe.id, recipe_id=mealRecipe.recipe.id,
person_id=item.person_id, person_id=item.person_id,
created_date=item.created_date created_date=item.created_date,
) )
for ingredient in meal.extra_ingredients: for ingredient in meal.extra_ingredients:
@ -47,12 +83,22 @@ def flatten_items(items: Iterator[ShoppingListItem], meals_lookup: Dict[int, Any
ingredient_id=ingredient.id, ingredient_id=ingredient.id,
meal_id=item.meal_id, meal_id=item.meal_id,
person_id=item.person_id, person_id=item.person_id,
created_date=item.created_date created_date=item.created_date,
) )
else: else:
yield item 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)] 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] meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0]
@ -60,14 +106,19 @@ async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[S
meals_lookup, recipes_lookup, ingredients_lookup = await to_lookups(conn, current_requests) 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] 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 = [] outstanding_items = []
purchased_items = [] purchased_items = []
flattened = list(flatten_items(current_requests, meals_lookup)) flattened = list(flatten_items(current_requests, meals_lookup))
# Now ensure that all ingredients from the flattened items are in the 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: for r in flattened:
# Meal ingredients may have already been purchased # Meal ingredients may have already been purchased
@ -79,4 +130,11 @@ async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[S
outstanding_items.append(r) 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,
)

View file

@ -1,16 +1,25 @@
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 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): 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 id: int = -1
list_id: Optional[int] = None list_id: Optional[int] = None
@ -22,36 +31,39 @@ class ShoppingListItem(BaseLinkedModel):
meal_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): class StoreEnum(str, Enum):
woolworths = 'woolworths' woolworths = "woolworths"
coles = 'coles' coles = "coles"
home = '' home = ""
class ShoppingList(BaseLinkedModel): class ShoppingList(BaseLinkedModel):
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name'] KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
id: int = -1 id: int = -1
created_date: datetime = datetime.now().astimezone() created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
store_name: StoreEnum = '' store_name: StoreEnum = StoreEnum.home
purchased_by_id: int = -1 purchased_by_id: int = -1
purchased_by: Optional[Person] = None purchased_by: Optional[Person] = None
items: List[ShoppingListItem] = [] items: List[ShoppingListItem] = Field(default_factory=list)
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS ShoppingList ( CREATE TABLE IF NOT EXISTS ShoppingList (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
created_date DATETIME NOT NULL, created_date DATETIME NOT NULL,
store_name TEXT NOT NULL, store_name TEXT NOT NULL,
purchased_by_id INTEGER, purchased_by_id INTEGER,
FOREIGN KEY(purchased_by_id) REFERENCES Person(id) FOREIGN KEY(purchased_by_id) REFERENCES Person(id)
);''') );"""
)
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS ShoppingListItem ( CREATE TABLE IF NOT EXISTS ShoppingListItem (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ingredient_id INTEGER, ingredient_id INTEGER,
@ -65,29 +77,39 @@ async def create(conn):
FOREIGN KEY(person_id) REFERENCES Person(id), FOREIGN KEY(person_id) REFERENCES Person(id),
FOREIGN KEY(meal_id) REFERENCES Meal(id), FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(recipe_id) REFERENCES Recipe(id) FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
);''') );"""
)
def validate_request(request: ShoppingListItem) -> None: def validate_request(request: ShoppingListItem) -> None:
if request.person_id < 0: 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 # A request must have either an ingredient or a meal, but not both
if not request.ingredient_id and not request.meal_id: 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: async def purchase(conn, shopping_list: ShoppingList) -> None:
if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0: 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: 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() 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) INSERT INTO ShoppingList (created_date, store_name, purchased_by_id)
VALUES (?, ?, ?) 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 shopping_list.id = cursor.lastrowid
for item in shopping_list.items: for item in shopping_list.items:
@ -95,17 +117,18 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
validate_request(item) validate_request(item)
if item.ingredient_id is None or item.ingredient_id < 0: 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 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 isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0
if not isMeal and not isPersonRequest: 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: if isPersonRequest:
# Update existing request from its null id, or throw # Update existing request from its null id, or throw
async with conn.execute(''' async with conn.execute(
"""
UPDATE ShoppingListItem UPDATE ShoppingListItem
SET list_id = ? SET list_id = ?
WHERE ingredient_id = ? WHERE ingredient_id = ?
@ -113,59 +136,94 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
AND person_id = ? AND person_id = ?
AND meal_id IS NULL AND meal_id IS NULL
AND recipe_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: 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: elif isMeal:
# Insert new request for meal # Insert new request for meal
if item.meal_id is None or item.meal_id < 0: if item.meal_id is None or item.meal_id < 0:
raise ValueError('Meal request must have a valid meal id') raise ValueError("Meal request must have a valid meal id")
async with conn.execute(''' async with conn.execute(
"""
INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date) INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date)
VALUES (?, ?, ?, ?, ?, ?) 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 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) await update_purchased_meals(conn, meal_ids)
async def update_purchased_meals(conn, meal_ids: List[int]) -> None: async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
if not meal_ids: if not meal_ids:
return 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: for meal_id in meal_ids:
meal = await find_meal_by_id(conn, meal_id) meal = await find_meal_by_id(conn, meal_id)
ingredients = {ingredient.id for recipe in meal.recipes for ingredient in recipe.recipe.ingredients} | \ if not meal:
{ingredient.id for ingredient in meal.extra_ingredients} 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 remaining_ingredients = ingredients - purchased_ingredient_ids
if not remaining_ingredients: if not remaining_ingredients:
await mark_purchased(conn, meal) 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: async def is_requested(conn, meal: Meal) -> bool:
if meal.id < 0: if meal.id < 0:
return False return False
async with conn.execute(''' async with conn.execute(
"""
SELECT COUNT(*) FROM ShoppingListItem SELECT COUNT(*) FROM ShoppingListItem
WHERE meal_id = ? AND list_id IS NULL WHERE meal_id = ? AND list_id IS NULL
''', (meal.id,)) as cursor: """,
(meal.id,),
) as cursor:
row = await cursor.fetchone() row = await cursor.fetchone()
return row[0] > 0 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: 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: 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: 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: if ingredient is not None and ingredient.id < 0:
await insert_ingredient(conn, ingredient) await insert_ingredient(conn, ingredient)
@ -173,68 +231,86 @@ async def request(conn, person: Person, ingredient: Optional[Ingredient] = None,
ingredient_id = ingredient.id if ingredient else None ingredient_id = ingredient.id if ingredient else None
meal_id = meal.id if meal else None meal_id = meal.id if meal else None
item = ShoppingListItem( item = ShoppingListItem(ingredient_id=ingredient_id, person_id=person.id, meal_id=meal_id)
ingredient_id=ingredient_id,
person_id=person.id,
meal_id=meal_id
)
validate_request(item) validate_request(item)
if meal is not None and await is_requested(conn, meal): 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) INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date)
VALUES (?, ?, ?, ?) 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 item.id = cursor.lastrowid
return item 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: if meal is not None:
async with conn.execute(''' async with conn.execute(
"""
DELETE FROM ShoppingListItem DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND meal_id = ? WHERE list_id IS NULL AND meal_id = ?
''', (meal.id,)) as cursor: """,
(meal.id,),
) as cursor:
return cursor.rowcount > 0 return cursor.rowcount > 0
elif ingredient is not None: elif ingredient is not None:
async with conn.execute(''' async with conn.execute(
"""
DELETE FROM ShoppingListItem DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ? 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 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]: 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 = f"""
SELECT {','.join(request_keys)} SELECT {','.join(request_cols)}
FROM ShoppingListItem 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: 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) cursor = await conn.execute(select + where, params)
async for row in cursor: async for row in cursor:
request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row)} request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
request = ShoppingListItem(**request_keys) request = ShoppingListItem(**request_map)
yield request yield request
async def load_shopping_list(conn, id: int) -> ShoppingList:
shopping_list = None async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
async with conn.execute(f''' shopping_list: Optional[ShoppingList] = None
async with conn.execute(
f"""
SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
''', (id,)) as cursor: """,
(id,),
) as cursor:
async for row in 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 break
@ -245,14 +321,18 @@ async def load_shopping_list(conn, id: int) -> ShoppingList:
return shopping_list return shopping_list
async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]: async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
if not meal_ids: if not meal_ids:
return return
async with conn.execute(f''' async with conn.execute(
f"""
SELECT {','.join(ShoppingListItem.KEYS)} SELECT {','.join(ShoppingListItem.KEYS)}
FROM ShoppingListItem FROM ShoppingListItem
WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) AND list_id IS NOT NULL 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: async for row in cursor:
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)}) yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})

View file

@ -2,6 +2,7 @@ import httpx
import json import json
import os import os
class RecordingAsyncClient: class RecordingAsyncClient:
def __init__(self, save_dir: str): def __init__(self, save_dir: str):
self.save_dir = save_dir self.save_dir = save_dir
@ -27,21 +28,21 @@ class RecordingAsyncClient:
"method": method, "method": method,
"url": url, "url": url,
"headers": dict(response.request.headers), "headers": dict(response.request.headers),
"content": response.request.content.decode('utf-8', errors='ignore'), "content": response.request.content.decode("utf-8", errors="ignore"),
}, },
"response": { "response": {
"status_code": response.status_code, "status_code": response.status_code,
"headers": dict(response.headers), "headers": dict(response.headers),
"content": response.text, "content": response.text,
"cookies": dict(response.cookies), "cookies": dict(response.cookies),
} },
} }
# Generate a filename based on the URL and method # Generate a filename based on the URL and method
record_file = os.path.join(self.save_dir, f"{method}_{url.replace('/', '_')}.json") record_file = os.path.join(self.save_dir, f"{method}_{url.replace('/', '_')}.json")
# Save the record to a file # 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) json.dump(record, f, indent=4)
return response return response
@ -58,10 +59,12 @@ class RecordingAsyncClient:
async def delete(self, url: str, **kwargs): async def delete(self, url: str, **kwargs):
return await self.request("DELETE", url, **kwargs) return await self.request("DELETE", url, **kwargs)
from unittest.mock import Mock from unittest.mock import Mock
import os import os
import json import json
class MockAsyncClient: class MockAsyncClient:
def __init__(self, load_dir: str): def __init__(self, load_dir: str):
self.load_dir = load_dir self.load_dir = load_dir
@ -82,32 +85,32 @@ class MockAsyncClient:
raise FileNotFoundError(f"Recorded response not found for {method} {url}") raise FileNotFoundError(f"Recorded response not found for {method} {url}")
# Load the recorded response from the file # 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) record = json.load(f)
# Create a mock response object # Create a mock response object
mock_response = Mock() mock_response = Mock()
# Mock the status code # 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 # Mock the json method to return the content as a parsed JSON
def mock_json(): def mock_json():
try: try:
return json.loads(record['response']['content']) return json.loads(record["response"]["content"])
except json.JSONDecodeError: except json.JSONDecodeError:
return record['response']['content'] return record["response"]["content"]
mock_response.json = mock_json mock_response.json = mock_json
# Mock the cookies as a dictionary # 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 the headers as a dictionary
mock_response.headers = record['response']['headers'] mock_response.headers = record["response"]["headers"]
# Mock the text attribute # Mock the text attribute
mock_response.text = record['response']['content'] mock_response.text = record["response"]["content"]
return mock_response return mock_response

View file

@ -1,28 +1,23 @@
import persons import persons
class Persons: class Persons:
jacob = persons.Person( jacob = persons.Person(id=1, name="Jacob")
id=1,
name='Jacob')
ryan = persons.Person( ryan = persons.Person(id=2, name="Ryan")
id=2,
name='Ryan')
ellie = persons.Person( ellie = persons.Person(id=3, name="Ellie")
id=3,
name='Ellie') chris = persons.Person(id=4, name="Chris")
chris = persons.Person(
id=4,
name='Chris')
import products import products
class Products: class Products:
broccoli = products.Product( broccoli = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Fresh Broccoli", name="Fresh Broccoli",
product_id="134681", product_id="134681",
quantity=1, quantity=1,
@ -35,7 +30,7 @@ class Products:
garlic_bread = products.Product( garlic_bread = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="La Famiglia Garlic Bread", name="La Famiglia Garlic Bread",
product_id="294517", product_id="294517",
quantity=1, quantity=1,
@ -48,7 +43,7 @@ class Products:
beans_round = products.Product( beans_round = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Beans Round", name="Beans Round",
product_id="134072", product_id="134072",
quantity=1, quantity=1,
@ -61,7 +56,7 @@ class Products:
western_star_unsalted_butter_chefs_choice = products.Product( western_star_unsalted_butter_chefs_choice = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Western Star Unsalted Butter Chef's Choice", name="Western Star Unsalted Butter Chef's Choice",
product_id="712251", product_id="712251",
quantity=500, quantity=500,
@ -74,7 +69,7 @@ class Products:
saxa_iodised_table_salt_shaker = products.Product( saxa_iodised_table_salt_shaker = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Saxa Iodised Table Salt Shaker", name="Saxa Iodised Table Salt Shaker",
quantity=750, quantity=750,
unit="g", unit="g",
@ -87,7 +82,7 @@ class Products:
mckenzies_pepper_black_ground = products.Product( mckenzies_pepper_black_ground = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Mckenzie's Pepper Black Ground", name="Mckenzie's Pepper Black Ground",
quantity=100, quantity=100,
unit="g", unit="g",
@ -100,7 +95,7 @@ class Products:
apple = products.Product( apple = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Apple", name="Apple",
product_id="3542", product_id="3542",
quantity=1, quantity=1,
@ -113,7 +108,7 @@ class Products:
banana = products.Product( banana = products.Product(
id=0, id=0,
shop_code='woolworths', shop_code="woolworths",
name="Banana", name="Banana",
product_id="214", product_id="214",
quantity=1, quantity=1,
@ -125,46 +120,57 @@ class Products:
) )
_tags = { _tags = {
apple.product_id: ['apple', 'fruit', 'fresh fruit'], apple.product_id: ["apple", "fruit", "fresh fruit"],
banana.product_id: ['banana', 'fruit', 'fresh fruit'], banana.product_id: ["banana", "fruit", "fresh fruit"],
broccoli.product_id: ['broccoli', 'fresh broccoli'], broccoli.product_id: ["broccoli", "fresh broccoli"],
garlic_bread.product_id: ['garlic bread', 'bread', 'garlic', 'frozen garlic bread'], garlic_bread.product_id: ["garlic bread", "bread", "garlic", "frozen garlic bread"],
beans_round.product_id: ['beans', 'green beans', 'fresh green beans', 'fresh beans'], 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'], western_star_unsalted_butter_chefs_choice.product_id: [
saxa_iodised_table_salt_shaker.product_id: ['salt', 'iodised salt', 'kosher salt'], "butter",
mckenzies_pepper_black_ground.product_id: ['pepper', 'black pepper', 'ground pepper', 'fresh ground pepper'], "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 import ingredients
class Ingredients: class Ingredients:
one_apple = ingredients.Ingredient( one_apple = ingredients.Ingredient(
id=0, id=0,
line='1 Apple', line="1 Apple",
name='Apple', name="Apple",
unit='Items', unit="Items",
quantity='1', quantity="1",
preparation='', preparation="",
product=Products.apple, product=Products.apple,
) )
broccoli_chopped_1kg = ingredients.Ingredient( broccoli_chopped_1kg = ingredients.Ingredient(
id=0, id=0,
line='1kg Broccoli, Chopped', line="1kg Broccoli, Chopped",
name='Broccoli', name="Broccoli",
unit='kg', unit="kg",
quantity='1', quantity="1",
preparation='Chopped', preparation="Chopped",
product=Products.broccoli, product=Products.broccoli,
) )
garlic_bread_1_loaf = ingredients.Ingredient( garlic_bread_1_loaf = ingredients.Ingredient(
id=0, id=0,
line='1 Loaf Garlic Bread', line="1 Loaf Garlic Bread",
name='Garlic Bread', name="Garlic Bread",
unit='Loaf', unit="Loaf",
quantity='1', quantity="1",
preparation='', preparation="",
product=Products.garlic_bread, product=Products.garlic_bread,
) )
@ -208,15 +214,19 @@ class Ingredients:
product=Products.mckenzies_pepper_black_ground, product=Products.mckenzies_pepper_black_ground,
) )
import recipes import recipes
class Recipes: class Recipes:
broccoli_soup = recipes.Recipe( broccoli_soup = recipes.Recipe(
id=0, id=0,
name='Broccoli Soup', name="Broccoli Soup",
link='https://www.bbcgoodfood.com/recipes/broccoli-soup', link="https://www.bbcgoodfood.com/recipes/broccoli-soup",
serves=4, 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], ingredients=[Ingredients.broccoli_chopped_1kg],
created_by_id=Persons.jacob.id, created_by_id=Persons.jacob.id,
) )
@ -226,14 +236,23 @@ class Recipes:
name="How to Steam Green Beans", name="How to Steam Green Beans",
link="https://www.thespruceeats.com/steamed-green-beans-3057051", link="https://www.thespruceeats.com/steamed-green-beans-3057051",
serves=4, 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"], image_urls=[
ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper], "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, created_by_id=Persons.jacob.id,
) )
from meals import db as meals_db from meals import db as meals_db
from datetime import datetime from datetime import datetime
class Meals: class Meals:
broccoli_soup_for_jacob = meals_db.Meal( broccoli_soup_for_jacob = meals_db.Meal(
id=0, id=0,
@ -243,17 +262,22 @@ class Meals:
chefs=[Persons.jacob], chefs=[Persons.jacob],
cleanup=[Persons.ryan], cleanup=[Persons.ryan],
consumers=[Persons.ellie, Persons.chris], 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], extra_ingredients=[Ingredients.garlic_bread_1_loaf],
) )
def class_fields(obj): 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): async def create_persons(conn):
for person in class_fields(Persons).values(): for person in class_fields(Persons).values():
await persons.insert_person(conn, person) await persons.insert_person(conn, person)
async def create_test_data(conn): async def create_test_data(conn):
await create_persons(conn) await create_persons(conn)
@ -271,6 +295,7 @@ async def create_test_data(conn):
for meal in class_fields(Meals).values(): for meal in class_fields(Meals).values():
await meals_db.insert_meal(conn, meal) await meals_db.insert_meal(conn, meal)
""" """
import re import re
def to_name(thing): def to_name(thing):

View file

@ -4,10 +4,12 @@ import asyncio
import tests.test_data as test_data import tests.test_data as test_data
import importlib import importlib
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
from db import connect, create from db import connect, create
import ingredients import ingredients
import ingredients.db as ingredients_db import ingredients.db as ingredients_db
@ -17,7 +19,7 @@ import units
class TestIngredient(unittest.IsolatedAsyncioTestCase): class TestIngredient(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn) await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
@ -34,7 +36,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
line="500g fresh broccoli", line="500g fresh broccoli",
unit="g", unit="g",
quantity=500.0, quantity=500.0,
preparation="chopped" preparation="chopped",
) )
self.assertEqual(ingredient.name, "Broccoli") self.assertEqual(ingredient.name, "Broccoli")
@ -46,11 +48,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
async def test_insert_ingredient(self): async def test_insert_ingredient(self):
"""Test inserting an ingredient into the database""" """Test inserting an ingredient into the database"""
ingredient = ingredients_db.Ingredient( ingredient = ingredients_db.Ingredient(
name="Garlic", name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced"
line="2 cloves garlic",
unit="Items",
quantity=2.0,
preparation="minced"
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -76,7 +74,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=product.id, product_id=product.id,
product=product product=product,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -104,7 +102,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=2.0, quantity=2.0,
preparation="", preparation="",
recipe_id=recipe_id recipe_id=recipe_id,
) )
ingredient2 = ingredients_db.Ingredient( ingredient2 = ingredients_db.Ingredient(
@ -113,7 +111,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
recipe_id=recipe_id recipe_id=recipe_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient1)
@ -140,7 +138,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="lb", unit="lb",
quantity=1.0, quantity=1.0,
preparation="diced", preparation="diced",
meal_id=meal_id meal_id=meal_id,
) )
ingredient2 = ingredients_db.Ingredient( ingredient2 = ingredients_db.Ingredient(
@ -149,7 +147,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=2.0, quantity=2.0,
preparation="", preparation="",
meal_id=meal_id meal_id=meal_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient1)
@ -175,7 +173,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="Items", unit="Items",
quantity=2.0, quantity=2.0,
preparation="sliced", preparation="sliced",
meal_id=meal_id meal_id=meal_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -202,10 +200,12 @@ import asyncio
import tests.test_data as test_data import tests.test_data as test_data
import importlib import importlib
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
from db import connect, create from db import connect, create
import ingredients import ingredients
import ingredients.db as ingredients_db import ingredients.db as ingredients_db
@ -215,7 +215,7 @@ import units
class TestIngredient(unittest.IsolatedAsyncioTestCase): class TestIngredient(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn) await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
@ -232,7 +232,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
line="500g fresh broccoli", line="500g fresh broccoli",
unit="g", unit="g",
quantity=500.0, quantity=500.0,
preparation="chopped" preparation="chopped",
) )
self.assertEqual(ingredient.name, "Broccoli") self.assertEqual(ingredient.name, "Broccoli")
@ -244,11 +244,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
async def test_insert_ingredient(self): async def test_insert_ingredient(self):
"""Test inserting an ingredient into the database""" """Test inserting an ingredient into the database"""
ingredient = ingredients_db.Ingredient( ingredient = ingredients_db.Ingredient(
name="Garlic", name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced"
line="2 cloves garlic",
unit="Items",
quantity=2.0,
preparation="minced"
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -274,7 +270,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=product.id, product_id=product.id,
product=product product=product,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -302,7 +298,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=2.0, quantity=2.0,
preparation="", preparation="",
recipe_id=recipe_id recipe_id=recipe_id,
) )
ingredient2 = ingredients_db.Ingredient( ingredient2 = ingredients_db.Ingredient(
@ -311,7 +307,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
recipe_id=recipe_id recipe_id=recipe_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient1)
@ -338,7 +334,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="lb", unit="lb",
quantity=1.0, quantity=1.0,
preparation="diced", preparation="diced",
meal_id=meal_id meal_id=meal_id,
) )
ingredient2 = ingredients_db.Ingredient( ingredient2 = ingredients_db.Ingredient(
@ -347,7 +343,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="cups", unit="cups",
quantity=2.0, quantity=2.0,
preparation="", preparation="",
meal_id=meal_id meal_id=meal_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient1)
@ -373,7 +369,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="Items", unit="Items",
quantity=2.0, quantity=2.0,
preparation="sliced", preparation="sliced",
meal_id=meal_id meal_id=meal_id,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -401,7 +397,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
unit="Items", unit="Items",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=-1 product_id=-1,
) )
await ingredients_db.insert_ingredient(self.conn, ingredient) await ingredients_db.insert_ingredient(self.conn, ingredient)
@ -412,7 +408,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
class TestIngredientParsing(unittest.IsolatedAsyncioTestCase): class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn) await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
@ -429,7 +425,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
"just a url https://example.com", "just a url https://example.com",
"no quantity https://example.com", "no quantity https://example.com",
"", "",
"abc https://example.com" "abc https://example.com",
] ]
for invalid_link in invalid_links: for invalid_link in invalid_links:
@ -455,13 +451,17 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
# Test the regex pattern used in parse_ingredient_from_link # Test the regex pattern used in parse_ingredient_from_link
test_cases = [ test_cases = [
("2 https://example.com", "2", "https://example.com"), ("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"), ("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: 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: if match:
quantity = int(match.group(1)) if match.group(1) else 1 quantity = int(match.group(1)) if match.group(1) else 1
url = match.group(2) url = match.group(2)
@ -494,7 +494,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
class TestIngredientMatching(unittest.IsolatedAsyncioTestCase): class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn) await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
@ -504,8 +504,6 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
await self.conn.close() await self.conn.close()
return await super().asyncTearDown() return await super().asyncTearDown()
async def test_match_existing_products_with_real_data(self): async def test_match_existing_products_with_real_data(self):
"""Test matching ingredients to existing products using real operations""" """Test matching ingredients to existing products using real operations"""
# Setup: Create and insert a product with tags # Setup: Create and insert a product with tags
@ -515,11 +513,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
# Create ingredients without products # Create ingredients without products
ingredient1 = ingredients_db.Ingredient( ingredient1 = ingredients_db.Ingredient(
name="broccoli", name="broccoli", line="1 piece broccoli", unit="Items", quantity=1.0, preparation=""
line="1 piece broccoli",
unit="Items",
quantity=1.0,
preparation=""
) )
ingredient2 = ingredients_db.Ingredient( ingredient2 = ingredients_db.Ingredient(
@ -527,7 +521,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
line="1 piece unknown vegetable", line="1 piece unknown vegetable",
unit="Items", unit="Items",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
ingredients_list = [ingredient1, ingredient2] ingredients_list = [ingredient1, ingredient2]
@ -552,7 +546,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product=product, product=product,
product_id=product.id product_id=product.id,
) )
ingredients_list = [ingredient] ingredients_list = [ingredient]
@ -569,17 +563,23 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
async def test_ingredient_keys_constant(self): async def test_ingredient_keys_constant(self):
"""Test that the KEYS constant contains expected fields""" """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) self.assertEqual(ingredients_db.Ingredient.KEYS, expected_keys)
async def test_ingredient_default_values(self): async def test_ingredient_default_values(self):
"""Test ingredient default values""" """Test ingredient default values"""
ingredient = ingredients_db.Ingredient( ingredient = ingredients_db.Ingredient(
name="Test", name="Test", line="Test line", unit="Items", quantity=1.0, preparation=""
line="Test line",
unit="Items",
quantity=1.0,
preparation=""
) )
self.assertEqual(ingredient.id, -1) self.assertEqual(ingredient.id, -1)
@ -589,5 +589,5 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(ingredient.product) self.assertIsNone(ingredient.product)
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -7,10 +7,12 @@ from fastapi.testclient import TestClient
import tests.test_data as test_data import tests.test_data as test_data
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
from db import connect, create from db import connect, create
import main import main
import meals import meals
@ -28,7 +30,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
# Use in-memory database for testing # Use in-memory database for testing
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -75,21 +77,23 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
response = self.client.get("/api/recipes") response = self.client.get("/api/recipes")
recipes_data = response.json() recipes_data = response.json()
if recipes_data: if recipes_data:
recipe_id = recipes_data[0]['id'] recipe_id = recipes_data[0]["id"]
response = self.client.get(f"/api/recipes/{recipe_id}") response = self.client.get(f"/api/recipes/{recipe_id}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
recipe_data = response.json() 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): def test_get_recipe_by_id_not_found(self):
"""Test getting a recipe that doesn't exist""" """Test getting a recipe that doesn't exist"""
response = self.client.get("/api/recipes/99999") response = self.client.get("/api/recipes/99999")
self.assertEqual(response.status_code, 404) 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): def test_parse_ingredients(self):
"""Test parsing ingredient strings""" """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) self.assertEqual(response.status_code, 200)
ingredients_data = response.json() ingredients_data = response.json()
self.assertIsInstance(ingredients_data, list) 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) # Use a URL that would be recognized by the scrapers (woolworths format)
product_data = { product_data = {
"url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product", "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) response = self.client.post("/api/products", json=product_data)
# This might fail if the scraper can't actually scrape the URL # 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""" """Test getting a meal that doesn't exist"""
response = self.client.get("/api/meals/99999") response = self.client.get("/api/meals/99999")
self.assertEqual(response.status_code, 404) 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): def test_create_meal_invalid_no_chefs(self):
"""Test creating a meal without chefs (should fail validation)""" """Test creating a meal without chefs (should fail validation)"""
@ -131,11 +135,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 1, "name": "Ryan"}], "cleanup": [{"id": 1, "name": "Ryan"}],
"consumers": [{"id": 1, "name": "Ellie"}], "consumers": [{"id": 1, "name": "Ellie"}],
"recipes": [], "recipes": [],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_invalid_no_cleanup(self):
"""Test creating a meal without cleanup people (should fail validation)""" """Test creating a meal without cleanup people (should fail validation)"""
@ -146,11 +150,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [], "cleanup": [],
"consumers": [{"id": 1, "name": "Ellie"}], "consumers": [{"id": 1, "name": "Ellie"}],
"recipes": [], "recipes": [],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_invalid_no_consumers(self):
"""Test creating a meal without consumers (should fail validation)""" """Test creating a meal without consumers (should fail validation)"""
@ -161,11 +165,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [], "consumers": [],
"recipes": [], "recipes": [],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_invalid_no_recipes_or_ingredients(self):
"""Test creating a meal without recipes or ingredients (should fail validation)""" """Test creating a meal without recipes or ingredients (should fail validation)"""
@ -176,11 +180,13 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [], "recipes": [],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_invalid_duplicate_chefs(self):
"""Test creating a meal with duplicate chefs (should fail validation)""" """Test creating a meal with duplicate chefs (should fail validation)"""
@ -191,11 +197,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_invalid_zero_servings(self):
"""Test creating a meal with zero servings (should fail validation)""" """Test creating a meal with zero servings (should fail validation)"""
@ -206,11 +212,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_create_meal_valid(self):
"""Test creating a valid meal""" """Test creating a valid meal"""
@ -221,15 +227,15 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.post("/api/meals", json=meal_data) response = self.client.post("/api/meals", json=meal_data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
created_meal = response.json() created_meal = response.json()
self.assertGreater(created_meal['id'], 0) self.assertGreater(created_meal["id"], 0)
self.assertEqual(len(created_meal['chefs']), 1) self.assertEqual(len(created_meal["chefs"]), 1)
self.assertEqual(len(created_meal['cleanup']), 1) self.assertEqual(len(created_meal["cleanup"]), 1)
self.assertEqual(len(created_meal['consumers']), 1) self.assertEqual(len(created_meal["consumers"]), 1)
def test_update_meal_id_mismatch(self): def test_update_meal_id_mismatch(self):
"""Test updating a meal with mismatched IDs""" """Test updating a meal with mismatched IDs"""
@ -240,11 +246,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}], "recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.put("/api/meals/123", json=meal_data) response = self.client.put("/api/meals/123", json=meal_data)
self.assertEqual(response.status_code, 400) 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): def test_update_meal_not_found(self):
"""Test updating a meal that doesn't exist""" """Test updating a meal that doesn't exist"""
@ -255,11 +261,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}], "recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
response = self.client.put("/api/meals/99999", json=meal_data) response = self.client.put("/api/meals/99999", json=meal_data)
self.assertEqual(response.status_code, 404) 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): def test_delete_meal_not_found(self):
"""Test deleting a meal that doesn't exist""" """Test deleting a meal that doesn't exist"""
@ -272,7 +278,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
try: try:
response = self.client.delete("/api/meals/99999") response = self.client.delete("/api/meals/99999")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertIn('Meal not found', response.json()['message']) self.assertIn("Meal not found", response.json()["message"])
finally: finally:
# Clean up the override # Clean up the override
if main.cookie_person in main.app.dependency_overrides: if main.cookie_person in main.app.dependency_overrides:
@ -283,23 +289,23 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
response = self.client.get("/api/shopping/current") response = self.client.get("/api/shopping/current")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_data = response.json() shopping_data = response.json()
self.assertIn('outstanding_items', shopping_data) self.assertIn("outstanding_items", shopping_data)
self.assertIn('requested_meals', shopping_data) self.assertIn("requested_meals", shopping_data)
self.assertIn('purchased_items', shopping_data) self.assertIn("purchased_items", shopping_data)
def test_get_shopping_list_by_id(self): def test_get_shopping_list_by_id(self):
"""Test getting a shopping list by ID that doesn't exist""" """Test getting a shopping list by ID that doesn't exist"""
response = self.client.get("/api/shopping/1") response = self.client.get("/api/shopping/1")
# Should return 404 when shopping list is not found # Should return 404 when shopping list is not found
self.assertEqual(response.status_code, 404) 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): async def test_get_shopping_list_by_id_exists(self):
"""Test getting a shopping list that exists""" """Test getting a shopping list that exists"""
# First create a product and ingredient # First create a product and ingredient
product = products.Product( product = products.Product(
id=-1, id=-1,
shop_code='test', shop_code="test",
name="Test Product", name="Test Product",
product_id="test_123", product_id="test_123",
quantity=1, quantity=1,
@ -318,19 +324,21 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
unit="item", unit="item",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=product.id product_id=product.id,
) )
await ingredients.insert_ingredient(self.conn, ingredient) await ingredients.insert_ingredient(self.conn, ingredient)
# Create a request using the proper workflow # 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) # Create a shopping list and purchase it (which will include the requested item)
shopping_list = shopping.ShoppingList( shopping_list = shopping.ShoppingList(
id=-1, id=-1,
purchased_by=test_data.Persons.jacob, purchased_by=test_data.Persons.jacob,
store_name="woolworths", 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) # Purchase the shopping list (which creates it in the database)
@ -340,13 +348,13 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
response = self.client.get(f"/api/shopping/{shopping_list.id}") response = self.client.get(f"/api/shopping/{shopping_list.id}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_data = response.json() shopping_data = response.json()
self.assertIn('list', shopping_data) self.assertIn("list", shopping_data)
self.assertEqual(shopping_data['list']['id'], shopping_list.id) self.assertEqual(shopping_data["list"]["id"], shopping_list.id)
self.assertEqual(shopping_data['list']['store_name'], "woolworths") self.assertEqual(shopping_data["list"]["store_name"], "woolworths")
# Verify that lookup tables are present # Verify that lookup tables are present
self.assertIn('ingredients_lookup', shopping_data) self.assertIn("ingredients_lookup", shopping_data)
self.assertIn('meals_lookup', shopping_data) self.assertIn("meals_lookup", shopping_data)
self.assertIn('recipes_lookup', shopping_data) self.assertIn("recipes_lookup", shopping_data)
def test_get_persons_no_query(self): def test_get_persons_no_query(self):
"""Test getting all persons without search query""" """Test getting all persons without search query"""
@ -365,15 +373,12 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
def test_create_person(self): def test_create_person(self):
"""Test creating a new person""" """Test creating a new person"""
person_data = { person_data = {"id": -1, "name": "Test Person"}
"id": -1,
"name": "Test Person"
}
response = self.client.post("/api/persons", json=person_data) response = self.client.post("/api/persons", json=person_data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
created_person = response.json() created_person = response.json()
self.assertGreater(created_person['id'], 0) self.assertGreater(created_person["id"], 0)
self.assertEqual(created_person['name'], "Test Person") self.assertEqual(created_person["name"], "Test Person")
def test_login_person_exists(self): def test_login_person_exists(self):
"""Test login with existing person""" """Test login with existing person"""
@ -381,14 +386,14 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
response = self.client.post("/api/auth/login", json=login_data) response = self.client.post("/api/auth/login", json=login_data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
person_data = response.json() person_data = response.json()
self.assertEqual(person_data['name'], "Jacob") self.assertEqual(person_data["name"], "Jacob")
def test_login_person_not_found(self): def test_login_person_not_found(self):
"""Test login with non-existent person""" """Test login with non-existent person"""
login_data = {"username": "NonExistentUser"} login_data = {"username": "NonExistentUser"}
response = self.client.post("/api/auth/login", json=login_data) response = self.client.post("/api/auth/login", json=login_data)
self.assertEqual(response.status_code, 404) 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): class TestMainHelperFunctions(unittest.TestCase):
@ -399,7 +404,7 @@ class TestMainHelperFunctions(unittest.TestCase):
persons_list = [ persons_list = [
persons.Person(id=1, name="Jacob"), persons.Person(id=1, name="Jacob"),
persons.Person(id=2, name="Ryan"), persons.Person(id=2, name="Ryan"),
persons.Person(id=3, name="Ellie") persons.Person(id=3, name="Ellie"),
] ]
duplicates = main.get_duplicates(persons_list) duplicates = main.get_duplicates(persons_list)
self.assertEqual(len(duplicates), 0) self.assertEqual(len(duplicates), 0)
@ -410,7 +415,7 @@ class TestMainHelperFunctions(unittest.TestCase):
persons.Person(id=1, name="Jacob"), persons.Person(id=1, name="Jacob"),
persons.Person(id=2, name="Ryan"), persons.Person(id=2, name="Ryan"),
persons.Person(id=1, name="Jacob"), # Duplicate 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) duplicates = main.get_duplicates(persons_list)
self.assertEqual(len(duplicates), 1) self.assertEqual(len(duplicates), 1)
@ -425,7 +430,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[persons.Person(id=2, name="Ryan")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNone(result) self.assertIsNone(result)
@ -439,7 +444,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[persons.Person(id=2, name="Ryan")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -454,7 +459,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[], cleanup=[],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -469,7 +474,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[persons.Person(id=2, name="Ryan")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[], consumers=[],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -484,7 +489,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[persons.Person(id=2, name="Ryan")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[], recipes=[],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -497,12 +502,12 @@ class TestMainHelperFunctions(unittest.TestCase):
suggested_date=datetime(2024, 6, 1, 18, 0), suggested_date=datetime(2024, 6, 1, 18, 0),
chefs=[ chefs=[
persons.Person(id=1, name="Jacob"), 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")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)], recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -517,7 +522,7 @@ class TestMainHelperFunctions(unittest.TestCase):
cleanup=[persons.Person(id=2, name="Ryan")], cleanup=[persons.Person(id=2, name="Ryan")],
consumers=[persons.Person(id=3, name="Ellie")], consumers=[persons.Person(id=3, name="Ellie")],
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=0)], # Zero servings recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=0)], # Zero servings
extra_ingredients=[] extra_ingredients=[],
) )
result = main.validate_meal(meal) result = main.validate_meal(meal)
self.assertIsNotNone(result) self.assertIsNotNone(result)
@ -529,7 +534,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
# Use in-memory database for testing # Use in-memory database for testing
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -577,9 +582,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"line": "1 cup test ingredient", "line": "1 cup test ingredient",
"unit": "cup", "unit": "cup",
"quantity": 1.0, "quantity": 1.0,
"preparation": "" "preparation": "",
} }
] ],
} }
response = self.client.post("/api/recipes", json=recipe_data) response = self.client.post("/api/recipes", json=recipe_data)
# Due to authentication dependency issues, this will likely return 422 # Due to authentication dependency issues, this will likely return 422
@ -605,7 +610,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"link": "https://example.com/test-recipe", "link": "https://example.com/test-recipe",
"serves": 4, "serves": 4,
"created_by_id": 1, # Add required field "created_by_id": 1, # Add required field
"ingredients": [] "ingredients": [],
} }
response = self.client.post("/api/recipes", json=recipe_data) response = self.client.post("/api/recipes", json=recipe_data)
# Due to authentication dependency issues, this will likely return 422 # Due to authentication dependency issues, this will likely return 422
@ -633,16 +638,17 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"cleanup": [{"id": 2, "name": "Ryan"}], "cleanup": [{"id": 2, "name": "Ryan"}],
"consumers": [{"id": 3, "name": "Ellie"}], "consumers": [{"id": 3, "name": "Ellie"}],
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}], "recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
"extra_ingredients": [] "extra_ingredients": [],
} }
create_response = self.client.post("/api/meals", json=meal_data) 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 # Try to mark as consumed with invalid timezone
response = self.client.post(f"/api/meals/{meal_id}/consumed", response = self.client.post(
params={"consumed_date": "2024-06-01T19:00:00"}) # No timezone f"/api/meals/{meal_id}/consumed", params={"consumed_date": "2024-06-01T19:00:00"}
) # No timezone
self.assertEqual(response.status_code, 400) 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: finally:
# Clean up the override # Clean up the override
if main.cookie_person in main.app.dependency_overrides: if main.cookie_person in main.app.dependency_overrides:
@ -660,7 +666,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
request_data = {"meal_id": 99999} request_data = {"meal_id": 99999}
response = self.client.post("/api/shopping/current/meals/me", json=request_data) response = self.client.post("/api/shopping/current/meals/me", json=request_data)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertIn('Meal not found', response.json()['message']) self.assertIn("Meal not found", response.json()["message"])
finally: finally:
# Clean up the override # Clean up the override
if main.cookie_person in main.app.dependency_overrides: if main.cookie_person in main.app.dependency_overrides:
@ -677,7 +683,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
response = self.client.delete("/api/shopping/current/meals/99999") response = self.client.delete("/api/shopping/current/meals/99999")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertIn('Meal not found', response.json()['message']) self.assertIn("Meal not found", response.json()["message"])
finally: finally:
# Clean up the override # Clean up the override
if main.cookie_person in main.app.dependency_overrides: if main.cookie_person in main.app.dependency_overrides:
@ -713,7 +719,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 test ingredient", line="1 test ingredient",
unit="item", unit="item",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
await ingredients.insert_ingredient(self.conn, ingredient) await ingredients.insert_ingredient(self.conn, ingredient)
@ -732,8 +738,8 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
shopping_list = response.json() shopping_list = response.json()
self.assertIsInstance(shopping_list, list) self.assertIsInstance(shopping_list, list)
self.assertEqual(len(shopping_list), 1) self.assertEqual(len(shopping_list), 1)
self.assertEqual(shopping_list[0]['name'], "Test Ingredient") self.assertEqual(shopping_list[0]["name"], "Test Ingredient")
self.assertEqual(shopping_list[0]['line'], "1 test ingredient") self.assertEqual(shopping_list[0]["line"], "1 test ingredient")
finally: finally:
# Clean up the override # Clean up the override
if main.cookie_person in main.app.dependency_overrides: if main.cookie_person in main.app.dependency_overrides:
@ -769,7 +775,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="2 cups new ingredient 1", line="2 cups new ingredient 1",
unit="cup", unit="cup",
quantity=2.0, quantity=2.0,
preparation="" preparation="",
) )
ingredient2 = ingredients.Ingredient( ingredient2 = ingredients.Ingredient(
@ -778,7 +784,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 tbsp new ingredient 2", line="1 tbsp new ingredient 2",
unit="tbsp", unit="tbsp",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
# Insert ingredients to get valid IDs # Insert ingredients to get valid IDs
@ -793,14 +799,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Sync the ingredients # Sync the ingredients
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": ingredient1.id, "id": ingredient1.id,
"name": ingredient1.name, "name": ingredient1.name,
"line": ingredient1.line, "line": ingredient1.line,
"unit": ingredient1.unit, "unit": ingredient1.unit,
"quantity": ingredient1.quantity, "quantity": ingredient1.quantity,
"preparation": ingredient1.preparation "preparation": ingredient1.preparation,
}, },
{ {
"id": ingredient2.id, "id": ingredient2.id,
@ -808,9 +816,10 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"line": ingredient2.line, "line": ingredient2.line,
"unit": ingredient2.unit, "unit": ingredient2.unit,
"quantity": ingredient2.quantity, "quantity": ingredient2.quantity,
"preparation": ingredient2.preparation "preparation": ingredient2.preparation,
} },
]) ],
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_list = response.json() shopping_list = response.json()
@ -818,7 +827,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(shopping_list), 2) self.assertEqual(len(shopping_list), 2)
# Check that both ingredients are now in the shopping list # 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 1", ingredient_names)
self.assertIn("New Ingredient 2", ingredient_names) self.assertIn("New Ingredient 2", ingredient_names)
@ -838,7 +847,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 cup existing ingredient 1", line="1 cup existing ingredient 1",
unit="cup", unit="cup",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
ingredient2 = ingredients.Ingredient( ingredient2 = ingredients.Ingredient(
@ -847,7 +856,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="2 tbsp existing ingredient 2", line="2 tbsp existing ingredient 2",
unit="tbsp", unit="tbsp",
quantity=2.0, quantity=2.0,
preparation="" preparation="",
) )
await ingredients.insert_ingredient(self.conn, ingredient1) await ingredients.insert_ingredient(self.conn, ingredient1)
@ -865,22 +874,25 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Sync with only one ingredient (effectively removing the other) # Sync with only one ingredient (effectively removing the other)
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": ingredient1.id, "id": ingredient1.id,
"name": ingredient1.name, "name": ingredient1.name,
"line": ingredient1.line, "line": ingredient1.line,
"unit": ingredient1.unit, "unit": ingredient1.unit,
"quantity": ingredient1.quantity, "quantity": ingredient1.quantity,
"preparation": ingredient1.preparation "preparation": ingredient1.preparation,
} }
]) ],
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_list = response.json() shopping_list = response.json()
self.assertIsInstance(shopping_list, list) self.assertIsInstance(shopping_list, list)
self.assertEqual(len(shopping_list), 1) 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: finally:
# Clean up the override # Clean up the override
@ -898,7 +910,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 existing ingredient", line="1 existing ingredient",
unit="item", unit="item",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
remove_ingredient = ingredients.Ingredient( remove_ingredient = ingredients.Ingredient(
@ -907,7 +919,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 remove this ingredient", line="1 remove this ingredient",
unit="item", unit="item",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
new_ingredient = ingredients.Ingredient( new_ingredient = ingredients.Ingredient(
@ -916,7 +928,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="2 new ingredient", line="2 new ingredient",
unit="item", unit="item",
quantity=2.0, quantity=2.0,
preparation="" preparation="",
) )
# Insert all ingredients # Insert all ingredients
@ -936,14 +948,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Sync to keep existing, remove remove_ingredient, add new_ingredient # Sync to keep existing, remove remove_ingredient, add new_ingredient
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": existing_ingredient.id, "id": existing_ingredient.id,
"name": existing_ingredient.name, "name": existing_ingredient.name,
"line": existing_ingredient.line, "line": existing_ingredient.line,
"unit": existing_ingredient.unit, "unit": existing_ingredient.unit,
"quantity": existing_ingredient.quantity, "quantity": existing_ingredient.quantity,
"preparation": existing_ingredient.preparation "preparation": existing_ingredient.preparation,
}, },
{ {
"id": new_ingredient.id, "id": new_ingredient.id,
@ -951,16 +965,17 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
"line": new_ingredient.line, "line": new_ingredient.line,
"unit": new_ingredient.unit, "unit": new_ingredient.unit,
"quantity": new_ingredient.quantity, "quantity": new_ingredient.quantity,
"preparation": new_ingredient.preparation "preparation": new_ingredient.preparation,
} },
]) ],
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_list = response.json() shopping_list = response.json()
self.assertIsInstance(shopping_list, list) self.assertIsInstance(shopping_list, list)
self.assertEqual(len(shopping_list), 2) 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("Existing Ingredient", ingredient_names)
self.assertIn("New Ingredient", ingredient_names) self.assertIn("New Ingredient", ingredient_names)
self.assertNotIn("Remove This Ingredient", ingredient_names) self.assertNotIn("Remove This Ingredient", ingredient_names)
@ -982,16 +997,19 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Sync with new ingredients (negative IDs) # Sync with new ingredients (negative IDs)
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": -1, "id": -1,
"name": "Brand New Ingredient", "name": "Brand New Ingredient",
"line": "3 cups brand new ingredient", "line": "3 cups brand new ingredient",
"unit": "cup", "unit": "cup",
"quantity": 3.0, "quantity": 3.0,
"preparation": "chopped" "preparation": "chopped",
} }
]) ],
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_list = response.json() shopping_list = response.json()
@ -999,10 +1017,10 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(shopping_list), 1) self.assertEqual(len(shopping_list), 1)
# The ingredient should now have a positive ID # The ingredient should now have a positive ID
self.assertGreater(shopping_list[0]['id'], 0) self.assertGreater(shopping_list[0]["id"], 0)
self.assertEqual(shopping_list[0]['name'], "Brand New Ingredient") 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]["line"], "3 cups brand new ingredient")
self.assertEqual(shopping_list[0]['preparation'], "chopped") self.assertEqual(shopping_list[0]["preparation"], "chopped")
finally: finally:
# Clean up the override # Clean up the override
@ -1020,7 +1038,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
line="1 special line match test", line="1 special line match test",
unit="item", unit="item",
quantity=1.0, quantity=1.0,
preparation="" preparation="",
) )
await ingredients.insert_ingredient(self.conn, existing_ingredient) await ingredients.insert_ingredient(self.conn, existing_ingredient)
@ -1034,16 +1052,19 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Sync with ingredient with different ID but same line # Sync with ingredient with different ID but same line
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": -99, # Different ID "id": -99, # Different ID
"name": "Different Name", "name": "Different Name",
"line": "1 special line match test", # Same line "line": "1 special line match test", # Same line
"unit": "piece", "unit": "piece",
"quantity": 1.0, "quantity": 1.0,
"preparation": "different prep" "preparation": "different prep",
} }
]) ],
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
shopping_list = response.json() shopping_list = response.json()
@ -1051,8 +1072,8 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(shopping_list), 1) self.assertEqual(len(shopping_list), 1)
# Should keep the original ingredient since lines match # Should keep the original ingredient since lines match
self.assertEqual(shopping_list[0]['name'], "Existing Item") self.assertEqual(shopping_list[0]["name"], "Existing Item")
self.assertEqual(shopping_list[0]['line'], "1 special line match test") self.assertEqual(shopping_list[0]["line"], "1 special line match test")
finally: finally:
# Clean up the override # Clean up the override
@ -1081,13 +1102,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
try: try:
# Send invalid ingredient data # Send invalid ingredient data
response = self.client.post("/api/shopping/current/me/ingredients", json=[ response = self.client.post(
"/api/shopping/current/me/ingredients",
json=[
{ {
"id": "not_a_number", # Invalid ID type "id": "not_a_number", # Invalid ID type
"name": "Test Ingredient" "name": "Test Ingredient"
# Missing required fields # Missing required fields
} }
]) ],
)
self.assertEqual(response.status_code, 422) # Validation error self.assertEqual(response.status_code, 422) # Validation error
@ -1097,5 +1121,5 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
del main.app.dependency_overrides[main.cookie_person] del main.app.dependency_overrides[main.cookie_person]
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -5,10 +5,12 @@ import importlib
import tests.test_data as test_data import tests.test_data as test_data
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
from db import connect, create from db import connect, create
import meals import meals
import meals.db as meals_db import meals.db as meals_db
@ -23,7 +25,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
"""Test the meals data models""" """Test the meals data models"""
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn) await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
@ -39,7 +41,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 1, 1, 18, 0), suggested_date=datetime(2024, 1, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], 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.id, -1) # Default ID
@ -53,11 +55,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
def test_meal_recipe_creation(self): def test_meal_recipe_creation(self):
"""Test basic MealRecipe creation""" """Test basic MealRecipe creation"""
meal_recipe = MealRecipe( meal_recipe = MealRecipe(meal_id=1, recipe_id=2, servings=4.0)
meal_id=1,
recipe_id=2,
servings=4.0
)
self.assertEqual(meal_recipe.meal_id, 1) self.assertEqual(meal_recipe.meal_id, 1)
self.assertEqual(meal_recipe.recipe_id, 2) self.assertEqual(meal_recipe.recipe_id, 2)
@ -69,7 +67,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
"""Test meals CRUD operations""" """Test meals CRUD operations"""
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -85,7 +83,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 1, 15, 19, 0), suggested_date=datetime(2024, 1, 15, 19, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -110,19 +108,14 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
recipe.id = -1 # Reset ID recipe.id = -1 # Reset ID
await recipes.insert_recipe(self.conn, recipe) await recipes.insert_recipe(self.conn, recipe)
meal_recipe = MealRecipe( meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=3.0, recipe=recipe)
meal_id=-1,
recipe_id=recipe.id,
servings=3.0,
recipe=recipe
)
meal = Meal( meal = Meal(
suggested_date=datetime(2024, 2, 1, 18, 30), suggested_date=datetime(2024, 2, 1, 18, 30),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie, test_data.Persons.chris], consumers=[test_data.Persons.ellie, test_data.Persons.chris],
recipes=[meal_recipe] recipes=[meal_recipe],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -143,7 +136,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
# Create a new product for testing # Create a new product for testing
product = products.Product( product = products.Product(
id=-1, id=-1,
shop_code='woolworths', shop_code="woolworths",
name="Test Garlic Bread", name="Test Garlic Bread",
product_id="test_294517", product_id="test_294517",
quantity=1, quantity=1,
@ -163,7 +156,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
unit="loaf", unit="loaf",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=product.id product_id=product.id,
) )
meal = Meal( meal = Meal(
@ -171,7 +164,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie], consumers=[test_data.Persons.ellie],
extra_ingredients=[extra_ingredient] extra_ingredients=[extra_ingredient],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -196,7 +189,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 4, 1, 18, 0), suggested_date=datetime(2024, 4, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -228,7 +221,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 5, 1, 18, 0), suggested_date=datetime(2024, 5, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -250,7 +243,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 6, 1, 18, 0), suggested_date=datetime(2024, 6, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -272,7 +265,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 7, 1, 18, 0), suggested_date=datetime(2024, 7, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -304,21 +297,21 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 8, 1, 18, 0), suggested_date=datetime(2024, 8, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
meal2 = Meal( meal2 = Meal(
suggested_date=datetime(2024, 8, 15, 18, 0), suggested_date=datetime(2024, 8, 15, 18, 0),
chefs=[test_data.Persons.ryan], chefs=[test_data.Persons.ryan],
cleanup=[test_data.Persons.jacob], cleanup=[test_data.Persons.jacob],
consumers=[test_data.Persons.chris] consumers=[test_data.Persons.chris],
) )
meal3 = Meal( meal3 = Meal(
suggested_date=datetime(2024, 9, 1, 18, 0), suggested_date=datetime(2024, 9, 1, 18, 0),
chefs=[test_data.Persons.ellie], chefs=[test_data.Persons.ellie],
cleanup=[test_data.Persons.chris], cleanup=[test_data.Persons.chris],
consumers=[test_data.Persons.jacob] consumers=[test_data.Persons.jacob],
) )
# Create a consumed meal (should not appear in upcoming) # Create a consumed meal (should not appear in upcoming)
@ -327,7 +320,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
consumed_date=datetime(2024, 8, 10, 19, 0), consumed_date=datetime(2024, 8, 10, 19, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], 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, meal1)
@ -343,7 +336,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
end_date = datetime(2024, 8, 31) end_date = datetime(2024, 8, 31)
upcoming_meals = [] 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) upcoming_meals.append(meal)
# Should find meal1 and meal2, but not meal3 (outside range) or consumed_meal (consumed) # Should find meal1 and meal2, but not meal3 (outside range) or consumed_meal (consumed)
@ -357,7 +352,7 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
"""Test meal participant management""" """Test meal participant management"""
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -373,14 +368,14 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 10, 1, 18, 0), suggested_date=datetime(2024, 10, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
# Update participants # Update participants
new_chefs = [test_data.Persons.ryan, test_data.Persons.ellie] 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 # Verify participants were updated
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id) found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
@ -401,7 +396,7 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
"""Test meal recipe management""" """Test meal recipe management"""
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -431,15 +426,13 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 11, 1, 18, 0), suggested_date=datetime(2024, 11, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
# Add recipes to meal # Add recipes to meal
meal_recipes = [ meal_recipes = [MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0)]
MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0)
]
await meals_db.sync_meal_recipes(self.conn, meal.id, meal_recipes) await meals_db.sync_meal_recipes(self.conn, meal.id, meal_recipes)
@ -453,7 +446,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
"""Test meal extra ingredients management""" """Test meal extra ingredients management"""
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_test_data(self.conn) await test_data.create_test_data(self.conn)
reload_test_data() reload_test_data()
@ -468,7 +461,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
# Create a new product for testing # Create a new product for testing
product = products.Product( product = products.Product(
id=-1, id=-1,
shop_code='woolworths', shop_code="woolworths",
name="Test Bread Roll", name="Test Bread Roll",
product_id="test_bread_123", product_id="test_bread_123",
quantity=1, quantity=1,
@ -484,7 +477,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
suggested_date=datetime(2024, 12, 1, 18, 0), suggested_date=datetime(2024, 12, 1, 18, 0),
chefs=[test_data.Persons.jacob], chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan], cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie] consumers=[test_data.Persons.ellie],
) )
await meals_db.insert_meal(self.conn, meal) await meals_db.insert_meal(self.conn, meal)
@ -497,7 +490,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
unit="roll", unit="roll",
quantity=1.0, quantity=1.0,
preparation="", preparation="",
product_id=product.id product_id=product.id,
) )
await meals_db.sync_extra_ingredients(self.conn, meal.id, [extra_ingredient]) await meals_db.sync_extra_ingredients(self.conn, meal.id, [extra_ingredient])
@ -508,5 +501,5 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
self.assertEqual(found_meal.extra_ingredients[0].name, "Test Bread Roll") self.assertEqual(found_meal.extra_ingredients[0].name, "Test Bread Roll")
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -6,13 +6,16 @@ import products.db as products_db
from db import connect, create from db import connect, create
import importlib import importlib
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
class TestProductsDb(unittest.IsolatedAsyncioTestCase): class TestProductsDb(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
reload_test_data() reload_test_data()
return await super().asyncSetUp() return await super().asyncSetUp()
@ -39,31 +42,35 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase):
from . import httpx_mocks from . import httpx_mocks
from products import woolworths from products import woolworths
class TestWoolworths(unittest.IsolatedAsyncioTestCase): class TestWoolworths(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None: 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.MockAsyncClient(local_path)
# woolworths._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path) # woolworths._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
return await super().asyncSetUp() return await super().asyncSetUp()
async def test_get_product_id(self) -> None: async def test_get_product_id(self) -> None:
params = [ params = [
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', '144607'), ("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/133211/cavendish-bananas",
"133211",
),
("https://www.coles.com.au/product/coles-strawberries-250g-5191256", None),
] ]
for url, id in params: for url, id in params:
self.assertEqual(woolworths.get_product_id(url), id) self.assertEqual(woolworths.get_product_id(url), id)
async def test_get_strawberries(self) -> None: async def test_get_strawberries(self) -> None:
details, raw_data = await woolworths.scrape('144607') details, raw_data = await woolworths.scrape("144607")
expected = { expected = {
'name': 'Strawberries', "name": "Strawberries",
'quantity': 250, "quantity": 250,
'unit': 'g Punnet', "unit": "g Punnet",
'img_small': 'https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg', "img_small": "https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg",
'img_large': 'https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg' "img_large": "https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg",
} }
for key, value in expected.items(): for key, value in expected.items():
@ -72,33 +79,39 @@ class TestWoolworths(unittest.IsolatedAsyncioTestCase):
from products import coles from products import coles
class TestColes(unittest.IsolatedAsyncioTestCase): class TestColes(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None: 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.MockAsyncClient(local_path)
# coles._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path) # coles._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
return await super().asyncSetUp() return await super().asyncSetUp()
async def test_get_product_id(self) -> None: async def test_get_product_id(self) -> None:
params = [ 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.coles.com.au/product/coles-strawberries-250g-5191256",
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', None), "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: for url, id in params:
self.assertEqual(coles.get_product_id(url), id) self.assertEqual(coles.get_product_id(url), id)
async def test_get_strawberries(self) -> None: 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 = { expected = {
'name': 'Strawberries', "name": "Strawberries",
'quantity': 250, "quantity": 250,
'unit': 'g', "unit": "g",
'img_small': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg', "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' "img_large": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg",
} }
for key, value in expected.items(): for key, value in expected.items():
self.assertEqual(details[key], value, msg=key) self.assertEqual(details[key], value, msg=key)

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
from typing import Union
class Unit: class Unit:
def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0): def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0):
self.name = name self.name = name
@ -15,6 +13,7 @@ class Unit:
"""Converts a quantity from the base unit to this unit.""" """Converts a quantity from the base unit to this unit."""
return quantity / self.conversion_to_base return quantity / self.conversion_to_base
# Define common base units in SI units # Define common base units in SI units
ITEMS = Unit("Items", ["item", "items"], "count", 1) ITEMS = Unit("Items", ["item", "items"], "count", 1)
LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1) LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1)
@ -35,9 +34,26 @@ MILLIGRAM = Unit("Milligram", ["milligram", "milligrams", "mg"], "weight", 1)
KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000) KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000)
# Big list of units # 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.""" """Returns the corresponding unit based on alias or abbreviation."""
alias_lower = alias.lower() alias_lower = alias.lower()
for unit in ALL_UNITS: for unit in ALL_UNITS: