Added ruff, black and mypy
This commit is contained in:
parent
255ebd4613
commit
18f784665f
31 changed files with 2513 additions and 1630 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
30
.github/workflows/ci.yml
vendored
Normal 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
20
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -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 .
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
16
db.py
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,),
|
||||||
|
)
|
||||||
|
|
|
||||||
368
main.py
368
main.py
|
|
@ -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)
|
||||||
|
|
||||||
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
if r.created_by_id is not None:
|
||||||
|
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
||||||
|
|
||||||
return r
|
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,56 +189,81 @@ 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()
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.id in seen:
|
if item.id in seen:
|
||||||
duplicates.add(item.name)
|
duplicates.add(item.name)
|
||||||
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(),
|
||||||
|
|
@ -415,4 +571,4 @@ else:
|
||||||
background=BackgroundTask(rp_resp.aclose),
|
background=BackgroundTask(rp_resp.aclose),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_route("/{path:path}",_reverse_proxy, ["GET", "POST"])
|
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
207
meals/db.py
207
meals/db.py
|
|
@ -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,67 +136,97 @@ 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)})
|
||||||
|
|
||||||
await load_participants(conn, meal)
|
await load_participants(conn, 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":
|
||||||
meal.chefs.append(person)
|
if person:
|
||||||
elif row[1] == 'cleanup':
|
meal.chefs.append(person)
|
||||||
meal.cleanup.append(person)
|
elif row[1] == "cleanup":
|
||||||
elif row[1] == 'consumer':
|
if person:
|
||||||
meal.consumers.append(person)
|
meal.cleanup.append(person)
|
||||||
|
elif row[1] == "consumer":
|
||||||
|
if 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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
47
pyproject.toml
Normal 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 = ["."]
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
238
shopping/db.py
238
shopping/db.py
|
|
@ -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,70 +231,88 @@ 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
|
||||||
|
|
||||||
if shopping_list:
|
if shopping_list:
|
||||||
|
|
@ -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)})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,24 +799,27 @@ 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",
|
||||||
"id": ingredient1.id,
|
json=[
|
||||||
"name": ingredient1.name,
|
{
|
||||||
"line": ingredient1.line,
|
"id": ingredient1.id,
|
||||||
"unit": ingredient1.unit,
|
"name": ingredient1.name,
|
||||||
"quantity": ingredient1.quantity,
|
"line": ingredient1.line,
|
||||||
"preparation": ingredient1.preparation
|
"unit": ingredient1.unit,
|
||||||
},
|
"quantity": ingredient1.quantity,
|
||||||
{
|
"preparation": ingredient1.preparation,
|
||||||
"id": ingredient2.id,
|
},
|
||||||
"name": ingredient2.name,
|
{
|
||||||
"line": ingredient2.line,
|
"id": ingredient2.id,
|
||||||
"unit": ingredient2.unit,
|
"name": ingredient2.name,
|
||||||
"quantity": ingredient2.quantity,
|
"line": ingredient2.line,
|
||||||
"preparation": ingredient2.preparation
|
"unit": ingredient2.unit,
|
||||||
}
|
"quantity": ingredient2.quantity,
|
||||||
])
|
"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",
|
||||||
"id": ingredient1.id,
|
json=[
|
||||||
"name": ingredient1.name,
|
{
|
||||||
"line": ingredient1.line,
|
"id": ingredient1.id,
|
||||||
"unit": ingredient1.unit,
|
"name": ingredient1.name,
|
||||||
"quantity": ingredient1.quantity,
|
"line": ingredient1.line,
|
||||||
"preparation": ingredient1.preparation
|
"unit": ingredient1.unit,
|
||||||
}
|
"quantity": ingredient1.quantity,
|
||||||
])
|
"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,31 +948,34 @@ 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",
|
||||||
"id": existing_ingredient.id,
|
json=[
|
||||||
"name": existing_ingredient.name,
|
{
|
||||||
"line": existing_ingredient.line,
|
"id": existing_ingredient.id,
|
||||||
"unit": existing_ingredient.unit,
|
"name": existing_ingredient.name,
|
||||||
"quantity": existing_ingredient.quantity,
|
"line": existing_ingredient.line,
|
||||||
"preparation": existing_ingredient.preparation
|
"unit": existing_ingredient.unit,
|
||||||
},
|
"quantity": existing_ingredient.quantity,
|
||||||
{
|
"preparation": existing_ingredient.preparation,
|
||||||
"id": new_ingredient.id,
|
},
|
||||||
"name": new_ingredient.name,
|
{
|
||||||
"line": new_ingredient.line,
|
"id": new_ingredient.id,
|
||||||
"unit": new_ingredient.unit,
|
"name": new_ingredient.name,
|
||||||
"quantity": new_ingredient.quantity,
|
"line": new_ingredient.line,
|
||||||
"preparation": new_ingredient.preparation
|
"unit": new_ingredient.unit,
|
||||||
}
|
"quantity": new_ingredient.quantity,
|
||||||
])
|
"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",
|
||||||
"id": -1,
|
json=[
|
||||||
"name": "Brand New Ingredient",
|
{
|
||||||
"line": "3 cups brand new ingredient",
|
"id": -1,
|
||||||
"unit": "cup",
|
"name": "Brand New Ingredient",
|
||||||
"quantity": 3.0,
|
"line": "3 cups brand new ingredient",
|
||||||
"preparation": "chopped"
|
"unit": "cup",
|
||||||
}
|
"quantity": 3.0,
|
||||||
])
|
"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",
|
||||||
"id": -99, # Different ID
|
json=[
|
||||||
"name": "Different Name",
|
{
|
||||||
"line": "1 special line match test", # Same line
|
"id": -99, # Different ID
|
||||||
"unit": "piece",
|
"name": "Different Name",
|
||||||
"quantity": 1.0,
|
"line": "1 special line match test", # Same line
|
||||||
"preparation": "different prep"
|
"unit": "piece",
|
||||||
}
|
"quantity": 1.0,
|
||||||
])
|
"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",
|
||||||
"id": "not_a_number", # Invalid ID type
|
json=[
|
||||||
"name": "Test Ingredient"
|
{
|
||||||
# Missing required fields
|
"id": "not_a_number", # Invalid ID type
|
||||||
}
|
"name": "Test Ingredient"
|
||||||
])
|
# 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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
24
units.py
24
units.py
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue