Compare commits
17 commits
255ebd4613
...
5be4e89c4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be4e89c4e | |||
| 50a1fcaeee | |||
| 865c02b195 | |||
| 45ff778112 | |||
| 589eb5380c | |||
| 322e14c26c | |||
| 99b699eec9 | |||
| 89cfbe9abc | |||
| a3c0a2701e | |||
| f51c90f922 | |||
| 53b07343d1 | |||
| 2bbed57313 | |||
| edfc341b4b | |||
| 3d7afa3765 | |||
| 1b76ea44b6 | |||
| 4c3f370ddc | |||
| 18f784665f |
58 changed files with 7028 additions and 3036 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
|
||||
50
.github/workflows/openapi.yml
vendored
Normal file
50
.github/workflows/openapi.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, openapi, '**/openapi' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
schema:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Export OpenAPI schema
|
||||
run: |
|
||||
python scripts/export_openapi.py
|
||||
|
||||
- name: Set up Node for schema tools
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Lint schema with Spectral
|
||||
run: npx -y @stoplight/spectral-cli lint openapi.json
|
||||
|
||||
- name: Compare with baseline if present
|
||||
run: |
|
||||
if [ -f openapi-baseline.json ]; then \
|
||||
npx -y openapi-diff --fail-on-changed --fail-on-incompatible openapi-baseline.json openapi.json; \
|
||||
else \
|
||||
echo "No baseline file found. Skipping diff."; \
|
||||
fi
|
||||
|
||||
- name: Upload schema artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openapi-schema
|
||||
path: openapi.json
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
|
|
@ -1,3 +1,47 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Caches
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# SQLite databases & dumps
|
||||
*.sqlite
|
||||
/data/dump/
|
||||
|
||||
# Environments
|
||||
.venv/
|
||||
.env
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage*
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.pytest_cache/
|
||||
.mypy_cache/
|
||||
.pytype/
|
||||
.venv/
|
||||
.env
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Local data
|
||||
/data/
|
||||
/front-dist/
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage*
|
||||
.venv/
|
||||
__pycache__
|
||||
data/
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -1,11 +1,69 @@
|
|||
Meal planner backend
|
||||
|
||||
## Structure
|
||||
|
||||
- `main.py`: FastAPI app with all HTTP endpoints.
|
||||
- `db.py`: aiosqlite connection + schema bootstrap across subpackages (calls each feature's `repository.create`).
|
||||
- Domain packages with models and persistence:
|
||||
- `products/` (models.py, repository.py, scrapers for Woolworths/Coles)
|
||||
- `ingredients/` (models.py, repository.py)
|
||||
- `recipes/` (models.py, repository.py, scraping.py)
|
||||
- `meals/` (models.py, repository.py, service.py)
|
||||
- `persons/` (models.py, repository.py)
|
||||
- `shopping/` (models.py, repository.py)
|
||||
- `tests/`: unit and API tests with sample HTTP fixtures.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install packages
|
||||
```
|
||||
pip install -r ./requirements.txt
|
||||
```
|
||||
|
||||
Run with
|
||||
Run API (dev)
|
||||
```
|
||||
uvicorn main:app
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
Run tests
|
||||
```
|
||||
pytest -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 .
|
||||
ruff format .
|
||||
mypy .
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
- DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`)
|
||||
- DOOF_PORT: Port the server listens on when containerized; align Dockerfile `EXPOSE` accordingly.
|
||||
|
||||
## OpenAPI schema
|
||||
|
||||
- Generate the schema artifact used by the frontend and CI checks:
|
||||
```
|
||||
python scripts/export_openapi.py
|
||||
```
|
||||
|
||||
This writes `openapi.json` to the repo root. Versioned endpoints live under `/api/v1`, legacy under `/api` (deprecated with `Deprecation` header).
|
||||
|
||||
## Schema lint/diff (manual)
|
||||
|
||||
Optionally, lint and compare schemas locally using Node tools:
|
||||
```
|
||||
npx -y @stoplight/spectral-cli lint openapi.json
|
||||
npx -y openapi-diff --fail-on-changed --fail-on-incompatible path/to/baseline.json openapi.json
|
||||
```
|
||||
|
||||
Keep a `baseline.json` on release branches to detect breaking changes.
|
||||
|
|
|
|||
2
api/__init__.py
Normal file
2
api/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# API package for FastAPI routers.
|
||||
# Routers will be split by feature: recipes, meals, persons, shopping, auth.
|
||||
49
api/auth.py
Normal file
49
api/auth.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
import persons
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ApiModel, ProblemDetails
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginBody(ApiModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=persons.Person,
|
||||
operation_id="login",
|
||||
summary="Login and set user_id cookie",
|
||||
responses={
|
||||
200: {"model": persons.Person, "description": "Successful Response"},
|
||||
404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}
|
||||
},
|
||||
)
|
||||
async def login(
|
||||
request: Request,
|
||||
data: LoginBody,
|
||||
response: Response,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> persons.Person:
|
||||
person = await persons.get_by_name(conn, data.username)
|
||||
if not person:
|
||||
return error_response(request, 404, "Person not found")
|
||||
|
||||
# When using response_model, return the Pydantic model and set the cookie on the Response
|
||||
response.set_cookie(key="user_id", value=str(person.id))
|
||||
return person
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=persons.Person,
|
||||
operation_id="refresh",
|
||||
summary="Refresh current user from cookie",
|
||||
)
|
||||
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
||||
return user
|
||||
57
api/deps.py
Normal file
57
api/deps.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, AsyncGenerator, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import Cookie, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import db
|
||||
import persons
|
||||
from common import ProblemDetails
|
||||
from settings import settings
|
||||
|
||||
|
||||
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
||||
async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
||||
sql_db = await db.connect(settings.database_path)
|
||||
# Connection-level configuration
|
||||
try:
|
||||
# Enable FK enforcement
|
||||
await sql_db.execute("PRAGMA foreign_keys=ON;")
|
||||
# Prefer WAL for better concurrency; ignore result
|
||||
async with sql_db.execute("PRAGMA journal_mode=WAL;") as _:
|
||||
await _.fetchone()
|
||||
# Reasonable durability/perf tradeoff
|
||||
await sql_db.execute("PRAGMA synchronous=NORMAL;")
|
||||
# Begin a transaction for the whole request
|
||||
await sql_db.execute("BEGIN;")
|
||||
|
||||
try:
|
||||
yield sql_db
|
||||
await sql_db.commit()
|
||||
except Exception:
|
||||
await sql_db.rollback()
|
||||
raise
|
||||
finally:
|
||||
await sql_db.close()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
||||
body = ProblemDetails(
|
||||
title=message,
|
||||
status=status_code,
|
||||
type=f"https://httpstatuses.com/{status_code}",
|
||||
instance=str(request.url) if request else None,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True),
|
||||
status_code=status_code,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
141
api/meals.py
Normal file
141
api/meals.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
import meals
|
||||
import persons
|
||||
import shopping
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ProblemDetails
|
||||
|
||||
router = APIRouter(prefix="/meals", tags=["meals"])
|
||||
|
||||
|
||||
@router.get("/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range")
|
||||
async def get_upcoming_meals(
|
||||
date_from: datetime.datetime = Query(..., alias="from"),
|
||||
to: datetime.datetime = Query(...),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> List[meals.Meal]:
|
||||
# Load base meals
|
||||
result: List[meals.Meal] = []
|
||||
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
|
||||
result.append(meal)
|
||||
|
||||
if not result:
|
||||
return result
|
||||
|
||||
# Batch load participants for all meals
|
||||
await meals.bulk_load_participants(conn, result)
|
||||
|
||||
# Load recipes and extra ingredients per meal (recipes include a small join)
|
||||
for meal in result:
|
||||
await meals.load_recipes(conn, meal)
|
||||
await meals.load_extra_ingredients(conn, meal)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
|
||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||
async def get_meal(
|
||||
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> meals.Meal | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
return meal
|
||||
|
||||
|
||||
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
|
||||
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
||||
async def create_meal(
|
||||
meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> meals.Meal | Response:
|
||||
validation_response = validate_meal(meal, request)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
await meals.insert_meal(conn, meal)
|
||||
response.headers["Location"] = f"/api/v1/meals/{meal.id}"
|
||||
return meal
|
||||
|
||||
|
||||
@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal",
|
||||
responses={
|
||||
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
|
||||
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
||||
})
|
||||
async def update_meal(
|
||||
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> meals.Meal | Response:
|
||||
if meal.id != meal_id:
|
||||
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
||||
|
||||
existing = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not existing:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
validation_response = validate_meal(meal, request)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
await meals.update_meal(conn, meal)
|
||||
|
||||
# Re-fetch and return the updated meal. Pass request and conn explicitly to avoid Depends resolution.
|
||||
return await get_meal(meal_id, request, conn)
|
||||
|
||||
|
||||
@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed",
|
||||
responses={
|
||||
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
|
||||
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
||||
})
|
||||
async def mark_consumed(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
consumed_date: Optional[datetime.datetime] = None,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> meals.Meal | Response:
|
||||
if consumed_date and not consumed_date.tzinfo:
|
||||
return error_response(request, 400, "Consumed date must include timezone")
|
||||
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
|
||||
return meal
|
||||
|
||||
|
||||
@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal",
|
||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||
async def delete_meal(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> meals.Meal | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
await meals.delete_meal(conn, meal.id)
|
||||
return meal
|
||||
|
||||
|
||||
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Response]:
|
||||
"""HTTP-friendly wrapper that maps service validation to ProblemDetails."""
|
||||
msg = meals.validate_meal(meal)
|
||||
if msg:
|
||||
return error_response(request, 400, msg)
|
||||
return None
|
||||
109
api/openapi.py
Normal file
109
api/openapi.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||
"""Augment FastAPI's OpenAPI spec with RFC7807 responses and cookie auth.
|
||||
|
||||
This mutates the app's OpenAPI generation in-place while delegating to the
|
||||
original generator for the base schema.
|
||||
"""
|
||||
original_openapi = app.openapi
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
spec = original_openapi()
|
||||
components = spec.setdefault("components", {})
|
||||
responses = components.setdefault("responses", {})
|
||||
security_schemes = components.setdefault("securitySchemes", {})
|
||||
|
||||
# Standard ProblemDetails responses
|
||||
responses.setdefault(
|
||||
"Problem400",
|
||||
{
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {},
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
responses.setdefault(
|
||||
"Problem404",
|
||||
{
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/problem+json": {},
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
responses.setdefault(
|
||||
"Problem422",
|
||||
{
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/problem+json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Cookie-based auth for documentation (does not enforce at runtime)
|
||||
security_schemes.setdefault(
|
||||
"cookieAuth",
|
||||
{
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "user_id",
|
||||
"description": "Authentication via user_id cookie (session-style).",
|
||||
},
|
||||
)
|
||||
|
||||
# Normalize v1 responses and mark cookie security for known endpoints
|
||||
paths = spec.get("paths", {})
|
||||
protected_ops: set[str] = {
|
||||
"parseRecipe",
|
||||
"createRecipe",
|
||||
"deleteRecipe",
|
||||
"markMealConsumed",
|
||||
"deleteMeal",
|
||||
"purchaseIngredients",
|
||||
"getMyShoppingList",
|
||||
"syncMyShoppingList",
|
||||
"requestMeal",
|
||||
"unrequestMeal",
|
||||
"refresh",
|
||||
}
|
||||
for path, ops in paths.items():
|
||||
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
||||
continue
|
||||
if not isinstance(ops, dict):
|
||||
continue
|
||||
for _method, op in ops.items():
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
resp = op.get("responses")
|
||||
if not isinstance(resp, dict):
|
||||
continue
|
||||
if "400" in resp:
|
||||
resp["400"] = {"$ref": "#/components/responses/Problem400"}
|
||||
if "404" in resp:
|
||||
resp["404"] = {"$ref": "#/components/responses/Problem404"}
|
||||
if "422" not in resp:
|
||||
resp["422"] = {"$ref": "#/components/responses/Problem422"}
|
||||
|
||||
op_id = op.get("operationId")
|
||||
if isinstance(op_id, str) and op_id in protected_ops:
|
||||
security = op.setdefault("security", [])
|
||||
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
||||
security.append({"cookieAuth": []})
|
||||
|
||||
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
|
||||
|
||||
return spec
|
||||
|
||||
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
||||
app.openapi = custom_openapi # type: ignore[method-assign]
|
||||
98
api/persons.py
Normal file
98
api/persons.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
|
||||
import persons
|
||||
from api.deps import get_db
|
||||
from common import Page
|
||||
|
||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listPersons",
|
||||
response_model=Page[persons.Person],
|
||||
summary="List persons (paginated)",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A page of persons",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Ada Lovelace"
|
||||
}
|
||||
],
|
||||
"nextCursor": "2",
|
||||
"prevCursor": "0",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def list_persons(
|
||||
q: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).",
|
||||
),
|
||||
cursor: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
||||
),
|
||||
limit: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="Maximum number of items to return (1-200).",
|
||||
),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> Page[persons.Person]:
|
||||
# v1: DB-backed pagination
|
||||
last_id = None
|
||||
if cursor:
|
||||
try:
|
||||
last_id = int(cursor)
|
||||
except ValueError:
|
||||
last_id = None
|
||||
|
||||
fetch_limit = limit + 1
|
||||
paged: List[persons.Person] = []
|
||||
if q:
|
||||
async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit):
|
||||
paged.append(p)
|
||||
else:
|
||||
async for p in persons.get_all_paged(conn, last_id, fetch_limit):
|
||||
paged.append(p)
|
||||
|
||||
has_more = len(paged) > limit
|
||||
items = paged[:limit]
|
||||
next_cursor = str(items[-1].id) if has_more and items else None
|
||||
# Compute prevCursor via DB helper
|
||||
prev_cursor: Optional[str] = None
|
||||
if items:
|
||||
first_id = items[0].id
|
||||
prev_cursor = await persons.compute_prev_cursor(conn, first_id, limit, q)
|
||||
total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn))
|
||||
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createPerson",
|
||||
summary="Create a person",
|
||||
response_model=persons.Person,
|
||||
)
|
||||
async def create_person(
|
||||
person: persons.Person, response: Response, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> persons.Person:
|
||||
await persons.insert_person(conn, person)
|
||||
response.headers["Location"] = f"/api/v1/persons/{person.id}"
|
||||
return person
|
||||
33
api/products.py
Normal file
33
api/products.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from pydantic import Field
|
||||
|
||||
import products
|
||||
from api.deps import get_db
|
||||
from common import ApiModel
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
|
||||
class ProductUrl(ApiModel):
|
||||
url: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createProduct",
|
||||
summary="Create or fetch a product from a URL",
|
||||
response_model=products.Product,
|
||||
)
|
||||
async def create_product(
|
||||
url: ProductUrl, response: Response, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> Optional[products.Product]:
|
||||
product = await products.get_or_create(conn, url.url, url.tags)
|
||||
if product:
|
||||
response.headers["Location"] = f"/api/v1/products/{product.id}"
|
||||
return product
|
||||
257
api/recipes.py
Normal file
257
api/recipes.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
|
||||
import ingredients
|
||||
import persons
|
||||
import recipes
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import Page, ProblemDetails
|
||||
|
||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/parse",
|
||||
response_model=recipes.Recipe,
|
||||
operation_id="parseRecipe",
|
||||
summary="Parse a recipe from a URL",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def parse_recipe_handler(
|
||||
url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person)
|
||||
) -> recipes.Recipe | Response:
|
||||
parsed = await recipes.parse_recipe(conn, person, url)
|
||||
if not parsed:
|
||||
return error_response(request, 400, "Recipe not found")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ingredients/parse",
|
||||
operation_id="parseIngredients",
|
||||
summary="Parse raw ingredient lines",
|
||||
)
|
||||
async def parse_ingredients(
|
||||
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> List[ingredients.Ingredient]:
|
||||
had_links = False
|
||||
result = []
|
||||
for line in lines:
|
||||
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
had_links = True
|
||||
continue
|
||||
|
||||
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
continue
|
||||
|
||||
if had_links:
|
||||
# Transaction will commit at end of request
|
||||
pass
|
||||
|
||||
await ingredients.match_existing_products(conn, result)
|
||||
return result
|
||||
|
||||
|
||||
async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]:
|
||||
r = await recipes.find_recipe_by_id(conn, id)
|
||||
if not r:
|
||||
return None
|
||||
|
||||
r.ingredients = []
|
||||
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
|
||||
r.ingredients.append(ingredient)
|
||||
|
||||
if r.created_by_id is not None:
|
||||
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
||||
|
||||
return r
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listRecipes",
|
||||
response_model=Page[recipes.Recipe],
|
||||
summary="List recipes (paginated)",
|
||||
responses={
|
||||
200: {
|
||||
"description": "A page of recipes",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Example Recipe",
|
||||
"link": "https://example.com/recipes/1",
|
||||
"serves": 4,
|
||||
"imageUrls": [],
|
||||
"ingredients": []
|
||||
}
|
||||
],
|
||||
"nextCursor": "2",
|
||||
"prevCursor": "0",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def list_recipes(
|
||||
request: Request,
|
||||
q: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
||||
),
|
||||
cursor: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
||||
),
|
||||
limit: int = Query(
|
||||
50,
|
||||
ge=1,
|
||||
le=200,
|
||||
description="Maximum number of items to return (1-200).",
|
||||
),
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
) -> Page[recipes.Recipe]:
|
||||
last_id = None
|
||||
if cursor:
|
||||
try:
|
||||
last_id = int(cursor)
|
||||
except ValueError:
|
||||
last_id = None
|
||||
|
||||
fetch_limit = limit + 1
|
||||
paged: List[recipes.Recipe] = []
|
||||
if q:
|
||||
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
|
||||
paged.append(r)
|
||||
else:
|
||||
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
|
||||
paged.append(r)
|
||||
|
||||
has_more = len(paged) > limit
|
||||
items = paged[:limit]
|
||||
# Batch-load ingredients for the page to avoid N+1 queries
|
||||
if items:
|
||||
recipe_ids = [r.id for r in items]
|
||||
by_recipe = await ingredients.find_ingredients_by_recipe_ids(conn, recipe_ids)
|
||||
for r in items:
|
||||
r.ingredients = by_recipe.get(r.id, [])
|
||||
next_cursor = str(items[-1].id) if has_more and items else None
|
||||
# Compute prevCursor via DB helper
|
||||
prev_cursor: Optional[str] = None
|
||||
if items:
|
||||
first_id = items[0].id
|
||||
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
|
||||
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
|
||||
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{recipe_id}",
|
||||
response_model=recipes.Recipe,
|
||||
operation_id="getRecipe",
|
||||
summary="Get a single recipe",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_recipe(
|
||||
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> recipes.Recipe | Response:
|
||||
r = await load_full_recipe(conn, recipe_id)
|
||||
if not r:
|
||||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
return r
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=recipes.Recipe,
|
||||
operation_id="createRecipe",
|
||||
summary="Create a new recipe (versioning semantics applied)",
|
||||
responses={
|
||||
400: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Validation error",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def create_recipe(
|
||||
recipe: recipes.Recipe,
|
||||
request: Request,
|
||||
response: Response,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
) -> recipes.Recipe | Response:
|
||||
if not recipe.ingredients:
|
||||
return error_response(request, 400, "Recipe must have at least one ingredient")
|
||||
|
||||
if recipe.id >= 0:
|
||||
await recipes.hide_recipe(conn, recipe.id, user)
|
||||
recipe.based_on_recipe = recipe.id
|
||||
recipe.id = 0
|
||||
|
||||
recipe.created_by_id = user.id
|
||||
await recipes.insert_recipe(conn, recipe)
|
||||
for ingredient in recipe.ingredients:
|
||||
ingredient.recipe_id = recipe.id
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
await ingredients.insert_ingredient(conn, ingredient)
|
||||
|
||||
# Transaction will commit at end of request
|
||||
# Set Location to the new resource
|
||||
response.headers["Location"] = f"/api/v1/recipes/{recipe.id}"
|
||||
return recipe
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{recipe_id}",
|
||||
response_model=recipes.Recipe,
|
||||
operation_id="deleteRecipe",
|
||||
summary="Soft-delete (hide) a recipe",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Recipe not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def delete_recipe(
|
||||
recipe_id: int,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
user: persons.Person = Depends(cookie_person),
|
||||
) -> recipes.Recipe | Response:
|
||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||
if not recipe:
|
||||
return error_response(request, 404, "Recipe not found")
|
||||
|
||||
await recipes.hide_recipe(conn, recipe_id, user)
|
||||
return recipe
|
||||
201
api/shopping.py
Normal file
201
api/shopping.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
import ingredients
|
||||
import meals
|
||||
import persons
|
||||
import recipes
|
||||
import shopping
|
||||
from api.deps import cookie_person, error_response, get_db
|
||||
from common import ApiModel, Field, ProblemDetails
|
||||
|
||||
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||
|
||||
|
||||
class CurrentShoppingList(ApiModel):
|
||||
outstanding_items: List[shopping.ShoppingListItem]
|
||||
requested_meals: 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)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/current",
|
||||
response_model=CurrentShoppingList,
|
||||
operation_id="getCurrentShoppingList",
|
||||
summary="Get the current aggregated shopping list",
|
||||
)
|
||||
async def get_current_shopping_list(conn: aiosqlite.Connection = Depends(get_db)) -> CurrentShoppingList:
|
||||
(
|
||||
outstanding_requests,
|
||||
purchased_requests,
|
||||
meal_requests,
|
||||
meals_lookup,
|
||||
recipes_lookup,
|
||||
ingredients_lookup,
|
||||
) = await shopping.get_outstanding_requests(conn)
|
||||
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
||||
|
||||
shopping_list_lookup = {}
|
||||
for list_id in other_shopping_list_ids:
|
||||
if list_id is not None:
|
||||
sl = await shopping.load_shopping_list(conn, list_id)
|
||||
if sl is not None:
|
||||
shopping_list_lookup[list_id] = sl
|
||||
|
||||
# Add any additional items from shopping lists to the existing lookups
|
||||
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
|
||||
if additional_items:
|
||||
await shopping.to_lookups(
|
||||
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
||||
)
|
||||
|
||||
return CurrentShoppingList(
|
||||
outstanding_items=outstanding_requests,
|
||||
requested_meals=meal_requests,
|
||||
purchased_items=purchased_requests,
|
||||
meals_lookup=meals_lookup,
|
||||
shopping_list_lookup=shopping_list_lookup,
|
||||
ingredients_lookup=ingredients_lookup,
|
||||
recipes_lookup=recipes_lookup,
|
||||
)
|
||||
|
||||
|
||||
class PurchasedShoppingList(ApiModel):
|
||||
list: shopping.ShoppingList
|
||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id",
|
||||
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
|
||||
async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | Response:
|
||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||
if not shopping_list:
|
||||
return error_response(request, 404, "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,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list")
|
||||
async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
|
||||
shopping_list = shopping.ShoppingList(
|
||||
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
|
||||
)
|
||||
|
||||
await shopping.purchase(conn, shopping_list)
|
||||
result = PurchasedShoppingList(list=shopping_list)
|
||||
await shopping.to_lookups(
|
||||
conn,
|
||||
shopping_list.items,
|
||||
result.meals_lookup,
|
||||
result.recipes_lookup,
|
||||
result.ingredients_lookup,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests")
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests")
|
||||
async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
|
||||
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
|
||||
return a.id == b.id or a.line == b.line
|
||||
|
||||
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
|
||||
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
|
||||
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
|
||||
|
||||
for r in to_remove:
|
||||
await shopping.remove_request(conn, person, ingredient=r)
|
||||
|
||||
for r in to_add:
|
||||
if r.id < 0:
|
||||
await ingredients.insert_ingredient(conn, r)
|
||||
await shopping.request(conn, person, ingredient=r)
|
||||
|
||||
return await get_my_shopping_list(conn, person)
|
||||
|
||||
|
||||
class MealIdWrapper(ApiModel):
|
||||
meal_id: int
|
||||
|
||||
|
||||
@router.post(
|
||||
"/current/meals/me",
|
||||
response_model=shopping.ShoppingListItem,
|
||||
operation_id="requestMeal",
|
||||
summary="Request a meal for shopping",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def request_meal(
|
||||
r: MealIdWrapper,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> shopping.ShoppingListItem | Response:
|
||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
response = await shopping.request(conn, person, meal=meal)
|
||||
return response
|
||||
|
||||
class Ok(ApiModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/current/meals/{meal_id}",
|
||||
response_model=Ok,
|
||||
operation_id="unrequestMeal",
|
||||
summary="Remove a meal request",
|
||||
responses={
|
||||
404: {
|
||||
"model": ProblemDetails,
|
||||
"description": "Meal not found",
|
||||
"content": {"application/problem+json": {}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def unrequest_meal(
|
||||
meal_id: int,
|
||||
request: Request,
|
||||
conn: aiosqlite.Connection = Depends(get_db),
|
||||
person: persons.Person = Depends(cookie_person),
|
||||
) -> Ok | Response:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return error_response(request, 404, "Meal not found")
|
||||
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
return Ok()
|
||||
|
||||
|
||||
# Removed duplicate placeholder endpoints left over from earlier scaffolding
|
||||
45
common.py
45
common.py
|
|
@ -1,9 +1,23 @@
|
|||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Optional, Any
|
||||
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
||||
|
||||
class BaseLinkedModel(BaseModel):
|
||||
model_config = dict(arbitrary_types_allowed=True)
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
def to_camel(s: str) -> str:
|
||||
parts = s.split("_")
|
||||
return parts[0] + "".join(p.title() for p in parts[1:])
|
||||
|
||||
|
||||
class ApiModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=True,
|
||||
ser_json_inf_nan="null",
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
class BaseLinkedModel(ApiModel):
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def auto_populate_ids(cls, data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -15,9 +29,30 @@ class BaseLinkedModel(BaseModel):
|
|||
if id_key in data:
|
||||
# If the id_key already exists, ensure it matches the value's id
|
||||
if data[id_key] != value.id:
|
||||
raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}")
|
||||
raise ValueError(
|
||||
f"ID mismatch for {key}: {data[id_key]} != {value.id}"
|
||||
)
|
||||
else:
|
||||
# If the id_key does not exist, set it to the value's id
|
||||
data[id_key] = value.id
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ProblemDetails(ApiModel):
|
||||
type: str = Field(default="about:blank")
|
||||
title: str
|
||||
status: int
|
||||
detail: Optional[str] = None
|
||||
instance: Optional[str] = None
|
||||
errors: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Page(ApiModel, Generic[T]):
|
||||
items: List[T]
|
||||
next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
|
||||
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
|
||||
total: Optional[int] = Field(default=None, description="Optional total count")
|
||||
|
|
|
|||
28
db.py
28
db.py
|
|
@ -1,29 +1,39 @@
|
|||
import asyncio
|
||||
|
||||
import aiosqlite
|
||||
|
||||
async def connect(path = './data/doof.sqlite') -> aiosqlite.Connection:
|
||||
|
||||
async def connect(path="./data/doof.sqlite") -> aiosqlite.Connection:
|
||||
return await aiosqlite.connect(path)
|
||||
|
||||
|
||||
async def create(conn: aiosqlite.Connection):
|
||||
import products.db as product_db
|
||||
import products.repository as product_db
|
||||
|
||||
await product_db.create(conn)
|
||||
|
||||
import ingredients.db as ingredient_db
|
||||
import ingredients.repository as ingredient_db
|
||||
|
||||
await ingredient_db.create(conn)
|
||||
|
||||
import recipes.db as recipe_db
|
||||
import recipes.repository as recipe_db
|
||||
|
||||
await recipe_db.create(conn)
|
||||
|
||||
import persons.db as person_db
|
||||
import persons.repository as person_db
|
||||
|
||||
await person_db.create(conn)
|
||||
|
||||
import meals.db as meals_db
|
||||
import meals.repository as meals_db
|
||||
|
||||
await meals_db.create(conn)
|
||||
|
||||
import shopping.db as shopping_db
|
||||
import shopping.repository as shopping_db
|
||||
|
||||
await shopping_db.create(conn)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
if __name__ == "__main__":
|
||||
from tests.test_data import create_test_data
|
||||
|
||||
async def main():
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
from ingredients.db import Ingredient, find_ingredient_by_id, find_ingredients_by_meal_id, find_ingredients_by_recipe_id, insert_ingredient, delete_ingredients_by_meal_id
|
||||
|
||||
import units
|
||||
from products import Product, find_product_by_tag, get_or_create, add_missing_tags
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from ingredient_parser import parse_ingredient
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
import units
|
||||
from ingredients.models import Ingredient
|
||||
from ingredients.repository import (
|
||||
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,
|
||||
find_ingredients_by_recipe_ids as find_ingredients_by_recipe_ids,
|
||||
insert_ingredient as insert_ingredient,
|
||||
)
|
||||
from products import Product, add_missing_tags, find_product_by_tag, get_or_create
|
||||
|
||||
async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
|
||||
match = re.match(r'^(\d+)?\s*(http.*)$', link)
|
||||
|
||||
async def parse_ingredient_from_link(conn, link: str) -> Optional[Ingredient]:
|
||||
match = re.match(r"^(\d+)?\s*(http.*)$", link)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
|
|
@ -19,19 +27,22 @@ async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
|
|||
if product:
|
||||
await add_missing_tags(conn, product, [product.name])
|
||||
|
||||
return Ingredient(id=-1,
|
||||
return Ingredient(
|
||||
id=-1,
|
||||
name=product.name,
|
||||
line=f"{quantity}x {product.name}",
|
||||
unit=units.ITEMS.name,
|
||||
quantity=quantity,
|
||||
preparation='',
|
||||
preparation="",
|
||||
product_id=product.id,
|
||||
product=product
|
||||
product=product,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
|
||||
ingredient = parse_ingredient(ingredient_string)
|
||||
name = ingredient.name.text if ingredient.name else ''
|
||||
name = ingredient.name.text if ingredient.name else ""
|
||||
|
||||
quantity, unit = None, None
|
||||
for amount in ingredient.amount:
|
||||
|
|
@ -55,24 +66,21 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
|
|||
if unit is None:
|
||||
unit = units.ITEMS.name
|
||||
|
||||
return Ingredient(id=-1,
|
||||
return Ingredient(
|
||||
id=-1,
|
||||
line=ingredient.sentence,
|
||||
name=name,
|
||||
quantity=quantity,
|
||||
unit=unit,
|
||||
preparation=ingredient.preparation.text if ingredient.preparation else '',
|
||||
product_id=-1
|
||||
preparation=ingredient.preparation.text if ingredient.preparation else "",
|
||||
product_id=-1,
|
||||
)
|
||||
|
||||
async def _find_existing_product(conn, ingredient: str) -> Product:
|
||||
gen = find_product_by_tag(conn, ingredient)
|
||||
try:
|
||||
async for item in gen:
|
||||
return item
|
||||
finally:
|
||||
await gen.aclose()
|
||||
|
||||
return None
|
||||
async def _find_existing_product(conn, ingredient: str) -> Optional[Product]:
|
||||
items = [item async for item in find_product_by_tag(conn, ingredient)]
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[Ingredient]:
|
||||
for ingredient in ingredients:
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
from products import Product
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, List, ClassVar, Optional
|
||||
|
||||
class Ingredient(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id']
|
||||
id: int = -1
|
||||
name: str
|
||||
line: str
|
||||
unit: str
|
||||
quantity: float
|
||||
preparation: str
|
||||
product_id: Optional[int] = None
|
||||
recipe_id: Optional[int] = None
|
||||
meal_id: Optional[int] = None
|
||||
product: Optional[Product] = None
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Ingredient (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
line TEXT,
|
||||
preparation TEXT,
|
||||
unit TEXT,
|
||||
quantity REAL,
|
||||
product_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
meal_id INTEGER,
|
||||
FOREIGN KEY (product_id) REFERENCES Product(id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES Recipe(id),
|
||||
FOREIGN KEY (meal_id) REFERENCES Meal(id)
|
||||
);''')
|
||||
|
||||
async def insert_ingredient(conn, ingredient: Ingredient):
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
if ingredient.product_id is None or ingredient.product_id < 0:
|
||||
ingredient.product_id = None
|
||||
|
||||
async with conn.execute('''
|
||||
INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (ingredient.name, ingredient.line, ingredient.preparation, ingredient.unit, ingredient.quantity, ingredient.product_id, ingredient.recipe_id, ingredient.meal_id)) as cursor:
|
||||
ingredient.id = cursor.lastrowid
|
||||
|
||||
async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient]:
|
||||
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
|
||||
product_keys = [f'product.{key}' for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE Ingredient.id = ?
|
||||
''', (ingredient_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])}
|
||||
product = Product(**product_keys) if product_keys['id'] else None
|
||||
return Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product)
|
||||
return None
|
||||
|
||||
async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[Ingredient]:
|
||||
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
|
||||
product_keys = [f'product.{key}' for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE recipe_id = ?
|
||||
''', (recipe_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])}
|
||||
product = Product(**product_keys) if product_keys['id'] else None
|
||||
yield Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product)
|
||||
|
||||
async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingredient]:
|
||||
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
|
||||
product_keys = [f'product.{key}' for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(ingredient_keys + product_keys)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE meal_id = ?
|
||||
''', (meal_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):])}
|
||||
product = Product(**product_keys) if product_keys['id'] else None
|
||||
yield Ingredient(**{k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}, product=product)
|
||||
|
||||
async def delete_ingredients_by_meal_id(conn, meal_id: int):
|
||||
await conn.execute('''
|
||||
DELETE FROM Ingredient
|
||||
WHERE meal_id = ?
|
||||
''', (meal_id,))
|
||||
48
ingredients/models.py
Normal file
48
ingredients/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from common import ApiModel
|
||||
from products import Product
|
||||
from units import ALL_UNITS
|
||||
|
||||
|
||||
class Ingredient(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"name",
|
||||
"line",
|
||||
"preparation",
|
||||
"unit",
|
||||
"quantity",
|
||||
"product_id",
|
||||
"recipe_id",
|
||||
"meal_id",
|
||||
]
|
||||
id: int = -1
|
||||
name: str
|
||||
line: str
|
||||
unit: str = Field(
|
||||
title="Unit",
|
||||
description="Measurement unit (enum values are advisory; runtime accepts any string)",
|
||||
json_schema_extra={"enum": [u.name for u in ALL_UNITS]},
|
||||
)
|
||||
quantity: float
|
||||
preparation: str
|
||||
product_id: Optional[int] = None
|
||||
recipe_id: Optional[int] = None
|
||||
meal_id: Optional[int] = None
|
||||
product: Optional[Product] = None
|
||||
|
||||
# Ensure quantity is stored as a float even if provided as a string in tests
|
||||
@field_validator("quantity", mode="before")
|
||||
@classmethod
|
||||
def _coerce_quantity(cls, v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return v
|
||||
return v
|
||||
155
ingredients/repository.py
Normal file
155
ingredients/repository.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from ingredients.models import Ingredient
|
||||
from products.models import Product
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Ingredient (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
line TEXT,
|
||||
preparation TEXT,
|
||||
unit TEXT,
|
||||
quantity REAL,
|
||||
product_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
meal_id INTEGER,
|
||||
FOREIGN KEY (product_id) REFERENCES Product(id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES Recipe(id),
|
||||
FOREIGN KEY (meal_id) REFERENCES Meal(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_ingredient_recipe_id ON Ingredient(recipe_id);")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_ingredient_meal_id ON Ingredient(meal_id);")
|
||||
|
||||
|
||||
async def insert_ingredient(conn, ingredient: Ingredient):
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
if ingredient.product_id is None or ingredient.product_id < 0:
|
||||
ingredient.product_id = None
|
||||
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO Ingredient (name, line, preparation, unit, quantity, product_id, recipe_id, meal_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ingredient.name,
|
||||
ingredient.line,
|
||||
ingredient.preparation,
|
||||
ingredient.unit,
|
||||
ingredient.quantity,
|
||||
ingredient.product_id,
|
||||
ingredient.recipe_id,
|
||||
ingredient.meal_id,
|
||||
),
|
||||
) as cursor:
|
||||
ingredient.id = cursor.lastrowid
|
||||
|
||||
|
||||
async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient]:
|
||||
ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
|
||||
product_cols = [f"product.{key}" for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE Ingredient.id = ?
|
||||
""",
|
||||
(ingredient_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
|
||||
product = Product(**product_map) if product_map["id"] else None
|
||||
return Ingredient(
|
||||
**{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])},
|
||||
product=product,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[Ingredient]:
|
||||
ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
|
||||
product_cols = [f"product.{key}" for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE recipe_id = ?
|
||||
""",
|
||||
(recipe_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
|
||||
product = Product(**product_map) if product_map["id"] else None
|
||||
yield Ingredient(
|
||||
**{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])},
|
||||
product=product,
|
||||
)
|
||||
|
||||
|
||||
async def find_ingredients_by_recipe_ids(conn, recipe_ids: List[int]) -> dict[int, List[Ingredient]]:
|
||||
"""Fetch ingredients for many recipes in one query. Returns recipe_id -> [Ingredient]."""
|
||||
if not recipe_ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(recipe_ids))
|
||||
ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
|
||||
product_cols = [f"product.{key}" for key in Product.KEYS]
|
||||
query = f"""
|
||||
SELECT {','.join(ingredient_cols + product_cols)}
|
||||
FROM Ingredient AS ingredient
|
||||
LEFT JOIN Product AS product ON ingredient.product_id = product.id
|
||||
WHERE ingredient.recipe_id IN ({placeholders})
|
||||
ORDER BY ingredient.recipe_id, ingredient.id
|
||||
"""
|
||||
result: dict[int, List[Ingredient]] = {rid: [] for rid in recipe_ids}
|
||||
async with conn.execute(query, recipe_ids) as cursor:
|
||||
async for row in cursor:
|
||||
product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
|
||||
product = Product(**product_map) if product_map["id"] else None
|
||||
ing = Ingredient(
|
||||
**{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])},
|
||||
product=product,
|
||||
)
|
||||
if ing.recipe_id is not None:
|
||||
result.setdefault(int(ing.recipe_id), []).append(ing)
|
||||
return result
|
||||
|
||||
|
||||
async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingredient]:
|
||||
ingredient_cols = [f"ingredient.{key}" for key in Ingredient.KEYS]
|
||||
product_cols = [f"product.{key}" for key in Product.KEYS]
|
||||
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
|
||||
LEFT JOIN Product ON Ingredient.product_id = Product.id
|
||||
WHERE meal_id = ?
|
||||
""",
|
||||
(meal_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
product_map = {k: v for k, v in zip(Product.KEYS, row[len(Ingredient.KEYS) :])}
|
||||
product = Product(**product_map) if product_map["id"] else None
|
||||
yield Ingredient(
|
||||
**{k: v for k, v in zip(Ingredient.KEYS, row[: len(Ingredient.KEYS)])},
|
||||
product=product,
|
||||
)
|
||||
|
||||
|
||||
async def delete_ingredients_by_meal_id(conn, meal_id: int):
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM Ingredient
|
||||
WHERE meal_id = ?
|
||||
""",
|
||||
(meal_id,),
|
||||
)
|
||||
563
main.py
563
main.py
|
|
@ -1,412 +1,155 @@
|
|||
import sqlite3
|
||||
import products, recipes, db, meals, persons, ingredients, shopping
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Annotated, Optional, Union
|
||||
from fastapi import FastAPI, Depends, Query, Cookie
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
import os
|
||||
DATABASE_PATH = os.environ.get('DOOF_DB', './data/doof.sqlite')
|
||||
|
||||
# Dependency to create SQLite connection
|
||||
async def get_db():
|
||||
sql_db = await db.connect(DATABASE_PATH)
|
||||
try:
|
||||
yield sql_db
|
||||
finally:
|
||||
await sql_db.close()
|
||||
|
||||
async def cookie_person(user_id: Annotated[int, Cookie(alias='user_id')], conn: sqlite3.Connection = Depends(get_db)) -> persons.Person:
|
||||
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:
|
||||
parsed = await recipes.parse_recipe(conn, person, url)
|
||||
if not parsed:
|
||||
return JSONResponse(status_code=400, content={'message': 'Recipe not found'})
|
||||
return parsed
|
||||
|
||||
@app.get("/api/recipes/ingredients/parse")
|
||||
async def parse_ingredients(lines: Annotated[
|
||||
List[str],
|
||||
Query(alias="ingredients",
|
||||
title="Array of ingredients to parse")],
|
||||
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]:
|
||||
|
||||
had_links = False
|
||||
result = []
|
||||
for line in lines:
|
||||
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
had_links = True
|
||||
continue
|
||||
|
||||
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
continue
|
||||
|
||||
if had_links:
|
||||
await conn.commit()
|
||||
|
||||
await ingredients.match_existing_products(conn, result)
|
||||
return result
|
||||
|
||||
class ProductUrl(BaseModel):
|
||||
url: str
|
||||
tags: List[str] = []
|
||||
|
||||
@app.post("/api/products")
|
||||
async def create_product(url: ProductUrl, conn: sqlite3.Connection = Depends(get_db)) -> products.Product:
|
||||
return await products.get_or_create(conn, url.url, url.tags)
|
||||
|
||||
async def load_full_recipe(conn: sqlite3.Connection, id: int) -> recipes.Recipe:
|
||||
r = await recipes.find_recipe_by_id(conn, id)
|
||||
if not r:
|
||||
return None
|
||||
|
||||
r.ingredients = []
|
||||
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
|
||||
r.ingredients.append(ingredient)
|
||||
|
||||
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
||||
|
||||
return r
|
||||
|
||||
@app.get("/api/recipes")
|
||||
async def get_recipes(q: str | None = None, conn: sqlite3.Connection = Depends(get_db)) -> List[recipes.Recipe]:
|
||||
result = []
|
||||
if q:
|
||||
async for recipe in recipes.find_recipes_by_name(conn, q):
|
||||
result.append(recipe)
|
||||
else:
|
||||
async for recipe in recipes.get_all(conn):
|
||||
result.append(recipe)
|
||||
|
||||
for recipe in result:
|
||||
recipe.ingredients = []
|
||||
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, recipe.id):
|
||||
recipe.ingredients.append(ingredient)
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/api/recipes/{recipe_id}")
|
||||
async def get_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db)) -> recipes.Recipe:
|
||||
r = await load_full_recipe(conn, recipe_id)
|
||||
if not r:
|
||||
return JSONResponse(status_code=404, content={'message': 'Recipe not found'})
|
||||
|
||||
return r
|
||||
|
||||
@app.post('/api/recipes')
|
||||
async def create_recipe(recipe: recipes.Recipe, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
|
||||
if not recipe.ingredients:
|
||||
return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'})
|
||||
|
||||
if recipe.id >= 0:
|
||||
await recipes.hide_recipe(conn, recipe.id, user)
|
||||
recipe.based_on_recipe = recipe.id
|
||||
recipe.id = 0
|
||||
|
||||
recipe.created_by_id = user.id
|
||||
await recipes.insert_recipe(conn, recipe)
|
||||
for ingredient in recipe.ingredients:
|
||||
ingredient.recipe_id = recipe.id
|
||||
if ingredient.product:
|
||||
ingredient.product_id = ingredient.product.id
|
||||
|
||||
await ingredients.insert_ingredient(conn, ingredient)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return recipe
|
||||
|
||||
@app.delete('/recipes/{recipe_id}')
|
||||
async def delete_recipe(recipe_id: int, conn: sqlite3.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person)) -> recipes.Recipe:
|
||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||
if not recipe:
|
||||
return JSONResponse(status_code=404, content={'message': 'Recipe not found'})
|
||||
|
||||
await recipes.hide_recipe(conn, recipe_id, user)
|
||||
await conn.commit()
|
||||
return recipe
|
||||
|
||||
@app.get("/api/meals/upcoming")
|
||||
async def get_upcoming_meals(date_from: Annotated[datetime.datetime, Query(alias='from')], to: datetime.datetime, conn: sqlite3.Connection = Depends(get_db)) -> List[meals.Meal]:
|
||||
result = []
|
||||
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
|
||||
await meals.load_recipes(conn, meal)
|
||||
await meals.load_extra_ingredients(conn, meal)
|
||||
await meals.load_participants(conn, meal)
|
||||
result.append(meal)
|
||||
|
||||
return result
|
||||
|
||||
@app.get("/api/meals/{meal_id}")
|
||||
async def get_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
return meal
|
||||
|
||||
def get_duplicates(items: List[meals.Person]) -> set[str]:
|
||||
seen : set[int] = set()
|
||||
duplicates : set[str] = set()
|
||||
for item in items:
|
||||
if item.id in seen:
|
||||
duplicates.add(item.name)
|
||||
seen.add(item.id)
|
||||
return duplicates
|
||||
|
||||
def validate_meal(meal : meals.Meal) -> JSONResponse | None:
|
||||
if not meal.chefs:
|
||||
return JSONResponse(status_code=400, content={'message': 'Meal must have at least one chef'})
|
||||
|
||||
if not meal.cleanup:
|
||||
return JSONResponse(status_code=400, content={'message': 'Meal must have at least one cleanup person'})
|
||||
|
||||
if not meal.consumers:
|
||||
return JSONResponse(status_code=400, content={'message': 'Meal must have at least one consumer'})
|
||||
|
||||
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
||||
return JSONResponse(status_code=400, content={'message': 'Meal must have at least one recipe or ingredient'})
|
||||
|
||||
duplicates = get_duplicates(meal.chefs)
|
||||
if duplicates:
|
||||
return JSONResponse(status_code=400, content={'message': f'Duplicate chef: {", ".join(duplicates)}'})
|
||||
|
||||
duplicates = get_duplicates(meal.cleanup)
|
||||
if duplicates:
|
||||
return JSONResponse(status_code=400, content={'message': f'Duplicate cleanup person: {", ".join(duplicates)}'})
|
||||
|
||||
duplicates = get_duplicates(meal.consumers)
|
||||
if duplicates:
|
||||
return JSONResponse(status_code=400, content={'message': f'Duplicate consumer: {", ".join(duplicates)}'})
|
||||
|
||||
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
||||
if zero_servings:
|
||||
return JSONResponse(status_code=400, content={'message': 'Recipe servings must be greater than 0'})
|
||||
|
||||
return None
|
||||
|
||||
@app.post("/api/meals")
|
||||
async def create_meal(meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
|
||||
validation_response = validate_meal(meal)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
await meals.insert_meal(conn, meal)
|
||||
await conn.commit()
|
||||
return meal
|
||||
|
||||
@app.put("/api/meals/{meal_id}")
|
||||
async def update_meal(meal_id: int, meal: meals.Meal, conn: sqlite3.Connection = Depends(get_db)) -> meals.Meal:
|
||||
if meal.id != meal_id:
|
||||
return JSONResponse(status_code=400, content={'message': 'Meal ID in URL does not match meal ID in body'})
|
||||
|
||||
existing = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not existing:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
validation_response = validate_meal(meal)
|
||||
if validation_response:
|
||||
return validation_response
|
||||
|
||||
await meals.update_meal(conn, meal)
|
||||
await conn.commit()
|
||||
|
||||
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:
|
||||
if consumed_date and not consumed_date.tzinfo:
|
||||
return JSONResponse(status_code=400, content={'message': 'Consumed date must include timezone'})
|
||||
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
|
||||
await conn.commit()
|
||||
return meal
|
||||
|
||||
@app.delete("/api/meals/{meal_id}")
|
||||
async def delete_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> meals.Meal:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
await meals.delete_meal(conn, meal.id)
|
||||
|
||||
await conn.commit()
|
||||
return meal
|
||||
|
||||
class CurrentShoppingList(BaseModel):
|
||||
outstanding_items: List[shopping.ShoppingListItem]
|
||||
requested_meals: List[shopping.ShoppingListItem]
|
||||
purchased_items: List[shopping.ShoppingListItem] = []
|
||||
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
|
||||
meals_lookup: Dict[int, meals.Meal] = {}
|
||||
shopping_list_lookup: Dict[int, shopping.ShoppingList] = {}
|
||||
recipes_lookup: Dict[int, recipes.Recipe] = {}
|
||||
|
||||
@app.get("/api/shopping/current")
|
||||
async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList:
|
||||
outstanding_requests, purchased_requests, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup = await shopping.get_outstanding_requests(conn)
|
||||
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
||||
|
||||
shopping_list_lookup = { list_id: await shopping.load_shopping_list(conn, list_id) for list_id in other_shopping_list_ids }
|
||||
|
||||
# Add any additional items from shopping lists to the existing lookups
|
||||
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
|
||||
if additional_items:
|
||||
await shopping.to_lookups(conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup)
|
||||
|
||||
return CurrentShoppingList(
|
||||
outstanding_items=outstanding_requests,
|
||||
requested_meals=meal_requests,
|
||||
purchased_items=purchased_requests,
|
||||
meals_lookup=meals_lookup,
|
||||
shopping_list_lookup=shopping_list_lookup,
|
||||
ingredients_lookup=ingredients_lookup,
|
||||
recipes_lookup=recipes_lookup
|
||||
)
|
||||
|
||||
class PurchasedShoppingList(BaseModel):
|
||||
list: shopping.ShoppingList
|
||||
meals_lookup: Dict[int, meals.Meal] = {}
|
||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = {}
|
||||
recipes_lookup: Dict[int, recipes.Recipe] = {}
|
||||
|
||||
@app.get("/api/shopping/{list_id}")
|
||||
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> PurchasedShoppingList:
|
||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||
if not shopping_list:
|
||||
return JSONResponse(status_code=404, content={'message': 'Shopping list not found'})
|
||||
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(conn, shopping_list.items)
|
||||
return PurchasedShoppingList(list=shopping_list, meals_lookup=meals_lookup, recipes_lookup=recipes_lookup, ingredients_lookup=ingredients_lookup)
|
||||
|
||||
@app.post("/api/shopping/")
|
||||
async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
|
||||
shopping_list = shopping.ShoppingList(purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name)
|
||||
|
||||
await shopping.purchase(conn, shopping_list)
|
||||
await conn.commit()
|
||||
|
||||
result = PurchasedShoppingList(list=shopping_list)
|
||||
await shopping.to_lookups(conn, shopping_list.items, result.meals_lookup, result.recipes_lookup, result.ingredients_lookup)
|
||||
return result
|
||||
|
||||
@app.get("/api/shopping/current/me/ingredients")
|
||||
async def get_my_shopping_list(conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
|
||||
return await shopping.get_persons_requests(conn, person.id)
|
||||
|
||||
@app.post("/api/shopping/current/me/ingredients")
|
||||
async def sync_my_shopping_list(requests: List[ingredients.Ingredient], conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
|
||||
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
|
||||
return a.id == b.id or a.line == b.line
|
||||
|
||||
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
|
||||
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
|
||||
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
|
||||
|
||||
for r in to_remove:
|
||||
await shopping.remove_request(conn, person, ingredient=r)
|
||||
|
||||
for r in to_add:
|
||||
if r.id < 0:
|
||||
await ingredients.insert_ingredient(conn, r)
|
||||
await shopping.request(conn, person, ingredient=r)
|
||||
|
||||
await conn.commit()
|
||||
return await get_my_shopping_list(conn, person)
|
||||
|
||||
class MealIdWrapper(BaseModel):
|
||||
meal_id: int
|
||||
|
||||
@app.post("/api/shopping/current/meals/me")
|
||||
async def request_meal(r: MealIdWrapper, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem:
|
||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||
if not meal:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
response = await shopping.request(conn, person, meal=meal)
|
||||
await conn.commit()
|
||||
return response
|
||||
|
||||
@app.delete("/api/shopping/current/meals/{meal_id}")
|
||||
async def unrequest_meal(meal_id: int, conn: sqlite3.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict:
|
||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||
if not meal:
|
||||
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
|
||||
|
||||
await shopping.remove_request(conn, person, meal=meal)
|
||||
await conn.commit()
|
||||
return {}
|
||||
|
||||
@app.get("/api/persons")
|
||||
async def get_persons(q: str = None, conn: sqlite3.Connection = Depends(get_db)) -> List[meals.Person]:
|
||||
query = persons.search_by_name(conn, q) if q else persons.get_all(conn)
|
||||
result = []
|
||||
async for person in query:
|
||||
result.append(person)
|
||||
|
||||
return result
|
||||
|
||||
@app.post("/api/persons")
|
||||
async def create_person(person: persons.Person, conn: sqlite3.Connection = Depends(get_db)) -> persons.Person:
|
||||
await persons.insert_person(conn, person)
|
||||
await conn.commit()
|
||||
return person
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
|
||||
@app.post('/api/auth/login')
|
||||
async def login(data: LoginBody, conn: sqlite3.Connection = Depends(get_db)) -> persons.Person:
|
||||
person = await persons.get_by_name(conn, data.username)
|
||||
if not person:
|
||||
return JSONResponse(status_code=404, content={'message': 'Person not found'})
|
||||
|
||||
response = JSONResponse(content=jsonable_encoder(person))
|
||||
response.set_cookie(key='user_id', value=str(person.id))
|
||||
return response
|
||||
|
||||
@app.post('/api/auth/refresh')
|
||||
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
||||
return user
|
||||
|
||||
if os.environ.get('DOOF_PROD', False):
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
||||
else:
|
||||
# Proxy the request to the frontend development server
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import ValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from api import (
|
||||
auth as auth_router,
|
||||
meals as meals_router,
|
||||
persons as persons_router,
|
||||
products as products_router,
|
||||
recipes as recipes_router,
|
||||
shopping as shopping_router,
|
||||
)
|
||||
from api.deps import (
|
||||
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||
error_response as error_response, # noqa: F401 - re-exported for completeness
|
||||
get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
|
||||
)
|
||||
from api.openapi import extend_with_problem_and_cookie_auth
|
||||
from common import ApiModel, ProblemDetails
|
||||
from settings import settings
|
||||
|
||||
|
||||
class CamelCaseRoute(APIRoute):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("response_model_by_alias", True)
|
||||
kwargs.setdefault("response_model_exclude_none", True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(app: FastAPI):
|
||||
client = None
|
||||
if not settings.prod:
|
||||
import httpx
|
||||
|
||||
client = httpx.AsyncClient(base_url="http://localhost:8080/")
|
||||
client = httpx.AsyncClient(base_url=settings.frontend_dev_url)
|
||||
app.state.proxy_client = client
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
async def _reverse_proxy(request: Request):
|
||||
url = httpx.URL(path=request.url.path,
|
||||
query=request.url.query.encode("utf-8"))
|
||||
rp_req = client.build_request(request.method, url,
|
||||
headers=request.headers.raw,
|
||||
content=request.stream())
|
||||
|
||||
# RFC7807 Problem Details handlers (standalone functions, registered in factory)
|
||||
async def http_exc_handler(request: Request, exc: Exception):
|
||||
# Narrow to StarletteHTTPException at runtime
|
||||
assert isinstance(exc, StarletteHTTPException)
|
||||
body = ProblemDetails(
|
||||
title=str(exc.detail) if exc.detail else "HTTP Error",
|
||||
status=exc.status_code,
|
||||
type=f"https://httpstatuses.com/{exc.status_code}",
|
||||
instance=str(request.url),
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True),
|
||||
status_code=exc.status_code,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
|
||||
async def validation_exc_handler(request: Request, exc: Exception):
|
||||
assert isinstance(exc, ValidationError)
|
||||
errors: Dict[str, Any] = {}
|
||||
for e in exc.errors():
|
||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||
errors.setdefault(loc, []).append(e.get("msg"))
|
||||
body = ProblemDetails(
|
||||
title="Validation Error",
|
||||
status=422,
|
||||
type="https://datatracker.ietf.org/doc/html/rfc7807",
|
||||
instance=str(request.url),
|
||||
errors=errors,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
|
||||
)
|
||||
|
||||
|
||||
async def request_validation_exc_handler(request: Request, exc: Exception):
|
||||
assert isinstance(exc, RequestValidationError)
|
||||
errors: Dict[str, Any] = {}
|
||||
for e in exc.errors():
|
||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||
errors.setdefault(loc, []).append(e.get("msg"))
|
||||
body = ProblemDetails(
|
||||
title="Validation Error",
|
||||
status=422,
|
||||
type="https://datatracker.ietf.org/doc/html/rfc7807",
|
||||
instance=str(request.url),
|
||||
errors=errors,
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
|
||||
)
|
||||
|
||||
|
||||
class HealthStatus(ApiModel):
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
async def healthz() -> HealthStatus:
|
||||
return HealthStatus()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
|
||||
|
||||
# OpenAPI augmentation
|
||||
extend_with_problem_and_cookie_auth(app)
|
||||
|
||||
# Exception handlers
|
||||
app.add_exception_handler(StarletteHTTPException, http_exc_handler)
|
||||
app.add_exception_handler(ValidationError, validation_exc_handler)
|
||||
app.add_exception_handler(RequestValidationError, request_validation_exc_handler)
|
||||
|
||||
# Routers
|
||||
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||
|
||||
# Routes
|
||||
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||
|
||||
# Static/proxy
|
||||
if settings.prod:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
||||
else:
|
||||
# Proxy the request to the frontend development server
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
async def _reverse_proxy(request: StarletteRequest):
|
||||
import httpx
|
||||
|
||||
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
||||
client = app.state.proxy_client
|
||||
rp_req = client.build_request(
|
||||
request.method, url, headers=request.headers.raw, content=request.stream()
|
||||
)
|
||||
rp_resp = await client.send(rp_req, stream=True)
|
||||
return StreamingResponse(
|
||||
rp_resp.aiter_raw(),
|
||||
|
|
@ -415,4 +158,14 @@ else:
|
|||
background=BackgroundTask(rp_resp.aclose),
|
||||
)
|
||||
|
||||
app.add_route("/{path:path}",_reverse_proxy, ["GET", "POST"])
|
||||
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Module-level app for uvicorn
|
||||
app = create_app()
|
||||
|
||||
DATABASE_PATH = settings.database_path
|
||||
|
||||
# get_db, cookie_person, and error_response are imported from api.deps
|
||||
|
|
|
|||
|
|
@ -1 +1,27 @@
|
|||
from meals.db import *
|
||||
from meals.models import Meal as Meal, MealRecipe as MealRecipe
|
||||
from meals.repository import (
|
||||
bulk_load_participants as bulk_load_participants,
|
||||
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 meals.roles import (
|
||||
ROLE_CHEF as ROLE_CHEF,
|
||||
ROLE_CLEANUP as ROLE_CLEANUP,
|
||||
ROLE_CONSUMER as ROLE_CONSUMER,
|
||||
)
|
||||
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
||||
from persons import Person as Person
|
||||
|
|
|
|||
226
meals/db.py
226
meals/db.py
|
|
@ -1,226 +0,0 @@
|
|||
from typing import AsyncIterator, List, ClassVar, Optional
|
||||
from pydantic import BaseModel
|
||||
from ingredients import Ingredient, insert_ingredient, find_ingredients_by_meal_id, delete_ingredients_by_meal_id
|
||||
|
||||
from recipes import Recipe, row_to_recipe, load_recipe_ingredients
|
||||
|
||||
import persons
|
||||
from persons import Person
|
||||
|
||||
import datetime
|
||||
|
||||
class MealRecipe(BaseModel):
|
||||
meal_id: int
|
||||
recipe_id: int
|
||||
servings: float
|
||||
|
||||
recipe: Optional[Recipe] = None
|
||||
|
||||
class Meal(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'suggested_date', 'consumed_date', 'purchase_date']
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[Person] = []
|
||||
cleanup: List[Person] = []
|
||||
consumers: List[Person] = []
|
||||
recipes: List[MealRecipe] = []
|
||||
extra_ingredients: List[Ingredient] = []
|
||||
|
||||
# Set from shopping list
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Meal (
|
||||
id INTEGER PRIMARY KEY,
|
||||
suggested_date DATETIME,
|
||||
consumed_date DATETIME DEFAULT NULL,
|
||||
deleted_date DATETIME DEFAULT NULL,
|
||||
purchase_date DATETIME DEFAULT NULL
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS MealParticipant (
|
||||
meal_id INTEGER,
|
||||
person_id INTEGER,
|
||||
role TEXT,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id)
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS MealRecipe (
|
||||
meal_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
servings REAL,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);''')
|
||||
|
||||
async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str):
|
||||
await conn.execute('''
|
||||
INSERT INTO MealParticipant (meal_id, person_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
''', (meal_id, person_id, role))
|
||||
|
||||
async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str):
|
||||
await conn.execute('''
|
||||
DELETE FROM MealParticipant
|
||||
WHERE meal_id = ? AND role = ?
|
||||
''', (meal_id, role))
|
||||
|
||||
for person in participants:
|
||||
await insert_meal_participant(conn, meal_id, person.id, role)
|
||||
|
||||
async def insert_meal_recipe(conn, r: MealRecipe):
|
||||
if r.meal_id < 0:
|
||||
raise ValueError('Meal must be inserted before meal recipe')
|
||||
|
||||
if r.recipe_id < 0 and r.recipe:
|
||||
r.recipe_id = r.recipe.id
|
||||
|
||||
if r.recipe_id < 0:
|
||||
raise ValueError('Recipe must be inserted before meal')
|
||||
|
||||
await conn.execute('''
|
||||
INSERT INTO MealRecipe (meal_id, recipe_id, servings)
|
||||
VALUES (?, ?, ?)
|
||||
''', (r.meal_id, r.recipe_id, r.servings))
|
||||
|
||||
async def insert_meal(conn, meal: Meal):
|
||||
async with conn.execute('''
|
||||
INSERT INTO Meal (suggested_date)
|
||||
VALUES (?)
|
||||
''', (meal.suggested_date.isoformat(),)) as cursor:
|
||||
meal.id = cursor.lastrowid
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, 'chef')
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup')
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer')
|
||||
|
||||
for meal_recipe in meal.recipes:
|
||||
meal_recipe.meal_id = meal.id
|
||||
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
|
||||
async def find_meal_by_id(conn, meal_id: int) -> Meal:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (meal_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
meal = Meal(**{k:v for k,v in zip(Meal.KEYS, row)})
|
||||
|
||||
await load_participants(conn, meal)
|
||||
await load_recipes(conn, meal)
|
||||
await load_extra_ingredients(conn, meal)
|
||||
return meal
|
||||
|
||||
async def find_upcoming_meals_by_date_range(conn, start: datetime, end: datetime) -> AsyncIterator[Meal]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL
|
||||
''', (start, end)) as cursor:
|
||||
async for row in cursor:
|
||||
yield Meal(**{k:v for k,v in zip(Meal.KEYS, row)})
|
||||
|
||||
async def load_participants(conn, meal: Meal) -> None:
|
||||
async with conn.execute(f'''
|
||||
SELECT person_id, role FROM MealParticipant
|
||||
WHERE meal_id = ?
|
||||
''', (meal.id,)) as cursor:
|
||||
async for row in cursor:
|
||||
person = await persons.get_by_id(conn, row[0])
|
||||
if row[1] == 'chef':
|
||||
meal.chefs.append(person)
|
||||
elif row[1] == 'cleanup':
|
||||
meal.cleanup.append(person)
|
||||
elif row[1] == 'consumer':
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f'Unknown role: {row[1]}')
|
||||
|
||||
async def load_recipes(conn, meal: Meal) -> None:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
|
||||
FROM Recipe
|
||||
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
|
||||
WHERE MealRecipe.meal_id = ?
|
||||
''', (meal.id,)) as cursor:
|
||||
async for row in cursor:
|
||||
recipe = row_to_recipe(zip(Recipe.KEYS, row[:-1]))
|
||||
await load_recipe_ingredients(conn, recipe)
|
||||
|
||||
meal.recipes.append(MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=row[-1], recipe=recipe))
|
||||
|
||||
async def load_extra_ingredients(conn, meal: Meal) -> None:
|
||||
async for ingredient in find_ingredients_by_meal_id(conn, meal.id):
|
||||
meal.extra_ingredients.append(ingredient)
|
||||
|
||||
async def delete_meal(conn, meal_id: int) -> None:
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET deleted_date = ?
|
||||
WHERE id = ?
|
||||
''', (datetime.datetime.now().astimezone().isoformat(), meal_id))
|
||||
|
||||
async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredient]) -> None:
|
||||
await delete_ingredients_by_meal_id(conn, meal_id)
|
||||
|
||||
for ingredient in ingredients:
|
||||
ingredient.meal_id = meal_id
|
||||
ingredient.recipe_id = None
|
||||
|
||||
await insert_ingredient(conn, ingredient)
|
||||
|
||||
async def sync_meal_recipes(conn, meal_id: int, recipes: List[Recipe]) -> None:
|
||||
await conn.execute('''
|
||||
DELETE FROM MealRecipe
|
||||
WHERE meal_id = ?
|
||||
''', (meal_id,))
|
||||
|
||||
for meal_recipe in recipes:
|
||||
if meal_recipe.meal_id >= 0 and meal_recipe.meal_id != meal_id:
|
||||
raise ValueError('Already associated with another meal')
|
||||
|
||||
meal_recipe.meal_id = meal_id
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
async def update_meal(conn, meal: Meal) -> None:
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET suggested_date = ?
|
||||
WHERE id = ?
|
||||
''', (meal.suggested_date.isoformat(), meal.id))
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, 'chef')
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup')
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer')
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
await sync_meal_recipes(conn, meal.id, meal.recipes)
|
||||
|
||||
async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None:
|
||||
meal.consumed_date = date
|
||||
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET consumed_date = ?
|
||||
WHERE id = ?
|
||||
''', (date.isoformat(), meal.id))
|
||||
|
||||
async def mark_purchased(conn, meal: Meal) -> Meal:
|
||||
meal.purchase_date = datetime.datetime.now().astimezone()
|
||||
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET purchase_date = ?
|
||||
WHERE id = ?
|
||||
''', (meal.purchase_date.isoformat(), meal.id))
|
||||
|
||||
return meal
|
||||
35
meals/models.py
Normal file
35
meals/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
from recipes import Recipe
|
||||
|
||||
|
||||
class MealRecipe(ApiModel):
|
||||
meal_id: int
|
||||
recipe_id: int
|
||||
servings: float
|
||||
|
||||
recipe: Optional[Recipe] = None
|
||||
|
||||
|
||||
class Meal(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[Person] = Field(default_factory=list)
|
||||
cleanup: List[Person] = Field(default_factory=list)
|
||||
consumers: List[Person] = Field(default_factory=list)
|
||||
recipes: List[MealRecipe] = Field(default_factory=list)
|
||||
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
|
||||
# Set from shopping list
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
354
meals/repository.py
Normal file
354
meals/repository.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import datetime
|
||||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from ingredients import (
|
||||
Ingredient,
|
||||
delete_ingredients_by_meal_id,
|
||||
find_ingredients_by_meal_id,
|
||||
insert_ingredient,
|
||||
)
|
||||
from meals.models import Meal, MealRecipe
|
||||
from persons.models import Person
|
||||
from persons.repository import get_by_ids as persons_get_by_ids
|
||||
from recipes.models import Recipe
|
||||
from recipes.repository import load_recipe_ingredients, row_to_recipe
|
||||
|
||||
from .roles import ROLE_CHEF, ROLE_CLEANUP, ROLE_CONSUMER
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Meal (
|
||||
id INTEGER PRIMARY KEY,
|
||||
suggested_date DATETIME,
|
||||
consumed_date DATETIME DEFAULT NULL,
|
||||
deleted_date DATETIME DEFAULT NULL,
|
||||
purchase_date DATETIME DEFAULT NULL
|
||||
);"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS MealParticipant (
|
||||
meal_id INTEGER,
|
||||
person_id INTEGER,
|
||||
role TEXT,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_meal_participants_meal_role ON MealParticipant(meal_id, role);")
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS MealRecipe (
|
||||
meal_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
servings REAL,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);"""
|
||||
)
|
||||
|
||||
# Index for faster lookup of recipes by meal
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_meal_recipes_meal_id ON MealRecipe(meal_id);")
|
||||
|
||||
|
||||
async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO MealParticipant (meal_id, person_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(meal_id, person_id, role),
|
||||
)
|
||||
|
||||
|
||||
async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str):
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM MealParticipant
|
||||
WHERE meal_id = ? AND role = ?
|
||||
""",
|
||||
(meal_id, role),
|
||||
)
|
||||
|
||||
for person in participants:
|
||||
await insert_meal_participant(conn, meal_id, person.id, role)
|
||||
|
||||
|
||||
async def insert_meal_recipe(conn, r: MealRecipe):
|
||||
if r.meal_id < 0:
|
||||
raise ValueError("Meal must be inserted before meal recipe")
|
||||
|
||||
if r.recipe_id < 0 and r.recipe:
|
||||
r.recipe_id = r.recipe.id
|
||||
|
||||
if r.recipe_id < 0:
|
||||
raise ValueError("Recipe must be inserted before meal")
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO MealRecipe (meal_id, recipe_id, servings)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(r.meal_id, r.recipe_id, r.servings),
|
||||
)
|
||||
|
||||
|
||||
async def insert_meal(conn, meal: Meal):
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO Meal (suggested_date)
|
||||
VALUES (?)
|
||||
""",
|
||||
(meal.suggested_date.isoformat(),),
|
||||
) as cursor:
|
||||
meal.id = cursor.lastrowid
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, ROLE_CHEF)
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, ROLE_CLEANUP)
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, ROLE_CONSUMER)
|
||||
|
||||
for meal_recipe in meal.recipes:
|
||||
meal_recipe.meal_id = meal.id
|
||||
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
|
||||
|
||||
async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(meal_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
meal = Meal(**{k: v for k, v in zip(Meal.KEYS, row)})
|
||||
|
||||
await load_participants(conn, meal)
|
||||
await load_recipes(conn, meal)
|
||||
await load_extra_ingredients(conn, meal)
|
||||
return meal
|
||||
return None
|
||||
|
||||
|
||||
async def find_upcoming_meals_by_date_range(
|
||||
conn, start: datetime.datetime, end: datetime.datetime
|
||||
) -> AsyncIterator[Meal]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL
|
||||
""",
|
||||
(start, end),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)})
|
||||
|
||||
|
||||
async def load_participants(conn, meal: Meal) -> None:
|
||||
# Fetch all participant links
|
||||
links: list[tuple[int, str]] = []
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT person_id, role FROM MealParticipant
|
||||
WHERE meal_id = ?
|
||||
""",
|
||||
(meal.id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
links.append((int(row[0]), str(row[1])))
|
||||
|
||||
if not links:
|
||||
return
|
||||
|
||||
# Bulk load persons by id
|
||||
unique_ids = sorted({pid for pid, _ in links})
|
||||
people = await persons_get_by_ids(conn, unique_ids)
|
||||
|
||||
for pid, role in links:
|
||||
person = people.get(pid)
|
||||
if role == ROLE_CHEF:
|
||||
if person:
|
||||
meal.chefs.append(person)
|
||||
elif role == ROLE_CLEANUP:
|
||||
if person:
|
||||
meal.cleanup.append(person)
|
||||
elif role == ROLE_CONSUMER:
|
||||
if person:
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
||||
async def bulk_load_participants(conn, meals: List[Meal]) -> None:
|
||||
"""Populate participants for many meals in one query to avoid N+1.
|
||||
|
||||
For each meal, fills meal.chefs, meal.cleanup, meal.consumers using a bulk
|
||||
lookup of MealParticipant rows and a single persons.get_by_ids fetch.
|
||||
"""
|
||||
if not meals:
|
||||
return
|
||||
|
||||
meal_ids = [m.id for m in meals]
|
||||
placeholders = ",".join(["?"] * len(meal_ids))
|
||||
|
||||
# Collect (meal_id -> [(person_id, role), ...]) and dedupe person IDs
|
||||
links_by_meal: dict[int, list[tuple[int, str]]] = {mid: [] for mid in meal_ids}
|
||||
person_ids: set[int] = set()
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT meal_id, person_id, role
|
||||
FROM MealParticipant
|
||||
WHERE meal_id IN ({placeholders})
|
||||
""",
|
||||
meal_ids,
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
mid, pid, role = int(row[0]), int(row[1]), str(row[2])
|
||||
links_by_meal.setdefault(mid, []).append((pid, role))
|
||||
person_ids.add(pid)
|
||||
|
||||
if not person_ids:
|
||||
return
|
||||
|
||||
# Bulk load persons once
|
||||
people = await persons_get_by_ids(conn, sorted(person_ids))
|
||||
|
||||
# Assign per meal
|
||||
by_id = {m.id: m for m in meals}
|
||||
for mid, links in links_by_meal.items():
|
||||
meal = by_id.get(mid)
|
||||
if not meal:
|
||||
continue
|
||||
# Reset roles to avoid duplicates
|
||||
meal.chefs = []
|
||||
meal.cleanup = []
|
||||
meal.consumers = []
|
||||
for pid, role in links:
|
||||
person = people.get(pid)
|
||||
if not person:
|
||||
continue
|
||||
if role == ROLE_CHEF:
|
||||
meal.chefs.append(person)
|
||||
elif role == ROLE_CLEANUP:
|
||||
meal.cleanup.append(person)
|
||||
elif role == ROLE_CONSUMER:
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f"Unknown role: {role}")
|
||||
|
||||
|
||||
async def load_recipes(conn, meal: Meal) -> None:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
|
||||
FROM Recipe
|
||||
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
|
||||
WHERE MealRecipe.meal_id = ?
|
||||
""",
|
||||
(meal.id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
recipe = row_to_recipe(list(zip(Recipe.KEYS, row[:-1])))
|
||||
await load_recipe_ingredients(conn, recipe)
|
||||
|
||||
meal.recipes.append(
|
||||
MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=row[-1], recipe=recipe)
|
||||
)
|
||||
|
||||
|
||||
async def load_extra_ingredients(conn, meal: Meal) -> None:
|
||||
async for ingredient in find_ingredients_by_meal_id(conn, meal.id):
|
||||
meal.extra_ingredients.append(ingredient)
|
||||
|
||||
|
||||
async def delete_meal(conn, meal_id: int) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Meal
|
||||
SET deleted_date = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(datetime.datetime.now().astimezone().isoformat(), meal_id),
|
||||
)
|
||||
|
||||
|
||||
async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredient]) -> None:
|
||||
await delete_ingredients_by_meal_id(conn, meal_id)
|
||||
|
||||
for ingredient in ingredients:
|
||||
ingredient.meal_id = meal_id
|
||||
ingredient.recipe_id = None
|
||||
|
||||
await insert_ingredient(conn, ingredient)
|
||||
|
||||
|
||||
async def sync_meal_recipes(conn, meal_id: int, recipes: List[MealRecipe]) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM MealRecipe
|
||||
WHERE meal_id = ?
|
||||
""",
|
||||
(meal_id,),
|
||||
)
|
||||
|
||||
for meal_recipe in recipes:
|
||||
if meal_recipe.meal_id >= 0 and meal_recipe.meal_id != meal_id:
|
||||
raise ValueError("Already associated with another meal")
|
||||
|
||||
meal_recipe.meal_id = meal_id
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
|
||||
async def update_meal(conn, meal: Meal) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Meal
|
||||
SET suggested_date = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(meal.suggested_date.isoformat(), meal.id),
|
||||
)
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, ROLE_CHEF)
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, ROLE_CLEANUP)
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, ROLE_CONSUMER)
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
await sync_meal_recipes(conn, meal.id, meal.recipes)
|
||||
|
||||
|
||||
async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None:
|
||||
meal.consumed_date = date
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Meal
|
||||
SET consumed_date = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(date.isoformat(), meal.id),
|
||||
)
|
||||
|
||||
|
||||
async def mark_purchased(conn, meal: Meal) -> Meal:
|
||||
meal.purchase_date = datetime.datetime.now().astimezone()
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Meal
|
||||
SET purchase_date = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(meal.purchase_date.isoformat(), meal.id),
|
||||
)
|
||||
|
||||
return meal
|
||||
6
meals/roles.py
Normal file
6
meals/roles.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Centralized participant role constants to avoid string duplication/typos
|
||||
ROLE_CHEF = "chef"
|
||||
ROLE_CLEANUP = "cleanup"
|
||||
ROLE_CONSUMER = "consumer"
|
||||
54
meals/service.py
Normal file
54
meals/service.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Set
|
||||
|
||||
from meals.models import Meal
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
def get_duplicates(items: List[Person]) -> Set[str]:
|
||||
"""Return the set of duplicate person names based on repeated ids."""
|
||||
seen: set[int] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item in items:
|
||||
if item.id in seen:
|
||||
duplicates.add(item.name)
|
||||
seen.add(item.id)
|
||||
return duplicates
|
||||
|
||||
|
||||
def validate_meal(meal: Meal) -> str | None:
|
||||
"""Validate a Meal domain model.
|
||||
|
||||
Returns:
|
||||
None if valid, otherwise a human-readable error message.
|
||||
"""
|
||||
if not meal.chefs:
|
||||
return "Meal must have at least one chef"
|
||||
|
||||
if not meal.cleanup:
|
||||
return "Meal must have at least one cleanup person"
|
||||
|
||||
if not meal.consumers:
|
||||
return "Meal must have at least one consumer"
|
||||
|
||||
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
||||
return "Meal must have at least one recipe or ingredient"
|
||||
|
||||
duplicates = get_duplicates(meal.chefs)
|
||||
if duplicates:
|
||||
return f"Duplicate chef: {', '.join(duplicates)}"
|
||||
|
||||
duplicates = get_duplicates(meal.cleanup)
|
||||
if duplicates:
|
||||
return f"Duplicate cleanup person: {', '.join(duplicates)}"
|
||||
|
||||
duplicates = get_duplicates(meal.consumers)
|
||||
if duplicates:
|
||||
return f"Duplicate consumer: {', '.join(duplicates)}"
|
||||
|
||||
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
||||
if zero_servings:
|
||||
return "Recipe servings must be greater than 0"
|
||||
|
||||
return None
|
||||
2538
openapi.json
Normal file
2538
openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,15 @@
|
|||
from persons.db import *
|
||||
from persons.models import Person as Person
|
||||
from persons.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
create as create,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
get_by_id as get_by_id,
|
||||
get_by_ids as get_by_ids,
|
||||
get_by_name as get_by_name,
|
||||
insert_person as insert_person,
|
||||
search_by_name as search_by_name,
|
||||
search_by_name_paged as search_by_name_paged,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, ClassVar, List
|
||||
|
||||
class Person(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'name']
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE
|
||||
);''')
|
||||
|
||||
async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
|
||||
async with conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
''', (f'%{name}%',)) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_name(conn, name: str) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name = ?
|
||||
''', (name,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
async def get_by_id(conn, id: int) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id = ?
|
||||
''', (id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Person]:
|
||||
async with conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
''') as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
async def insert_person(conn, person: Person) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
INSERT INTO Person (name)
|
||||
VALUES (?)
|
||||
''', (person.name,))
|
||||
person.id = cursor.lastrowid
|
||||
return person
|
||||
10
persons/models.py
Normal file
10
persons/models.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from typing import ClassVar, List
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Person(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
192
persons/repository.py
Normal file
192
persons/repository.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from typing import AsyncIterator, List, Optional
|
||||
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for search and pagination
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_person_name ON Person(name);")
|
||||
|
||||
|
||||
async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_name(conn, name: str) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name = ?
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_id(conn, id: int) -> Optional[Person]:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id = ?
|
||||
""",
|
||||
(id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_ids(conn, ids: List[int]) -> dict[int, Person]:
|
||||
"""Fetch many persons in a single query. Returns a dict id->Person.
|
||||
|
||||
If ids is empty, returns {}.
|
||||
"""
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join(["?"] * len(ids))
|
||||
query = f"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id IN ({placeholders})
|
||||
"""
|
||||
result: dict[int, Person] = {}
|
||||
async with conn.execute(query, ids) as cursor:
|
||||
async for row in cursor:
|
||||
p = Person(id=row[0], name=row[1])
|
||||
result[p.id] = p
|
||||
return result
|
||||
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Person]:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
"""
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def search_by_name_paged(
|
||||
conn, name: str, after_id: Optional[int], limit: int
|
||||
) -> AsyncIterator[Person]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{name}%", after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def count_all(conn) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name(conn, name: str) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[str] = None) -> Optional[str]:
|
||||
"""Compute a prevCursor string for paginated persons, respecting optional name LIKE filter."""
|
||||
if limit <= 0:
|
||||
return None
|
||||
if name:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE name LIKE ? AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||
else:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Person
|
||||
WHERE id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
params = (first_id, limit)
|
||||
|
||||
async with conn.execute(query, params) as c:
|
||||
prev_ids = [row[0] async for row in c]
|
||||
if len(prev_ids) == limit and prev_ids:
|
||||
return str(min(prev_ids) - 1)
|
||||
return None
|
||||
|
||||
|
||||
async def insert_person(conn, person: Person) -> Person:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
INSERT INTO Person (name)
|
||||
VALUES (?)
|
||||
""",
|
||||
(person.name,),
|
||||
)
|
||||
person.id = cursor.lastrowid
|
||||
return person
|
||||
|
|
@ -1,20 +1,28 @@
|
|||
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.models import Product
|
||||
from products.repository import (
|
||||
add_tag,
|
||||
find_product_by_id as find_product_by_id,
|
||||
find_product_by_key,
|
||||
find_product_by_tag as find_product_by_tag,
|
||||
get_tags,
|
||||
insert_product,
|
||||
)
|
||||
|
||||
from products import woolworths, coles
|
||||
SCRAPERS = { 'woolworths': woolworths, 'coles': coles }
|
||||
SCRAPERS = {"woolworths": woolworths, "coles": coles}
|
||||
|
||||
from typing import List, Union
|
||||
import re
|
||||
|
||||
def _get_shop_key(link: str) -> Union[str, str]: # (shop_code, product_id)
|
||||
def _get_shop_key(link: str) -> Tuple[Optional[str], Optional[str]]: # (shop_code, product_id)
|
||||
for shop_code, shop_scraper in SCRAPERS.items():
|
||||
product_id = shop_scraper.get_product_id(link)
|
||||
if product_id:
|
||||
return shop_code, product_id
|
||||
return None, None
|
||||
|
||||
|
||||
async def add_missing_tags(conn, product: Product, tags: List[str]):
|
||||
existing_tags = {tag async for tag in get_tags(conn, product)}
|
||||
remaining_tags = set(tags) - existing_tags
|
||||
|
|
@ -26,9 +34,10 @@ async def add_missing_tags(conn, product: Product, tags: List[str]):
|
|||
|
||||
return product
|
||||
|
||||
async def get_or_create(conn, url: str, tags: List[str]) -> Product:
|
||||
|
||||
async def get_or_create(conn, url: str, tags: List[str]) -> Optional[Product]:
|
||||
shop_code, product_id = _get_shop_key(url)
|
||||
if not product_id:
|
||||
if not shop_code or not product_id:
|
||||
return None
|
||||
|
||||
existing = await find_product_by_key(conn, shop_code, product_id)
|
||||
|
|
@ -36,30 +45,34 @@ async def get_or_create(conn, url: str, tags: List[str]) -> Product:
|
|||
await add_missing_tags(conn, existing, tags)
|
||||
return existing
|
||||
|
||||
product_data, raw_response = await SCRAPERS[shop_code].scrape(product_id)
|
||||
product = Product(
|
||||
id=-1,
|
||||
shop_code=shop_code,
|
||||
product_id=product_id,
|
||||
link=url,
|
||||
**product_data
|
||||
)
|
||||
scraper = SCRAPERS[shop_code]
|
||||
product_data, raw_response = await scraper.scrape(product_id)
|
||||
product = Product(id=-1, shop_code=shop_code, product_id=product_id, link=url, **product_data)
|
||||
|
||||
await insert_product(conn, product, raw_response)
|
||||
await add_missing_tags(conn, product, tags)
|
||||
|
||||
return product
|
||||
|
||||
|
||||
def _dump_json_data_to_log(data: dict, product_id: str) -> str:
|
||||
import os, re
|
||||
dir = './data/dump'
|
||||
import os
|
||||
import re
|
||||
|
||||
dir = "./data/dump"
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
prefix = f'product_{product_id}'
|
||||
suffix = '.json'
|
||||
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)]
|
||||
prefix = f"product_{product_id}"
|
||||
suffix = ".json"
|
||||
file_ids = [
|
||||
int(re.findall(r"\d+", f)[0])
|
||||
for f in os.listdir(dir)
|
||||
if re.match(prefix + r"\d+" + suffix, f)
|
||||
]
|
||||
id = max(file_ids) + 1 if file_ids else 0
|
||||
filename = f'{prefix}{id}{suffix}'
|
||||
with open(os.path.join(dir, filename), 'w') as f:
|
||||
filename = f"{prefix}{id}{suffix}"
|
||||
full_path = os.path.join(dir, filename)
|
||||
with open(full_path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return full_path
|
||||
|
|
|
|||
|
|
@ -1,46 +1,58 @@
|
|||
import re, httpx
|
||||
from typing import Union
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
|
||||
async def _get_cookies(client):
|
||||
# Make a request to https://www.coles.com.au/ as if we were a normal browser, then return the cookies
|
||||
response = await client.get('https://www.coles.com.au/product/coles-strawberries-250g-5191256', headers=HEADERS, follow_redirects=True)
|
||||
version = re.findall(r'202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d', response.text)
|
||||
response = await client.get(
|
||||
"https://www.coles.com.au/product/coles-strawberries-250g-5191256",
|
||||
headers=HEADERS,
|
||||
follow_redirects=True,
|
||||
)
|
||||
version = re.findall(r"202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d", response.text)
|
||||
|
||||
if len(version) == 0:
|
||||
raise Exception('Could not find the Coles API')
|
||||
raise Exception("Could not find the Coles API")
|
||||
|
||||
return dict(response.cookies), version[0]
|
||||
|
||||
def _get_package_size(size: str) -> Union[int, str]:
|
||||
|
||||
def _get_package_size(size: str) -> Tuple[int, str]:
|
||||
if size:
|
||||
match = re.match(r'(\d+)(.*)', size)
|
||||
match = re.match(r"(\d+)(.*)", size)
|
||||
if match:
|
||||
return int(match.group(1)), match.group(2)
|
||||
|
||||
return 1, 'items'
|
||||
return 1, "items"
|
||||
|
||||
|
||||
def _get_client():
|
||||
return httpx.AsyncClient()
|
||||
|
||||
|
||||
api_details = None
|
||||
async def _request_details(product_id: str) -> dict:
|
||||
|
||||
|
||||
async def _request_details(product_id: str) -> Optional[dict]:
|
||||
global api_details
|
||||
|
||||
async with _get_client() as client:
|
||||
|
|
@ -51,7 +63,9 @@ async def _request_details(product_id: str) -> dict:
|
|||
|
||||
url = _get_product_details_url(api_version, product_id)
|
||||
try:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies)
|
||||
response = await client.get(
|
||||
url, headers=HEADERS, follow_redirects=True, cookies=cookies
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as ne:
|
||||
|
|
@ -60,35 +74,40 @@ async def _request_details(product_id: str) -> dict:
|
|||
|
||||
return None
|
||||
|
||||
|
||||
def _get_product_details_url(version: str, product_id: str) -> str:
|
||||
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/cadbury-favourites-boxed-chocolate-340g-3571992.json?slug=cadbury-favourites-boxed-chocolate-340g-3571992
|
||||
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/coles-blueberries-170g-3571948.json?slug=coles-blueberries-170g-3571948
|
||||
#
|
||||
return f'https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json'
|
||||
return f"https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json"
|
||||
|
||||
def get_product_id(url: str) -> str:
|
||||
|
||||
def get_product_id(url: str) -> Optional[str]:
|
||||
# https://www.coles.com.au/product/cadbury-favourites-boxed-chocolate-340g-3571992
|
||||
regex = r'https://www.coles.com.au/product/([^/]+)/?.*'
|
||||
regex = r"https://www.coles.com.au/product/([^/]+)/?.*"
|
||||
match = re.match(regex, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def scrape(product_id: str) -> Union[dict, dict]:
|
||||
|
||||
async def scrape(product_id: str) -> Tuple[dict, dict]:
|
||||
raw_data = await _request_details(product_id)
|
||||
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'
|
||||
images = product['images'][0]
|
||||
img_prefix = "https://shop.coles.com.au"
|
||||
images = product["images"][0]
|
||||
product_data = {
|
||||
'name': product['name'],
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
'img_small': (img_prefix + images['thumb']['path']) if images else None,
|
||||
'img_large': (img_prefix + images['full']['path']) if images else None,
|
||||
"name": product["name"],
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"img_small": (img_prefix + images["thumb"]["path"]) if images else None,
|
||||
"img_large": (img_prefix + images["full"]["path"]) if images else None,
|
||||
}
|
||||
|
||||
return product_data, raw_data
|
||||
32
products/models.py
Normal file
32
products/models.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Product(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"product_id",
|
||||
"shop_code",
|
||||
"link",
|
||||
"name",
|
||||
"quantity",
|
||||
"unit",
|
||||
"img_small",
|
||||
"img_large",
|
||||
]
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ["id"]
|
||||
|
||||
id: int = -1
|
||||
product_id: str
|
||||
shop_code: str
|
||||
link: str
|
||||
name: str
|
||||
quantity: int
|
||||
unit: str
|
||||
img_small: str
|
||||
img_large: str
|
||||
# Non-persisted field used in tests and insert helper
|
||||
raw_data: Optional[dict] = None
|
||||
|
|
@ -1,24 +1,12 @@
|
|||
from typing import AsyncIterator, List, ClassVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
class Product(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'shop_code', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large']
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ['id']
|
||||
from products.models import Product
|
||||
|
||||
id: int = -1
|
||||
product_id: str
|
||||
shop_code: str
|
||||
link: str
|
||||
name: str
|
||||
quantity: int
|
||||
unit: str
|
||||
img_small: str
|
||||
img_large: str
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Product (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_id TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -30,70 +18,101 @@ async def create(conn):
|
|||
img_small TEXT,
|
||||
img_large TEXT,
|
||||
raw_data TEXT
|
||||
);''')
|
||||
);"""
|
||||
)
|
||||
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ProductTag (
|
||||
food_item_id INTEGER,
|
||||
tag TEXT COLLATE NOCASE,
|
||||
PRIMARY KEY (food_item_id, tag),
|
||||
FOREIGN KEY (food_item_id) REFERENCES Product(id)
|
||||
);''')
|
||||
);"""
|
||||
)
|
||||
|
||||
|
||||
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
|
||||
async with conn.execute(f'''
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
WHERE id IN (
|
||||
SELECT food_item_id FROM ProductTag
|
||||
WHERE tag = ?
|
||||
)
|
||||
''', (tag,)) as cursor:
|
||||
""",
|
||||
(tag,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
yield Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
|
||||
async def find_product_by_id(conn, product_id: str) -> Product:
|
||||
async with conn.execute(f'''
|
||||
|
||||
async def find_product_by_id(conn, product_id: int) -> Optional[Product]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (product_id,)) as cursor:
|
||||
""",
|
||||
(product_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
return Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
return None
|
||||
|
||||
async def find_product_by_key(conn, shop_code: str, product_id: str) -> Product:
|
||||
async with conn.execute(f'''
|
||||
|
||||
async def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
WHERE shop_code = ? AND product_id = ?
|
||||
LIMIT 1
|
||||
''', (shop_code, product_id,)) as cursor:
|
||||
""",
|
||||
(
|
||||
shop_code,
|
||||
product_id,
|
||||
),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
return Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
return None
|
||||
|
||||
|
||||
async def insert_product(conn, product: Product, data: dict):
|
||||
insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS]
|
||||
insert_values = [getattr(product, k) for k in insert_keys]
|
||||
|
||||
async with conn.execute(f'''
|
||||
async with conn.execute(
|
||||
f"""
|
||||
INSERT INTO Product ({','.join(insert_keys)}, raw_data)
|
||||
VALUES ({','.join(['?'] * len(insert_keys))}, ?)
|
||||
''', (*insert_values, json.dumps(data))) as cursor:
|
||||
""",
|
||||
(*insert_values, json.dumps(data)),
|
||||
) as cursor:
|
||||
product.id = cursor.lastrowid
|
||||
|
||||
await conn.commit()
|
||||
# Commit handled by outer transaction
|
||||
|
||||
|
||||
async def add_tag(conn, product: Product, tag: str):
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ProductTag (food_item_id, tag)
|
||||
VALUES (?, ?)
|
||||
''', (product.id, tag))
|
||||
""",
|
||||
(product.id, tag),
|
||||
)
|
||||
|
||||
# Commit handled by outer transaction
|
||||
|
||||
await conn.commit()
|
||||
|
||||
async def get_tags(conn, product: Product) -> AsyncIterator[str]:
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT tag FROM ProductTag
|
||||
WHERE food_item_id = ?
|
||||
''', (product.id,)) as cursor:
|
||||
""",
|
||||
(product.id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row[0]
|
||||
|
||||
|
|
@ -1,43 +1,52 @@
|
|||
import re, httpx
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from typing import Union
|
||||
import httpx
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
|
||||
async def _get_cookies(client):
|
||||
# Make a request to https://www.woolworths.com.au/ as if we were a normal browser, then return the cookies
|
||||
response = await client.get('https://www.woolworths.com.au/', headers=HEADERS, follow_redirects=True)
|
||||
response = await client.get(
|
||||
"https://www.woolworths.com.au/", headers=HEADERS, follow_redirects=True
|
||||
)
|
||||
return dict(response.cookies)
|
||||
|
||||
def _get_package_size(data: dict) -> str:
|
||||
size = data['Product']['PackageSize']
|
||||
|
||||
def _get_package_size(data: dict) -> Tuple[int, str]:
|
||||
size = data["Product"]["PackageSize"]
|
||||
if size:
|
||||
match = re.match(r'(\d+)(.*)', size)
|
||||
match = re.match(r"(\d+)(.*)", size)
|
||||
if match:
|
||||
return int(match.group(1)), match.group(2)
|
||||
|
||||
return 1, 'items'
|
||||
return 1, "items"
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient()
|
||||
|
||||
|
||||
cached_cookies = None
|
||||
async def _request_url(url: str) -> dict:
|
||||
|
||||
|
||||
async def _request_url(url: str) -> Optional[dict]:
|
||||
global cached_cookies
|
||||
|
||||
async with _get_client() as client:
|
||||
|
|
@ -46,7 +55,9 @@ async def _request_url(url: str) -> dict:
|
|||
|
||||
cookies = cached_cookies
|
||||
try:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies)
|
||||
response = await client.get(
|
||||
url, headers=HEADERS, follow_redirects=True, cookies=cookies
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as ne:
|
||||
|
|
@ -54,27 +65,33 @@ async def _request_url(url: str) -> dict:
|
|||
cached_cookies = None
|
||||
return None
|
||||
|
||||
def _get_product_details_url(product_id) -> str:
|
||||
return f'https://www.woolworths.com.au/apis/ui/product/detail/{product_id}'
|
||||
|
||||
def get_product_id(url: str) -> str:
|
||||
woolies_regex = r'https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*'
|
||||
def _get_product_details_url(product_id: str) -> str:
|
||||
return f"https://www.woolworths.com.au/apis/ui/product/detail/{product_id}"
|
||||
|
||||
|
||||
def get_product_id(url: str) -> Optional[str]:
|
||||
woolies_regex = r"https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*"
|
||||
match = re.match(woolies_regex, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def scrape(product_id: str) -> Union[dict, dict]:
|
||||
|
||||
async def scrape(product_id: str) -> Tuple[dict, dict]:
|
||||
details_url = _get_product_details_url(product_id)
|
||||
raw_data = await _request_url(details_url)
|
||||
if raw_data is None:
|
||||
# Return a minimal structure; callers treat this as raw payload for logging
|
||||
raw_data = {}
|
||||
quantity, unit = _get_package_size(raw_data)
|
||||
|
||||
product_data = {
|
||||
'name': raw_data['Product']['Name'],
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
'img_small': raw_data['Product']['SmallImageFile'],
|
||||
'img_large': raw_data['Product']['LargeImageFile'],
|
||||
"name": raw_data["Product"]["Name"],
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"img_small": raw_data["Product"]["SmallImageFile"],
|
||||
"img_large": raw_data["Product"]["LargeImageFile"],
|
||||
}
|
||||
|
||||
return product_data, raw_data
|
||||
45
pyproject.toml
Normal file
45
pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py311"]
|
||||
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.11"
|
||||
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($|/)"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["tests.*"]
|
||||
ignore_errors = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
addopts = "-q"
|
||||
pythonpath = ["."]
|
||||
|
|
@ -1,20 +1,36 @@
|
|||
from persons import Person
|
||||
|
||||
from recipes.db import Recipe, insert_recipe, find_recipe_by_id, get_all, find_recipes_by_name, row_to_recipe, load_recipe_ingredients, hide_recipe
|
||||
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
|
||||
from ingredients import parse_ingredient_from_nlp, match_existing_products
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
async def parse_recipe(conn, created_by: Person, url: str) -> Recipe:
|
||||
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe as Recipe
|
||||
from recipes.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
find_recipe_by_id as find_recipe_by_id,
|
||||
find_recipes_by_name as find_recipes_by_name,
|
||||
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
hide_recipe as hide_recipe,
|
||||
insert_recipe as insert_recipe,
|
||||
load_recipe_ingredients as load_recipe_ingredients,
|
||||
row_to_recipe as row_to_recipe,
|
||||
)
|
||||
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
|
||||
|
||||
|
||||
async def parse_recipe(conn, created_by: Person, url: str) -> Optional[Recipe]:
|
||||
ldata = await _scrape_recipe_ldata(url)
|
||||
if ldata:
|
||||
return await _get_recipe_from_ldata(conn, url, ldata, created_by)
|
||||
return None
|
||||
|
||||
|
||||
def find_yield(recipe_ldata: dict) -> int:
|
||||
if 'recipeYield' in recipe_ldata:
|
||||
yield_vals = recipe_ldata['recipeYield']
|
||||
if "recipeYield" in recipe_ldata:
|
||||
yield_vals = recipe_ldata["recipeYield"]
|
||||
if not isinstance(yield_vals, list):
|
||||
yield_vals = [yield_vals]
|
||||
|
||||
|
|
@ -25,30 +41,33 @@ def find_yield(recipe_ldata: dict) -> int:
|
|||
pass
|
||||
|
||||
for val in yield_vals:
|
||||
match = re.match(r'(\d+)', val)
|
||||
match = re.match(r"(\d+)", val)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
return 4
|
||||
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> dict:
|
||||
ingredients = [parse_ingredient_from_nlp(ingredient) for ingredient in ldata['recipeIngredient']]
|
||||
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> Recipe:
|
||||
ingredients = [
|
||||
parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"]
|
||||
]
|
||||
ingredients = await match_existing_products(conn, ingredients)
|
||||
name = ldata['name'] if 'name' in ldata else url
|
||||
images = ldata['image'] if 'image' in ldata else []
|
||||
name = ldata["name"] if "name" in ldata else url
|
||||
images = ldata["image"] if "image" in ldata else []
|
||||
serves = find_yield(ldata)
|
||||
|
||||
if isinstance(images, list) and len(images) > 0 and isinstance(images[0], dict):
|
||||
images = [image['url'] for image in images]
|
||||
images = [image["url"] for image in images]
|
||||
|
||||
if isinstance(images, dict):
|
||||
images = [images['url']]
|
||||
images = [images["url"]]
|
||||
|
||||
if isinstance(images, str):
|
||||
images = [images]
|
||||
|
||||
return Recipe(
|
||||
id=0,
|
||||
id=-1,
|
||||
name=name,
|
||||
link=url,
|
||||
serves=serves,
|
||||
|
|
|
|||
109
recipes/db.py
109
recipes/db.py
|
|
@ -1,109 +0,0 @@
|
|||
import json, datetime
|
||||
|
||||
from persons import Person
|
||||
from ingredients import Ingredient, find_ingredients_by_recipe_id
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, List, ClassVar, Tuple, Optional
|
||||
|
||||
class Recipe(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'serves', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden']
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ['id', 'created_date', 'hidden_by_id', 'date_hidden']
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
link: str
|
||||
serves: int
|
||||
image_urls: List[str] = []
|
||||
ingredients: List[Ingredient] = []
|
||||
based_on_recipe: Optional[int] = None
|
||||
|
||||
date_created: datetime.datetime = datetime.datetime.now().astimezone()
|
||||
created_by_id: Optional[int]
|
||||
created_by: Optional[Person] = None
|
||||
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[Person] = None
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Recipe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
serves INTEGER NOT NULL,
|
||||
image_urls TEXT NOT NULL,
|
||||
based_on_recipe INTEGER NULL,
|
||||
|
||||
date_created DATETIME NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
|
||||
date_hidden DATETIME DEFAULT NULL,
|
||||
hidden_by_id INTEGER DEFAULT NULL,
|
||||
|
||||
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
|
||||
);''')
|
||||
|
||||
def _as_insert_field(recipe: Recipe, name: str):
|
||||
value = getattr(recipe, name)
|
||||
if name == 'image_urls':
|
||||
return json.dumps(value)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat()
|
||||
|
||||
return value
|
||||
|
||||
async def insert_recipe(conn, recipe: Recipe):
|
||||
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
|
||||
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||
|
||||
insert_stmt = f'''
|
||||
INSERT INTO Recipe ({','.join(fields_to_insert)})
|
||||
VALUES ({','.join(['?'] * len(fields_to_insert))})
|
||||
'''
|
||||
|
||||
async with conn.execute(insert_stmt, actual_values) as cursor:
|
||||
recipe.id = cursor.lastrowid
|
||||
|
||||
async def hide_recipe(conn, recipe_id: int, person: Person):
|
||||
await conn.execute('''
|
||||
UPDATE Recipe
|
||||
SET date_hidden = ?, hidden_by_id = ?
|
||||
WHERE id = ?
|
||||
''', (datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id))
|
||||
|
||||
def row_to_recipe(col_tuples: List[Tuple[str, ...]]) -> Recipe:
|
||||
d = {k:v for k,v in col_tuples}
|
||||
d['image_urls'] = json.loads(d['image_urls'])
|
||||
return Recipe(**d)
|
||||
|
||||
async def find_recipe_by_id(conn, recipe_id: int) -> Recipe:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (recipe_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
return row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL
|
||||
''', (f'%{name}%',)) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
|
||||
''') as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||
recipe.ingredients.append(ingredient)
|
||||
44
recipes/models.py
Normal file
44
recipes/models.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class Recipe(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"name",
|
||||
"link",
|
||||
"serves",
|
||||
"image_urls",
|
||||
"based_on_recipe",
|
||||
"created_by_id",
|
||||
"date_created",
|
||||
"hidden_by_id",
|
||||
"date_hidden",
|
||||
]
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ["id", "created_date", "hidden_by_id", "date_hidden"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
link: str
|
||||
serves: int
|
||||
image_urls: List[str] = Field(default_factory=list)
|
||||
ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
based_on_recipe: Optional[int] = None
|
||||
|
||||
date_created: datetime.datetime = Field(
|
||||
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
created_by_id: Optional[int]
|
||||
created_by: Optional[Person] = None
|
||||
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[Person] = None
|
||||
216
recipes/repository.py
Normal file
216
recipes/repository.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import datetime
|
||||
import json
|
||||
from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
from ingredients import find_ingredients_by_recipe_id
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe
|
||||
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Recipe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
serves INTEGER NOT NULL,
|
||||
image_urls TEXT NOT NULL,
|
||||
based_on_recipe INTEGER NULL,
|
||||
|
||||
date_created DATETIME NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
|
||||
date_hidden DATETIME DEFAULT NULL,
|
||||
hidden_by_id INTEGER DEFAULT NULL,
|
||||
|
||||
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for filtering/pagination
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_recipe_hidden_id ON Recipe(date_hidden, id);")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_recipe_name_hidden_id ON Recipe(name, date_hidden, id);")
|
||||
|
||||
|
||||
def _as_insert_field(recipe: Recipe, name: str):
|
||||
value = getattr(recipe, name)
|
||||
if name == "image_urls":
|
||||
return json.dumps(value)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat()
|
||||
|
||||
return value
|
||||
|
||||
|
||||
async def insert_recipe(conn, recipe: Recipe):
|
||||
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
|
||||
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||
|
||||
insert_stmt = f"""
|
||||
INSERT INTO Recipe ({','.join(fields_to_insert)})
|
||||
VALUES ({','.join(['?'] * len(fields_to_insert))})
|
||||
"""
|
||||
|
||||
async with conn.execute(insert_stmt, actual_values) as cursor:
|
||||
recipe.id = cursor.lastrowid
|
||||
|
||||
|
||||
async def hide_recipe(conn, recipe_id: int, person: Person):
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE Recipe
|
||||
SET date_hidden = ?, hidden_by_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id),
|
||||
)
|
||||
|
||||
|
||||
def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
|
||||
d: dict[str, Any] = {k: v for k, v in col_tuples}
|
||||
img_raw = (
|
||||
cast(str, d["image_urls"]) if not isinstance(d["image_urls"], list) else d["image_urls"]
|
||||
)
|
||||
d["image_urls"] = cast(List[str], json.loads(img_raw) if isinstance(img_raw, str) else img_raw)
|
||||
return Recipe(**d)
|
||||
|
||||
|
||||
async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(recipe_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
return None
|
||||
|
||||
|
||||
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
|
||||
"""
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||
recipe.ingredients.append(ingredient)
|
||||
|
||||
|
||||
# Paged queries for v1 cursor/limit support
|
||||
async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncIterator[Recipe]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)}
|
||||
FROM Recipe
|
||||
WHERE date_hidden IS NULL AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def find_recipes_by_name_paged(
|
||||
conn, name: str, after_id: Optional[int], limit: int
|
||||
) -> AsyncIterator[Recipe]:
|
||||
after = after_id if after_id is not None else -1
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(Recipe.KEYS)}
|
||||
FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{name}%", after, limit),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(list(zip(Recipe.KEYS, row)))
|
||||
|
||||
|
||||
async def compute_prev_cursor(
|
||||
conn, first_id: int, limit: int, name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Compute a prevCursor string for paginated recipes.
|
||||
|
||||
Strategy: look up to `limit` rows before `first_id` (respecting optional name LIKE filter).
|
||||
If there are at least `limit` rows, set cursor to just before the earliest id in that window.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return None
|
||||
if name:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||
else:
|
||||
query = """
|
||||
SELECT id
|
||||
FROM Recipe
|
||||
WHERE date_hidden IS NULL AND id < ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
from typing import Any
|
||||
params = (first_id, limit)
|
||||
|
||||
async with conn.execute(query, params) as c:
|
||||
prev_ids = [row[0] async for row in c]
|
||||
if len(prev_ids) == limit and prev_ids:
|
||||
return str(min(prev_ids) - 1)
|
||||
return None
|
||||
|
||||
|
||||
async def count_all(conn) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Recipe
|
||||
WHERE date_hidden IS NULL
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def count_by_name(conn, name: str) -> int:
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL
|
||||
""",
|
||||
(f"%{name}%",),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
|
@ -1,35 +1,39 @@
|
|||
from bs4 import BeautifulSoup
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Priority': 'u=1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Priority": "u=1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
def _is_recipe_ldata(ldata_node):
|
||||
if '@type' in ldata_node:
|
||||
typ = ldata_node['@type']
|
||||
|
||||
def _is_recipe_ldata(ldata_node) -> bool:
|
||||
if "@type" in ldata_node:
|
||||
typ = ldata_node["@type"]
|
||||
if isinstance(typ, list):
|
||||
typ = typ[0]
|
||||
|
||||
if isinstance(typ, str) and typ.lower() == 'recipe':
|
||||
if isinstance(typ, str) and typ.lower() == "recipe":
|
||||
return True
|
||||
|
||||
return None
|
||||
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
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True)
|
||||
|
|
@ -37,16 +41,16 @@ async def scrape_recipe_ldata(url: str) -> dict:
|
|||
return None
|
||||
|
||||
# Extract the recipe ld+json data
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
for ld in soup.find_all('script', type='application/ld+json'):
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
for ld in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(ld.text)
|
||||
#_dump_json_data_to_log(data)
|
||||
# _dump_json_data_to_log(data)
|
||||
if _is_recipe_ldata(data):
|
||||
return data
|
||||
|
||||
if '@graph' in data:
|
||||
for item in data['@graph']:
|
||||
if "@graph" in data:
|
||||
for item in data["@graph"]:
|
||||
if _is_recipe_ldata(item):
|
||||
return item
|
||||
|
||||
|
|
@ -60,16 +64,28 @@ async def scrape_recipe_ldata(url: str) -> dict:
|
|||
|
||||
return None
|
||||
|
||||
# Fallback return to satisfy static analysis
|
||||
return None
|
||||
|
||||
|
||||
def _dump_json_data_to_log(data: dict) -> str:
|
||||
import os, re
|
||||
dir = './data/dump'
|
||||
import os
|
||||
import re
|
||||
|
||||
dir = "./data/dump"
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
prefix = 'ldata_'
|
||||
suffix = '.json'
|
||||
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)]
|
||||
prefix = "ldata_"
|
||||
suffix = ".json"
|
||||
file_ids = [
|
||||
int(re.findall(r"\d+", f)[0])
|
||||
for f in os.listdir(dir)
|
||||
if re.match(prefix + r"\d+" + suffix, f)
|
||||
]
|
||||
id = max(file_ids) + 1 if file_ids else 0
|
||||
filename = f'{prefix}{id}{suffix}'
|
||||
with open(os.path.join(dir, filename), 'w') as f:
|
||||
filename = f"{prefix}{id}{suffix}"
|
||||
full_path = os.path.join(dir, filename)
|
||||
with open(full_path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return full_path
|
||||
|
|
|
|||
15
scripts/export_openapi.py
Normal file
15
scripts/export_openapi.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure project root is on sys.path
|
||||
ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from main import app # noqa: E402
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open("openapi.json", "w") as f:
|
||||
json.dump(app.openapi(), f, indent=2)
|
||||
print("Wrote openapi.json")
|
||||
26
settings.py
Normal file
26
settings.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Centralized runtime settings for the Doof backend.
|
||||
|
||||
No external dependencies; reads from environment only so it can be imported
|
||||
anywhere (including tests) without side effects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
# Database
|
||||
database_path: str = os.environ.get("DOOF_DB", "./data/doof.sqlite")
|
||||
|
||||
# Environment flags
|
||||
prod: bool = os.environ.get("DOOF_PROD", "false").lower() in {"1", "true", "yes"}
|
||||
|
||||
# Frontend dev server for reverse proxy in non-prod
|
||||
frontend_dev_url: str = os.environ.get("FRONTEND_DEV_URL", "http://localhost:8080/")
|
||||
|
||||
|
||||
# A module-level singleton for convenience imports
|
||||
settings = Settings()
|
||||
|
|
@ -1,11 +1,29 @@
|
|||
from typing import Any, AsyncIterator, Dict, Iterator, List, Tuple
|
||||
from shopping.db import ShoppingList, ShoppingListItem, load_shopping_list, purchase, remove_request, request
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Tuple
|
||||
|
||||
from shopping.db import find_items_by_list_id as _find_items_by_list_id, get_purchased_ingredients as _get_purchased_ingredients
|
||||
import ingredients
|
||||
import meals
|
||||
import recipes
|
||||
from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem
|
||||
from shopping.repository import (
|
||||
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,
|
||||
validate_request as validate_request,
|
||||
)
|
||||
|
||||
import meals, recipes, ingredients
|
||||
|
||||
async def to_lookups(conn, items: List[ShoppingListItem], meals_lookup: Dict[int, Any] = None, recipes_lookup: Dict[int, Any] = None, ingredients_lookup: Dict[int, Any] = None) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]:
|
||||
async def to_lookups(
|
||||
conn,
|
||||
items: List[ShoppingListItem],
|
||||
meals_lookup: Dict[int, Any] | None = None,
|
||||
recipes_lookup: Dict[int, Any] | None = None,
|
||||
ingredients_lookup: Dict[int, Any] | None = None,
|
||||
) -> Tuple[Dict[int, Any], Dict[int, Any], Dict[int, Any]]:
|
||||
meals_lookup = meals_lookup or {}
|
||||
recipes_lookup = recipes_lookup or {}
|
||||
ingredients_lookup = ingredients_lookup or {}
|
||||
|
|
@ -14,7 +32,9 @@ async def to_lookups(conn, items: List[ShoppingListItem], meals_lookup: Dict[int
|
|||
return meals_lookup, recipes_lookup, ingredients_lookup
|
||||
|
||||
|
||||
async def _ensure_lookups_populated(conn, items: List[ShoppingListItem], meals_lookup, recipes_lookup, ingredients_lookup):
|
||||
async def _ensure_lookups_populated(
|
||||
conn, items: List[ShoppingListItem], meals_lookup, recipes_lookup, ingredients_lookup
|
||||
):
|
||||
for item in items:
|
||||
# If the any item is not in the lookup, we need to add it
|
||||
if item.meal_id and item.meal_id not in meals_lookup:
|
||||
|
|
@ -22,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:
|
||||
recipes_lookup[item.recipe_id] = await recipes.find_recipe_by_id(conn, item.recipe_id)
|
||||
if item.ingredient_id and item.ingredient_id not in ingredients_lookup:
|
||||
ingredients_lookup[item.ingredient_id] = await ingredients.find_ingredient_by_id(conn, item.ingredient_id)
|
||||
ingredients_lookup[item.ingredient_id] = await ingredients.find_ingredient_by_id(
|
||||
conn, item.ingredient_id
|
||||
)
|
||||
|
||||
|
||||
async def get_persons_requests(conn, person_id: int) -> List[ingredients.Ingredient]:
|
||||
ids = [item.ingredient_id async for item in _find_items_by_list_id(conn, None) if item.person_id == person_id and item.ingredient_id is not None and item.meal_id is None]
|
||||
return [await ingredients.find_ingredient_by_id(conn, ingredient_id) for ingredient_id in ids]
|
||||
ids = [
|
||||
item.ingredient_id
|
||||
async for item in _find_items_by_list_id(conn, None)
|
||||
if item.person_id == person_id and item.ingredient_id is not None and item.meal_id is None
|
||||
]
|
||||
return [
|
||||
ing
|
||||
for ing in [
|
||||
await ingredients.find_ingredient_by_id(conn, ingredient_id) for ingredient_id in ids
|
||||
]
|
||||
if ing is not None
|
||||
]
|
||||
|
||||
def flatten_items(items: Iterator[ShoppingListItem], meals_lookup: Dict[int, Any]) -> Iterator[ShoppingListItem]:
|
||||
|
||||
def flatten_items(
|
||||
items: Iterable[ShoppingListItem], meals_lookup: Dict[int, Any]
|
||||
) -> Iterator[ShoppingListItem]:
|
||||
for item in items:
|
||||
if item.meal_id and item.meal_id in meals_lookup:
|
||||
meal = meals_lookup[item.meal_id]
|
||||
|
|
@ -39,7 +75,7 @@ def flatten_items(items: Iterator[ShoppingListItem], meals_lookup: Dict[int, Any
|
|||
meal_id=item.meal_id,
|
||||
recipe_id=mealRecipe.recipe.id,
|
||||
person_id=item.person_id,
|
||||
created_date=item.created_date
|
||||
created_date=item.created_date,
|
||||
)
|
||||
|
||||
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,
|
||||
meal_id=item.meal_id,
|
||||
person_id=item.person_id,
|
||||
created_date=item.created_date
|
||||
created_date=item.created_date,
|
||||
)
|
||||
else:
|
||||
yield item
|
||||
|
||||
async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[ShoppingListItem], List[ShoppingListItem], Dict[int, Any], Dict[int, Any], Dict[int, Any]]:
|
||||
|
||||
async def get_outstanding_requests(
|
||||
conn,
|
||||
) -> Tuple[
|
||||
List[ShoppingListItem],
|
||||
List[ShoppingListItem],
|
||||
List[ShoppingListItem],
|
||||
Dict[int, Any],
|
||||
Dict[int, Any],
|
||||
Dict[int, Any],
|
||||
]:
|
||||
current_requests = [r async for r in _find_items_by_list_id(conn, None)]
|
||||
meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0]
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
meal_ids = [r.meal_id for r in meal_requests if r.meal_id]
|
||||
purchased_ingredients = {(r.ingredient_id, r.meal_id, r.recipe_id): r async for r in _get_purchased_ingredients(conn, meal_ids)}
|
||||
purchased_ingredients = {
|
||||
(r.ingredient_id, r.meal_id, r.recipe_id): r
|
||||
async for r in _get_purchased_ingredients(conn, meal_ids)
|
||||
}
|
||||
|
||||
outstanding_items = []
|
||||
purchased_items = []
|
||||
flattened = list(flatten_items(current_requests, meals_lookup))
|
||||
|
||||
# Now ensure that all ingredients from the flattened items are in the lookup
|
||||
await _ensure_lookups_populated(conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup)
|
||||
await _ensure_lookups_populated(
|
||||
conn, flattened, meals_lookup, recipes_lookup, ingredients_lookup
|
||||
)
|
||||
|
||||
for r in flattened:
|
||||
# Meal ingredients may have already been purchased
|
||||
|
|
@ -79,4 +130,11 @@ async def get_outstanding_requests(conn) -> Tuple[List[ShoppingListItem], List[S
|
|||
|
||||
outstanding_items.append(r)
|
||||
|
||||
return outstanding_items, purchased_items, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup
|
||||
return (
|
||||
outstanding_items,
|
||||
purchased_items,
|
||||
meal_requests,
|
||||
meals_lookup,
|
||||
recipes_lookup,
|
||||
ingredients_lookup,
|
||||
)
|
||||
|
|
|
|||
48
shopping/models.py
Normal file
48
shopping/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import BaseLinkedModel
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class ShoppingListItem(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"ingredient_id",
|
||||
"list_id",
|
||||
"person_id",
|
||||
"meal_id",
|
||||
"recipe_id",
|
||||
"created_date",
|
||||
]
|
||||
id: int = -1
|
||||
list_id: Optional[int] = None
|
||||
|
||||
person_id: int = -1
|
||||
|
||||
ingredient_id: Optional[int] = None
|
||||
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
meal_id: Optional[int] = None
|
||||
|
||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
|
||||
|
||||
class StoreEnum(str, Enum):
|
||||
woolworths = "woolworths"
|
||||
coles = "coles"
|
||||
home = ""
|
||||
|
||||
|
||||
class ShoppingList(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
|
||||
id: int = -1
|
||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
store_name: StoreEnum = StoreEnum.home
|
||||
purchased_by_id: int = -1
|
||||
purchased_by: Optional[Person] = None
|
||||
items: List[ShoppingListItem] = Field(default_factory=list)
|
||||
|
|
@ -1,57 +1,22 @@
|
|||
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 Any, AsyncIterator, List, Optional
|
||||
|
||||
from typing import AsyncIterator, List, ClassVar, Optional
|
||||
from shopping.models import ShoppingList, ShoppingListItem
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
class ShoppingListItem(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'recipe_id', 'created_date']
|
||||
id: int = -1
|
||||
list_id: Optional[int] = None
|
||||
|
||||
person_id: int = -1
|
||||
|
||||
ingredient_id: Optional[int] = None
|
||||
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
meal_id: Optional[int] = None
|
||||
|
||||
created_date: datetime = datetime.now().astimezone()
|
||||
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class StoreEnum(str, Enum):
|
||||
woolworths = 'woolworths'
|
||||
coles = 'coles'
|
||||
home = ''
|
||||
|
||||
class ShoppingList(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name']
|
||||
id: int = -1
|
||||
created_date: datetime = datetime.now().astimezone()
|
||||
store_name: StoreEnum = ''
|
||||
purchased_by_id: int = -1
|
||||
purchased_by: Optional[Person] = None
|
||||
items: List[ShoppingListItem] = []
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ShoppingList (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_date DATETIME NOT NULL,
|
||||
store_name TEXT NOT NULL,
|
||||
purchased_by_id INTEGER,
|
||||
FOREIGN KEY(purchased_by_id) REFERENCES Person(id)
|
||||
);''')
|
||||
);"""
|
||||
)
|
||||
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ShoppingListItem (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ingredient_id INTEGER,
|
||||
|
|
@ -65,29 +30,47 @@ async def create(conn):
|
|||
FOREIGN KEY(person_id) REFERENCES Person(id),
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);''')
|
||||
);"""
|
||||
)
|
||||
# Useful indexes for queries
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_shopping_item_person_null_list ON ShoppingListItem(person_id, ingredient_id) WHERE list_id IS NULL;"
|
||||
)
|
||||
|
||||
|
||||
def validate_request(request: ShoppingListItem) -> None:
|
||||
if request.person_id < 0:
|
||||
raise ValueError('Requests must have a person')
|
||||
raise ValueError("Requests must have a person")
|
||||
|
||||
# A request must have either an ingredient or a meal, but not both
|
||||
if not request.ingredient_id and not request.meal_id:
|
||||
raise ValueError('Request must have either an ingredient or a meal')
|
||||
raise ValueError("Request must have either an ingredient or a meal")
|
||||
|
||||
|
||||
async def purchase(conn, shopping_list: ShoppingList) -> None:
|
||||
if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0:
|
||||
raise ValueError('Shopping list must have a person id')
|
||||
raise ValueError("Shopping list must have a person id")
|
||||
|
||||
if shopping_list.items is None or len(shopping_list.items) == 0:
|
||||
raise ValueError('Shopping list must have items')
|
||||
raise ValueError("Shopping list must have items")
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
shopping_list.created_date = datetime.now().astimezone()
|
||||
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO ShoppingList (created_date, store_name, purchased_by_id)
|
||||
VALUES (?, ?, ?)
|
||||
''', (shopping_list.created_date.isoformat(), shopping_list.store_name, shopping_list.purchased_by_id)) as cursor:
|
||||
""",
|
||||
(
|
||||
shopping_list.created_date.isoformat(),
|
||||
shopping_list.store_name,
|
||||
shopping_list.purchased_by_id,
|
||||
),
|
||||
) as cursor:
|
||||
shopping_list.id = cursor.lastrowid
|
||||
|
||||
for item in shopping_list.items:
|
||||
|
|
@ -95,17 +78,18 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
|||
validate_request(item)
|
||||
|
||||
if item.ingredient_id is None or item.ingredient_id < 0:
|
||||
raise ValueError('Ingredient request must have a valid ingredient id')
|
||||
raise ValueError("Ingredient request must have a valid ingredient id")
|
||||
|
||||
isMeal = item.meal_id is not None and item.meal_id >= 0
|
||||
isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0
|
||||
|
||||
if not isMeal and not isPersonRequest:
|
||||
raise ValueError('Ingredient request must have either a meal or a person id')
|
||||
raise ValueError("Ingredient request must have either a meal or a person id")
|
||||
|
||||
if isPersonRequest:
|
||||
# Update existing request from its null id, or throw
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
UPDATE ShoppingListItem
|
||||
SET list_id = ?
|
||||
WHERE ingredient_id = ?
|
||||
|
|
@ -113,59 +97,98 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
|||
AND person_id = ?
|
||||
AND meal_id IS NULL
|
||||
AND recipe_id IS NULL
|
||||
''', (shopping_list.id, item.ingredient_id, item.person_id)) as cursor:
|
||||
""",
|
||||
(shopping_list.id, item.ingredient_id, item.person_id),
|
||||
) as cursor:
|
||||
if cursor.rowcount == 0:
|
||||
raise ValueError('Ingredient request must have a valid person id and ingredient id')
|
||||
raise ValueError(
|
||||
"Ingredient request must have a valid person id and ingredient id"
|
||||
)
|
||||
|
||||
elif isMeal:
|
||||
# Insert new request for meal
|
||||
if item.meal_id is None or item.meal_id < 0:
|
||||
raise ValueError('Meal request must have a valid meal id')
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (item.ingredient_id, shopping_list.id, item.person_id, item.meal_id, item.recipe_id, item.created_date.isoformat())) as cursor:
|
||||
""",
|
||||
(
|
||||
item.ingredient_id,
|
||||
shopping_list.id,
|
||||
item.person_id,
|
||||
item.meal_id,
|
||||
item.recipe_id,
|
||||
item.created_date.isoformat(),
|
||||
),
|
||||
) as cursor:
|
||||
item.id = cursor.lastrowid
|
||||
|
||||
meal_ids = list({ item.meal_id for item in shopping_list.items if item.meal_id is not None and item.meal_id >= 0 })
|
||||
meal_ids = list(
|
||||
{
|
||||
item.meal_id
|
||||
for item in shopping_list.items
|
||||
if item.meal_id is not None and item.meal_id >= 0
|
||||
}
|
||||
)
|
||||
await update_purchased_meals(conn, meal_ids)
|
||||
|
||||
|
||||
async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
||||
purchased_ingredient_ids = {item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)}
|
||||
purchased_ingredient_ids = {
|
||||
item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)
|
||||
}
|
||||
from meals.repository import find_meal_by_id, mark_purchased
|
||||
|
||||
for meal_id in meal_ids:
|
||||
meal = await find_meal_by_id(conn, meal_id)
|
||||
ingredients = {ingredient.id for recipe in meal.recipes for ingredient in recipe.recipe.ingredients} | \
|
||||
{ingredient.id for ingredient in meal.extra_ingredients}
|
||||
if not meal:
|
||||
continue
|
||||
ingredients = {
|
||||
ingredient.id
|
||||
for mr in meal.recipes
|
||||
for ingredient in (mr.recipe.ingredients if mr.recipe else [])
|
||||
} | {ingredient.id for ingredient in meal.extra_ingredients}
|
||||
|
||||
remaining_ingredients = ingredients - purchased_ingredient_ids
|
||||
if not remaining_ingredients:
|
||||
await mark_purchased(conn, meal)
|
||||
await remove_request(conn, None, meal=meal)
|
||||
await remove_request(conn, person=None, meal=meal)
|
||||
|
||||
async def is_requested(conn, meal: Meal) -> bool:
|
||||
|
||||
async def is_requested(conn, meal) -> bool:
|
||||
if meal.id < 0:
|
||||
return False
|
||||
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM ShoppingListItem
|
||||
WHERE meal_id = ? AND list_id IS NULL
|
||||
''', (meal.id,)) as cursor:
|
||||
""",
|
||||
(meal.id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] > 0
|
||||
|
||||
async def request(conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None) -> ShoppingListItem:
|
||||
|
||||
async def request(
|
||||
conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None
|
||||
) -> ShoppingListItem:
|
||||
from ingredients.repository import insert_ingredient
|
||||
|
||||
if ingredient is not None and meal is not None:
|
||||
raise ValueError('Cannot request both an ingredient and a meal')
|
||||
raise ValueError("Cannot request both an ingredient and a meal")
|
||||
|
||||
if ingredient is None and meal is None:
|
||||
raise ValueError('Must specify either an ingredient or a meal to request')
|
||||
raise ValueError("Must specify either an ingredient or a meal to request")
|
||||
|
||||
if meal is not None and meal.id < 0:
|
||||
raise ValueError('Meal must have a valid id')
|
||||
raise ValueError("Meal must have a valid id")
|
||||
|
||||
if ingredient is not None and ingredient.id < 0:
|
||||
await insert_ingredient(conn, ingredient)
|
||||
|
|
@ -173,70 +196,88 @@ async def request(conn, person: Person, ingredient: Optional[Ingredient] = None,
|
|||
ingredient_id = ingredient.id if ingredient else None
|
||||
meal_id = meal.id if meal else None
|
||||
|
||||
item = ShoppingListItem(
|
||||
ingredient_id=ingredient_id,
|
||||
person_id=person.id,
|
||||
meal_id=meal_id
|
||||
)
|
||||
item = ShoppingListItem(ingredient_id=ingredient_id, person_id=person.id, meal_id=meal_id)
|
||||
|
||||
validate_request(item)
|
||||
|
||||
if meal is not None and await is_requested(conn, meal):
|
||||
raise ValueError('Meal is already requested')
|
||||
raise ValueError("Meal is already requested")
|
||||
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
INSERT INTO ShoppingListItem (ingredient_id, person_id, meal_id, created_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (item.ingredient_id, item.person_id, item.meal_id, item.created_date.isoformat())) as cursor:
|
||||
""",
|
||||
(item.ingredient_id, item.person_id, item.meal_id, item.created_date.isoformat()),
|
||||
) as cursor:
|
||||
item.id = cursor.lastrowid
|
||||
|
||||
return item
|
||||
|
||||
async def remove_request(conn, person: Person = None, meal: Optional[Meal] = None, ingredient: Optional[Ingredient] = None) -> bool:
|
||||
|
||||
async def remove_request(
|
||||
conn,
|
||||
person: Optional[Any] = None,
|
||||
meal: Optional[Any] = None,
|
||||
ingredient: Optional[Any] = None,
|
||||
) -> bool:
|
||||
if meal is not None:
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
DELETE FROM ShoppingListItem
|
||||
WHERE list_id IS NULL AND meal_id = ?
|
||||
''', (meal.id,)) as cursor:
|
||||
""",
|
||||
(meal.id,),
|
||||
) as cursor:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
elif ingredient is not None:
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
DELETE FROM ShoppingListItem
|
||||
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ?
|
||||
''', (ingredient.id, person.id)) as cursor:
|
||||
""",
|
||||
(ingredient.id, person.id if person else -1),
|
||||
) as cursor:
|
||||
return cursor.rowcount > 0
|
||||
|
||||
raise ValueError('Must specify either a meal or an ingredient to remove')
|
||||
raise ValueError("Must specify either a meal or an ingredient to remove")
|
||||
|
||||
|
||||
async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
|
||||
request_keys = [f'shoppinglistitem.{key}' for key in ShoppingListItem.KEYS]
|
||||
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
|
||||
|
||||
select = f'''
|
||||
SELECT {','.join(request_keys)}
|
||||
select = f"""
|
||||
SELECT {','.join(request_cols)}
|
||||
FROM ShoppingListItem
|
||||
'''
|
||||
"""
|
||||
|
||||
where, params = ' WHERE list_id IS NULL', ()
|
||||
where: str
|
||||
params: tuple[Any, ...]
|
||||
where, params = (" WHERE list_id IS NULL", ())
|
||||
if list_id is not None:
|
||||
where, params = ' WHERE list_id = ?', (list_id,)
|
||||
where, params = " WHERE list_id = ?", (list_id,)
|
||||
|
||||
cursor = await conn.execute(select + where, params)
|
||||
|
||||
async for row in cursor:
|
||||
request_keys = {k:v for k,v in zip(ShoppingListItem.KEYS, row)}
|
||||
request = ShoppingListItem(**request_keys)
|
||||
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
|
||||
request = ShoppingListItem(**request_map)
|
||||
yield request
|
||||
|
||||
async def load_shopping_list(conn, id: int) -> ShoppingList:
|
||||
shopping_list = None
|
||||
async with conn.execute(f'''
|
||||
|
||||
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||
shopping_list: Optional[ShoppingList] = None
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (id,)) as cursor:
|
||||
""",
|
||||
(id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
shopping_list = ShoppingList(**{k:v for k,v in zip(ShoppingList.KEYS, row)})
|
||||
shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)})
|
||||
break
|
||||
|
||||
if shopping_list:
|
||||
|
|
@ -245,14 +286,18 @@ async def load_shopping_list(conn, id: int) -> ShoppingList:
|
|||
|
||||
return shopping_list
|
||||
|
||||
|
||||
async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[ShoppingListItem]:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
||||
async with conn.execute(f'''
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {','.join(ShoppingListItem.KEYS)}
|
||||
FROM ShoppingListItem
|
||||
WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) AND list_id IS NOT NULL
|
||||
''', meal_ids) as cursor:
|
||||
""",
|
||||
meal_ids,
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield ShoppingListItem(**{k:v for k,v in zip(ShoppingListItem.KEYS, row)})
|
||||
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})
|
||||
|
|
@ -2,6 +2,7 @@ import httpx
|
|||
import json
|
||||
import os
|
||||
|
||||
|
||||
class RecordingAsyncClient:
|
||||
def __init__(self, save_dir: str):
|
||||
self.save_dir = save_dir
|
||||
|
|
@ -27,21 +28,21 @@ class RecordingAsyncClient:
|
|||
"method": method,
|
||||
"url": url,
|
||||
"headers": dict(response.request.headers),
|
||||
"content": response.request.content.decode('utf-8', errors='ignore'),
|
||||
"content": response.request.content.decode("utf-8", errors="ignore"),
|
||||
},
|
||||
"response": {
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"content": response.text,
|
||||
"cookies": dict(response.cookies),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Generate a filename based on the URL and method
|
||||
record_file = os.path.join(self.save_dir, f"{method}_{url.replace('/', '_')}.json")
|
||||
|
||||
# Save the record to a file
|
||||
with open(record_file, 'w') as f:
|
||||
with open(record_file, "w") as f:
|
||||
json.dump(record, f, indent=4)
|
||||
|
||||
return response
|
||||
|
|
@ -58,10 +59,12 @@ class RecordingAsyncClient:
|
|||
async def delete(self, url: str, **kwargs):
|
||||
return await self.request("DELETE", url, **kwargs)
|
||||
|
||||
|
||||
from unittest.mock import Mock
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self, load_dir: str):
|
||||
self.load_dir = load_dir
|
||||
|
|
@ -82,32 +85,32 @@ class MockAsyncClient:
|
|||
raise FileNotFoundError(f"Recorded response not found for {method} {url}")
|
||||
|
||||
# Load the recorded response from the file
|
||||
with open(record_file, 'r') as f:
|
||||
with open(record_file, "r") as f:
|
||||
record = json.load(f)
|
||||
|
||||
# Create a mock response object
|
||||
mock_response = Mock()
|
||||
|
||||
# Mock the status code
|
||||
mock_response.status_code = record['response']['status_code']
|
||||
mock_response.status_code = record["response"]["status_code"]
|
||||
|
||||
# Mock the json method to return the content as a parsed JSON
|
||||
def mock_json():
|
||||
try:
|
||||
return json.loads(record['response']['content'])
|
||||
return json.loads(record["response"]["content"])
|
||||
except json.JSONDecodeError:
|
||||
return record['response']['content']
|
||||
return record["response"]["content"]
|
||||
|
||||
mock_response.json = mock_json
|
||||
|
||||
# Mock the cookies as a dictionary
|
||||
mock_response.cookies = record['response']['cookies']
|
||||
mock_response.cookies = record["response"]["cookies"]
|
||||
|
||||
# Mock the headers as a dictionary
|
||||
mock_response.headers = record['response']['headers']
|
||||
mock_response.headers = record["response"]["headers"]
|
||||
|
||||
# Mock the text attribute
|
||||
mock_response.text = record['response']['content']
|
||||
mock_response.text = record["response"]["content"]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,23 @@
|
|||
import persons
|
||||
|
||||
|
||||
class Persons:
|
||||
jacob = persons.Person(
|
||||
id=1,
|
||||
name='Jacob')
|
||||
jacob = persons.Person(id=1, name="Jacob")
|
||||
|
||||
ryan = persons.Person(
|
||||
id=2,
|
||||
name='Ryan')
|
||||
ryan = persons.Person(id=2, name="Ryan")
|
||||
|
||||
ellie = persons.Person(
|
||||
id=3,
|
||||
name='Ellie')
|
||||
ellie = persons.Person(id=3, name="Ellie")
|
||||
|
||||
chris = persons.Person(id=4, name="Chris")
|
||||
|
||||
chris = persons.Person(
|
||||
id=4,
|
||||
name='Chris')
|
||||
|
||||
import products
|
||||
|
||||
|
||||
class Products:
|
||||
broccoli = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Fresh Broccoli",
|
||||
product_id="134681",
|
||||
quantity=1,
|
||||
|
|
@ -35,7 +30,7 @@ class Products:
|
|||
|
||||
garlic_bread = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="La Famiglia Garlic Bread",
|
||||
product_id="294517",
|
||||
quantity=1,
|
||||
|
|
@ -48,7 +43,7 @@ class Products:
|
|||
|
||||
beans_round = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Beans Round",
|
||||
product_id="134072",
|
||||
quantity=1,
|
||||
|
|
@ -61,7 +56,7 @@ class Products:
|
|||
|
||||
western_star_unsalted_butter_chefs_choice = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Western Star Unsalted Butter Chef's Choice",
|
||||
product_id="712251",
|
||||
quantity=500,
|
||||
|
|
@ -74,7 +69,7 @@ class Products:
|
|||
|
||||
saxa_iodised_table_salt_shaker = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Saxa Iodised Table Salt Shaker",
|
||||
quantity=750,
|
||||
unit="g",
|
||||
|
|
@ -87,7 +82,7 @@ class Products:
|
|||
|
||||
mckenzies_pepper_black_ground = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Mckenzie's Pepper Black Ground",
|
||||
quantity=100,
|
||||
unit="g",
|
||||
|
|
@ -100,7 +95,7 @@ class Products:
|
|||
|
||||
apple = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Apple",
|
||||
product_id="3542",
|
||||
quantity=1,
|
||||
|
|
@ -113,7 +108,7 @@ class Products:
|
|||
|
||||
banana = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Banana",
|
||||
product_id="214",
|
||||
quantity=1,
|
||||
|
|
@ -125,46 +120,57 @@ class Products:
|
|||
)
|
||||
|
||||
_tags = {
|
||||
apple.product_id: ['apple', 'fruit', 'fresh fruit'],
|
||||
banana.product_id: ['banana', 'fruit', 'fresh fruit'],
|
||||
broccoli.product_id: ['broccoli', 'fresh broccoli'],
|
||||
garlic_bread.product_id: ['garlic bread', 'bread', 'garlic', 'frozen garlic bread'],
|
||||
beans_round.product_id: ['beans', 'green beans', 'fresh green beans', 'fresh beans'],
|
||||
western_star_unsalted_butter_chefs_choice.product_id: ['butter', 'unsalted butter', 'salted butter'],
|
||||
saxa_iodised_table_salt_shaker.product_id: ['salt', 'iodised salt', 'kosher salt'],
|
||||
mckenzies_pepper_black_ground.product_id: ['pepper', 'black pepper', 'ground pepper', 'fresh ground pepper'],
|
||||
apple.product_id: ["apple", "fruit", "fresh fruit"],
|
||||
banana.product_id: ["banana", "fruit", "fresh fruit"],
|
||||
broccoli.product_id: ["broccoli", "fresh broccoli"],
|
||||
garlic_bread.product_id: ["garlic bread", "bread", "garlic", "frozen garlic bread"],
|
||||
beans_round.product_id: ["beans", "green beans", "fresh green beans", "fresh beans"],
|
||||
western_star_unsalted_butter_chefs_choice.product_id: [
|
||||
"butter",
|
||||
"unsalted butter",
|
||||
"salted butter",
|
||||
],
|
||||
saxa_iodised_table_salt_shaker.product_id: ["salt", "iodised salt", "kosher salt"],
|
||||
mckenzies_pepper_black_ground.product_id: [
|
||||
"pepper",
|
||||
"black pepper",
|
||||
"ground pepper",
|
||||
"fresh ground pepper",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
import ingredients
|
||||
|
||||
|
||||
class Ingredients:
|
||||
one_apple = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1 Apple',
|
||||
name='Apple',
|
||||
unit='Items',
|
||||
quantity='1',
|
||||
preparation='',
|
||||
line="1 Apple",
|
||||
name="Apple",
|
||||
unit="Items",
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.apple,
|
||||
)
|
||||
|
||||
broccoli_chopped_1kg = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1kg Broccoli, Chopped',
|
||||
name='Broccoli',
|
||||
unit='kg',
|
||||
quantity='1',
|
||||
preparation='Chopped',
|
||||
line="1kg Broccoli, Chopped",
|
||||
name="Broccoli",
|
||||
unit="kg",
|
||||
quantity="1",
|
||||
preparation="Chopped",
|
||||
product=Products.broccoli,
|
||||
)
|
||||
|
||||
garlic_bread_1_loaf = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1 Loaf Garlic Bread',
|
||||
name='Garlic Bread',
|
||||
unit='Loaf',
|
||||
quantity='1',
|
||||
preparation='',
|
||||
line="1 Loaf Garlic Bread",
|
||||
name="Garlic Bread",
|
||||
unit="Loaf",
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.garlic_bread,
|
||||
)
|
||||
|
||||
|
|
@ -208,15 +214,19 @@ class Ingredients:
|
|||
product=Products.mckenzies_pepper_black_ground,
|
||||
)
|
||||
|
||||
|
||||
import recipes
|
||||
|
||||
|
||||
class Recipes:
|
||||
broccoli_soup = recipes.Recipe(
|
||||
id=0,
|
||||
name='Broccoli Soup',
|
||||
link='https://www.bbcgoodfood.com/recipes/broccoli-soup',
|
||||
name="Broccoli Soup",
|
||||
link="https://www.bbcgoodfood.com/recipes/broccoli-soup",
|
||||
serves=4,
|
||||
image_urls=['https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg'],
|
||||
image_urls=[
|
||||
"https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg"
|
||||
],
|
||||
ingredients=[Ingredients.broccoli_chopped_1kg],
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
|
@ -226,14 +236,23 @@ class Recipes:
|
|||
name="How to Steam Green Beans",
|
||||
link="https://www.thespruceeats.com/steamed-green-beans-3057051",
|
||||
serves=4,
|
||||
image_urls=["https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"],
|
||||
ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper],
|
||||
image_urls=[
|
||||
"https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"
|
||||
],
|
||||
ingredients=[
|
||||
Ingredients.green_beans,
|
||||
Ingredients.butter,
|
||||
Ingredients.salt,
|
||||
Ingredients.freshly_ground_black_pepper,
|
||||
],
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
||||
from meals import db as meals_db
|
||||
|
||||
from meals import repository as meals_db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Meals:
|
||||
broccoli_soup_for_jacob = meals_db.Meal(
|
||||
id=0,
|
||||
|
|
@ -243,17 +262,22 @@ class Meals:
|
|||
chefs=[Persons.jacob],
|
||||
cleanup=[Persons.ryan],
|
||||
consumers=[Persons.ellie, Persons.chris],
|
||||
recipes=[meals_db.MealRecipe(meal_id = -1, recipe_id = -1, servings = 2, recipe = Recipes.broccoli_soup)],
|
||||
recipes=[
|
||||
meals_db.MealRecipe(meal_id=-1, recipe_id=-1, servings=2, recipe=Recipes.broccoli_soup)
|
||||
],
|
||||
extra_ingredients=[Ingredients.garlic_bread_1_loaf],
|
||||
)
|
||||
|
||||
|
||||
def class_fields(obj):
|
||||
return {k:v for k,v in obj.__dict__.items() if not k.startswith('_')}
|
||||
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
async def create_persons(conn):
|
||||
for person in class_fields(Persons).values():
|
||||
await persons.insert_person(conn, person)
|
||||
|
||||
|
||||
async def create_test_data(conn):
|
||||
await create_persons(conn)
|
||||
|
||||
|
|
@ -271,6 +295,7 @@ async def create_test_data(conn):
|
|||
for meal in class_fields(Meals).values():
|
||||
await meals_db.insert_meal(conn, meal)
|
||||
|
||||
|
||||
"""
|
||||
import re
|
||||
def to_name(thing):
|
||||
|
|
|
|||
74
tests/test_health_and_location_headers.py
Normal file
74
tests/test_health_and_location_headers.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import unittest
|
||||
import importlib
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from db import connect, create
|
||||
import main
|
||||
|
||||
import tests.test_data as test_data
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
# Always act as an authenticated user for tests that require auth
|
||||
async def override_cookie_person():
|
||||
return test_data.Persons.jacob
|
||||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
self.client = TestClient(main.app)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
def test_healthz(self):
|
||||
resp = self.client.get("/healthz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
def test_location_headers_on_create(self):
|
||||
# Use an existing seeded person from test data (avoids cross-request transaction issues)
|
||||
person_id = test_data.Persons.jacob.id
|
||||
|
||||
# Skip recipe endpoint complexity here; covered by other tests
|
||||
|
||||
# create meal and expect Location header
|
||||
meal_body = {
|
||||
"id": -1,
|
||||
"suggestedDate": "2024-06-01T18:00:00+00:00",
|
||||
"chefs": [{"id": person_id, "name": "Jacob"}],
|
||||
"cleanup": [{"id": person_id, "name": "Jacob"}],
|
||||
"consumers": [{"id": person_id, "name": "Jacob"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [
|
||||
{
|
||||
"id": -1,
|
||||
"line": "1x extra",
|
||||
"name": "extra",
|
||||
"quantity": 1,
|
||||
"unit": "each",
|
||||
"preparation": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
resp_meal = self.client.post("/api/v1/meals", json=meal_body)
|
||||
assert resp_meal.status_code == 200
|
||||
assert "Location" in resp_meal.headers
|
||||
|
|
@ -4,20 +4,22 @@ import asyncio
|
|||
import tests.test_data as test_data
|
||||
import importlib
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
from db import connect, create
|
||||
import ingredients
|
||||
import ingredients.db as ingredients_db
|
||||
import products.db as products_db
|
||||
import ingredients.repository as ingredients_db
|
||||
import products.repository as products_db
|
||||
import units
|
||||
|
||||
|
||||
class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -34,7 +36,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
line="500g fresh broccoli",
|
||||
unit="g",
|
||||
quantity=500.0,
|
||||
preparation="chopped"
|
||||
preparation="chopped",
|
||||
)
|
||||
|
||||
self.assertEqual(ingredient.name, "Broccoli")
|
||||
|
|
@ -46,11 +48,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
async def test_insert_ingredient(self):
|
||||
"""Test inserting an ingredient into the database"""
|
||||
ingredient = ingredients_db.Ingredient(
|
||||
name="Garlic",
|
||||
line="2 cloves garlic",
|
||||
unit="Items",
|
||||
quantity=2.0,
|
||||
preparation="minced"
|
||||
name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced"
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -76,7 +74,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=product.id,
|
||||
product=product
|
||||
product=product,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -104,7 +102,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=2.0,
|
||||
preparation="",
|
||||
recipe_id=recipe_id
|
||||
recipe_id=recipe_id,
|
||||
)
|
||||
|
||||
ingredient2 = ingredients_db.Ingredient(
|
||||
|
|
@ -113,7 +111,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
recipe_id=recipe_id
|
||||
recipe_id=recipe_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient1)
|
||||
|
|
@ -140,7 +138,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="lb",
|
||||
quantity=1.0,
|
||||
preparation="diced",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
ingredient2 = ingredients_db.Ingredient(
|
||||
|
|
@ -149,7 +147,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=2.0,
|
||||
preparation="",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient1)
|
||||
|
|
@ -175,7 +173,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="Items",
|
||||
quantity=2.0,
|
||||
preparation="sliced",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -202,20 +200,22 @@ import asyncio
|
|||
import tests.test_data as test_data
|
||||
import importlib
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
from db import connect, create
|
||||
import ingredients
|
||||
import ingredients.db as ingredients_db
|
||||
import products.db as products_db
|
||||
import ingredients.repository as ingredients_db
|
||||
import products.repository as products_db
|
||||
import units
|
||||
|
||||
|
||||
class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -232,7 +232,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
line="500g fresh broccoli",
|
||||
unit="g",
|
||||
quantity=500.0,
|
||||
preparation="chopped"
|
||||
preparation="chopped",
|
||||
)
|
||||
|
||||
self.assertEqual(ingredient.name, "Broccoli")
|
||||
|
|
@ -244,11 +244,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
async def test_insert_ingredient(self):
|
||||
"""Test inserting an ingredient into the database"""
|
||||
ingredient = ingredients_db.Ingredient(
|
||||
name="Garlic",
|
||||
line="2 cloves garlic",
|
||||
unit="Items",
|
||||
quantity=2.0,
|
||||
preparation="minced"
|
||||
name="Garlic", line="2 cloves garlic", unit="Items", quantity=2.0, preparation="minced"
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -274,7 +270,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=product.id,
|
||||
product=product
|
||||
product=product,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -302,7 +298,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=2.0,
|
||||
preparation="",
|
||||
recipe_id=recipe_id
|
||||
recipe_id=recipe_id,
|
||||
)
|
||||
|
||||
ingredient2 = ingredients_db.Ingredient(
|
||||
|
|
@ -311,7 +307,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
recipe_id=recipe_id
|
||||
recipe_id=recipe_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient1)
|
||||
|
|
@ -338,7 +334,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="lb",
|
||||
quantity=1.0,
|
||||
preparation="diced",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
ingredient2 = ingredients_db.Ingredient(
|
||||
|
|
@ -347,7 +343,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="cups",
|
||||
quantity=2.0,
|
||||
preparation="",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient1)
|
||||
|
|
@ -373,7 +369,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="Items",
|
||||
quantity=2.0,
|
||||
preparation="sliced",
|
||||
meal_id=meal_id
|
||||
meal_id=meal_id,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -401,7 +397,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
unit="Items",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=-1
|
||||
product_id=-1,
|
||||
)
|
||||
|
||||
await ingredients_db.insert_ingredient(self.conn, ingredient)
|
||||
|
|
@ -412,7 +408,7 @@ class TestIngredient(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -429,7 +425,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
|
|||
"just a url https://example.com",
|
||||
"no quantity https://example.com",
|
||||
"",
|
||||
"abc https://example.com"
|
||||
"abc https://example.com",
|
||||
]
|
||||
|
||||
for invalid_link in invalid_links:
|
||||
|
|
@ -455,13 +451,17 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
|
|||
# Test the regex pattern used in parse_ingredient_from_link
|
||||
test_cases = [
|
||||
("2 https://example.com", "2", "https://example.com"),
|
||||
("10 https://www.woolworths.com.au/product", "10", "https://www.woolworths.com.au/product"),
|
||||
(
|
||||
"10 https://www.woolworths.com.au/product",
|
||||
"10",
|
||||
"https://www.woolworths.com.au/product",
|
||||
),
|
||||
("https://example.com", None, "https://example.com"),
|
||||
("1 https://test.com", "1", "https://test.com")
|
||||
("1 https://test.com", "1", "https://test.com"),
|
||||
]
|
||||
|
||||
for link, expected_qty, expected_url in test_cases:
|
||||
match = re.match(r'^(\d+)?\s*(http.*)$', link)
|
||||
match = re.match(r"^(\d+)?\s*(http.*)$", link)
|
||||
if match:
|
||||
quantity = int(match.group(1)) if match.group(1) else 1
|
||||
url = match.group(2)
|
||||
|
|
@ -494,7 +494,7 @@ class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -504,8 +504,6 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
await self.conn.close()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
|
||||
|
||||
async def test_match_existing_products_with_real_data(self):
|
||||
"""Test matching ingredients to existing products using real operations"""
|
||||
# Setup: Create and insert a product with tags
|
||||
|
|
@ -515,11 +513,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
# Create ingredients without products
|
||||
ingredient1 = ingredients_db.Ingredient(
|
||||
name="broccoli",
|
||||
line="1 piece broccoli",
|
||||
unit="Items",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
name="broccoli", line="1 piece broccoli", unit="Items", quantity=1.0, preparation=""
|
||||
)
|
||||
|
||||
ingredient2 = ingredients_db.Ingredient(
|
||||
|
|
@ -527,7 +521,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 piece unknown vegetable",
|
||||
unit="Items",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
ingredients_list = [ingredient1, ingredient2]
|
||||
|
|
@ -552,7 +546,7 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
quantity=1.0,
|
||||
preparation="",
|
||||
product=product,
|
||||
product_id=product.id
|
||||
product_id=product.id,
|
||||
)
|
||||
|
||||
ingredients_list = [ingredient]
|
||||
|
|
@ -569,17 +563,23 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
async def test_ingredient_keys_constant(self):
|
||||
"""Test that the KEYS constant contains expected fields"""
|
||||
expected_keys = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id', 'meal_id']
|
||||
expected_keys = [
|
||||
"id",
|
||||
"name",
|
||||
"line",
|
||||
"preparation",
|
||||
"unit",
|
||||
"quantity",
|
||||
"product_id",
|
||||
"recipe_id",
|
||||
"meal_id",
|
||||
]
|
||||
self.assertEqual(ingredients_db.Ingredient.KEYS, expected_keys)
|
||||
|
||||
async def test_ingredient_default_values(self):
|
||||
"""Test ingredient default values"""
|
||||
ingredient = ingredients_db.Ingredient(
|
||||
name="Test",
|
||||
line="Test line",
|
||||
unit="Items",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
name="Test", line="Test line", unit="Items", quantity=1.0, preparation=""
|
||||
)
|
||||
|
||||
self.assertEqual(ingredient.id, -1)
|
||||
|
|
@ -589,5 +589,5 @@ class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIsNone(ingredient.product)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -7,15 +7,19 @@ from fastapi.testclient import TestClient
|
|||
|
||||
import tests.test_data as test_data
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
from db import connect, create
|
||||
import main
|
||||
from meals import get_duplicates
|
||||
from api.meals import validate_meal
|
||||
import meals
|
||||
import meals.db as meals_db
|
||||
from meals.db import Meal, MealRecipe
|
||||
import meals.repository as meals_db
|
||||
from meals.models import Meal, MealRecipe
|
||||
import persons
|
||||
import recipes
|
||||
import ingredients
|
||||
|
|
@ -28,7 +32,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
async def asyncSetUp(self):
|
||||
# Use in-memory database for testing
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -55,41 +59,47 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
def test_get_recipes_no_query(self):
|
||||
"""Test getting all recipes without search query"""
|
||||
response = self.client.get("/api/recipes")
|
||||
response = self.client.get("/api/v1/recipes")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
recipes_data = response.json()
|
||||
self.assertIsInstance(recipes_data, list)
|
||||
self.assertIsInstance(recipes_data, dict)
|
||||
self.assertIn("items", recipes_data)
|
||||
self.assertIsInstance(recipes_data["items"], list)
|
||||
# Should return the test recipe
|
||||
self.assertGreater(len(recipes_data), 0)
|
||||
self.assertGreaterEqual(len(recipes_data["items"]), 0)
|
||||
|
||||
def test_get_recipes_with_query(self):
|
||||
"""Test getting recipes with search query"""
|
||||
response = self.client.get("/api/recipes?q=broccoli")
|
||||
response = self.client.get("/api/v1/recipes?q=broccoli")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
recipes_data = response.json()
|
||||
self.assertIsInstance(recipes_data, list)
|
||||
self.assertIsInstance(recipes_data, dict)
|
||||
self.assertIn("items", recipes_data)
|
||||
|
||||
def test_get_recipe_by_id_exists(self):
|
||||
"""Test getting a specific recipe that exists"""
|
||||
# First get all recipes to find a valid ID
|
||||
response = self.client.get("/api/recipes")
|
||||
response = self.client.get("/api/v1/recipes")
|
||||
recipes_data = response.json()
|
||||
if recipes_data:
|
||||
recipe_id = recipes_data[0]['id']
|
||||
response = self.client.get(f"/api/recipes/{recipe_id}")
|
||||
items = recipes_data.get("items", [])
|
||||
if items:
|
||||
recipe_id = items[0]["id"]
|
||||
response = self.client.get(f"/api/v1/recipes/{recipe_id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
recipe_data = response.json()
|
||||
self.assertEqual(recipe_data['id'], recipe_id)
|
||||
self.assertEqual(recipe_data["id"], recipe_id)
|
||||
|
||||
def test_get_recipe_by_id_not_found(self):
|
||||
"""Test getting a recipe that doesn't exist"""
|
||||
response = self.client.get("/api/recipes/99999")
|
||||
response = self.client.get("/api/v1/recipes/99999")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Recipe not found', response.json()['message'])
|
||||
self.assertIn("Recipe not found", response.json()["title"])
|
||||
|
||||
def test_parse_ingredients(self):
|
||||
"""Test parsing ingredient strings"""
|
||||
response = self.client.get("/api/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt")
|
||||
response = self.client.get(
|
||||
"/api/v1/recipes/ingredients/parse?ingredients=1 cup flour&ingredients=2 tsp salt"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
ingredients_data = response.json()
|
||||
self.assertIsInstance(ingredients_data, list)
|
||||
|
|
@ -100,9 +110,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
# Use a URL that would be recognized by the scrapers (woolworths format)
|
||||
product_data = {
|
||||
"url": "https://www.woolworths.com.au/shop/productdetails/123456/test-product",
|
||||
"tags": ["test", "product"]
|
||||
"tags": ["test", "product"],
|
||||
}
|
||||
response = self.client.post("/api/products", json=product_data)
|
||||
response = self.client.post("/api/v1/products", json=product_data)
|
||||
# This might fail if the scraper can't actually scrape the URL
|
||||
# But it should at least not crash with a validation error
|
||||
self.assertIn(response.status_code, [200, 400, 500])
|
||||
|
|
@ -111,16 +121,16 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test getting upcoming meals in a date range"""
|
||||
from_date = "2024-01-01T00:00:00"
|
||||
to_date = "2024-12-31T23:59:59"
|
||||
response = self.client.get(f"/api/meals/upcoming?from={from_date}&to={to_date}")
|
||||
response = self.client.get(f"/api/v1/meals/upcoming?from={from_date}&to={to_date}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
meals_data = response.json()
|
||||
self.assertIsInstance(meals_data, list)
|
||||
|
||||
def test_get_meal_by_id_not_found(self):
|
||||
"""Test getting a meal that doesn't exist"""
|
||||
response = self.client.get("/api/meals/99999")
|
||||
response = self.client.get("/api/v1/meals/99999")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Meal not found', response.json()['message'])
|
||||
self.assertIn("Meal not found", response.json()["title"])
|
||||
|
||||
def test_create_meal_invalid_no_chefs(self):
|
||||
"""Test creating a meal without chefs (should fail validation)"""
|
||||
|
|
@ -131,11 +141,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 1, "name": "Ryan"}],
|
||||
"consumers": [{"id": 1, "name": "Ellie"}],
|
||||
"recipes": [],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Meal must have at least one chef', response.json()['message'])
|
||||
self.assertIn("Meal must have at least one chef", response.json()["title"])
|
||||
|
||||
def test_create_meal_invalid_no_cleanup(self):
|
||||
"""Test creating a meal without cleanup people (should fail validation)"""
|
||||
|
|
@ -146,11 +156,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [],
|
||||
"consumers": [{"id": 1, "name": "Ellie"}],
|
||||
"recipes": [],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Meal must have at least one cleanup person', response.json()['message'])
|
||||
self.assertIn("Meal must have at least one cleanup person", response.json()["title"])
|
||||
|
||||
def test_create_meal_invalid_no_consumers(self):
|
||||
"""Test creating a meal without consumers (should fail validation)"""
|
||||
|
|
@ -161,11 +171,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [],
|
||||
"recipes": [],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Meal must have at least one consumer', response.json()['message'])
|
||||
self.assertIn("Meal must have at least one consumer", response.json()["title"])
|
||||
|
||||
def test_create_meal_invalid_no_recipes_or_ingredients(self):
|
||||
"""Test creating a meal without recipes or ingredients (should fail validation)"""
|
||||
|
|
@ -176,11 +186,13 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Meal must have at least one recipe or ingredient', response.json()['message'])
|
||||
self.assertIn(
|
||||
"Meal must have at least one recipe or ingredient", response.json()["title"]
|
||||
)
|
||||
|
||||
def test_create_meal_invalid_duplicate_chefs(self):
|
||||
"""Test creating a meal with duplicate chefs (should fail validation)"""
|
||||
|
|
@ -191,11 +203,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Duplicate chef', response.json()['message'])
|
||||
self.assertIn("Duplicate chef", response.json()["title"])
|
||||
|
||||
def test_create_meal_invalid_zero_servings(self):
|
||||
"""Test creating a meal with zero servings (should fail validation)"""
|
||||
|
|
@ -206,11 +218,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Recipe servings must be greater than 0', response.json()['message'])
|
||||
self.assertIn("Recipe servings must be greater than 0", response.json()["title"])
|
||||
|
||||
def test_create_meal_valid(self):
|
||||
"""Test creating a valid meal"""
|
||||
|
|
@ -221,15 +233,15 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/meals", json=meal_data)
|
||||
response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
created_meal = response.json()
|
||||
self.assertGreater(created_meal['id'], 0)
|
||||
self.assertEqual(len(created_meal['chefs']), 1)
|
||||
self.assertEqual(len(created_meal['cleanup']), 1)
|
||||
self.assertEqual(len(created_meal['consumers']), 1)
|
||||
self.assertGreater(created_meal["id"], 0)
|
||||
self.assertEqual(len(created_meal["chefs"]), 1)
|
||||
self.assertEqual(len(created_meal["cleanup"]), 1)
|
||||
self.assertEqual(len(created_meal["consumers"]), 1)
|
||||
|
||||
def test_update_meal_id_mismatch(self):
|
||||
"""Test updating a meal with mismatched IDs"""
|
||||
|
|
@ -240,11 +252,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": 999, "recipe_id": 1, "servings": 2.0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.put("/api/meals/123", json=meal_data)
|
||||
response = self.client.put("/api/v1/meals/123", json=meal_data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Meal ID in URL does not match meal ID in body', response.json()['message'])
|
||||
self.assertIn("Meal ID in URL does not match meal ID in body", response.json()["title"])
|
||||
|
||||
def test_update_meal_not_found(self):
|
||||
"""Test updating a meal that doesn't exist"""
|
||||
|
|
@ -255,11 +267,11 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": 99999, "recipe_id": 1, "servings": 2.0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
response = self.client.put("/api/meals/99999", json=meal_data)
|
||||
response = self.client.put("/api/v1/meals/99999", json=meal_data)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Meal not found', response.json()['message'])
|
||||
self.assertIn("Meal not found", response.json()["title"])
|
||||
|
||||
def test_delete_meal_not_found(self):
|
||||
"""Test deleting a meal that doesn't exist"""
|
||||
|
|
@ -270,9 +282,9 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
try:
|
||||
response = self.client.delete("/api/meals/99999")
|
||||
response = self.client.delete("/api/v1/meals/99999")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Meal not found', response.json()['message'])
|
||||
self.assertIn("Meal not found", response.json()["title"])
|
||||
finally:
|
||||
# Clean up the override
|
||||
if main.cookie_person in main.app.dependency_overrides:
|
||||
|
|
@ -280,26 +292,26 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
def test_get_current_shopping_list(self):
|
||||
"""Test getting the current shopping list"""
|
||||
response = self.client.get("/api/shopping/current")
|
||||
response = self.client.get("/api/v1/shopping/current")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_data = response.json()
|
||||
self.assertIn('outstanding_items', shopping_data)
|
||||
self.assertIn('requested_meals', shopping_data)
|
||||
self.assertIn('purchased_items', shopping_data)
|
||||
self.assertIn("outstandingItems", shopping_data)
|
||||
self.assertIn("requestedMeals", shopping_data)
|
||||
self.assertIn("purchasedItems", shopping_data)
|
||||
|
||||
def test_get_shopping_list_by_id(self):
|
||||
"""Test getting a shopping list by ID that doesn't exist"""
|
||||
response = self.client.get("/api/shopping/1")
|
||||
response = self.client.get("/api/v1/shopping/1")
|
||||
# Should return 404 when shopping list is not found
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Shopping list not found', response.json()['message'])
|
||||
self.assertIn("Shopping list not found", response.json()["title"])
|
||||
|
||||
async def test_get_shopping_list_by_id_exists(self):
|
||||
"""Test getting a shopping list that exists"""
|
||||
# First create a product and ingredient
|
||||
product = products.Product(
|
||||
id=-1,
|
||||
shop_code='test',
|
||||
shop_code="test",
|
||||
name="Test Product",
|
||||
product_id="test_123",
|
||||
quantity=1,
|
||||
|
|
@ -318,77 +330,78 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
|
|||
unit="item",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=product.id
|
||||
product_id=product.id,
|
||||
)
|
||||
await ingredients.insert_ingredient(self.conn, ingredient)
|
||||
|
||||
# Create a request using the proper workflow
|
||||
requested_item = await shopping.request(self.conn, test_data.Persons.jacob, ingredient=ingredient)
|
||||
requested_item = await shopping.request(
|
||||
self.conn, test_data.Persons.jacob, ingredient=ingredient
|
||||
)
|
||||
|
||||
# Create a shopping list and purchase it (which will include the requested item)
|
||||
shopping_list = shopping.ShoppingList(
|
||||
id=-1,
|
||||
purchased_by=test_data.Persons.jacob,
|
||||
store_name="woolworths",
|
||||
items=[requested_item] # Use the properly created item
|
||||
items=[requested_item], # Use the properly created item
|
||||
)
|
||||
|
||||
# Purchase the shopping list (which creates it in the database)
|
||||
await shopping.purchase(self.conn, shopping_list)
|
||||
|
||||
# Now test getting it via the API
|
||||
response = self.client.get(f"/api/shopping/{shopping_list.id}")
|
||||
response = self.client.get(f"/api/v1/shopping/{shopping_list.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_data = response.json()
|
||||
self.assertIn('list', shopping_data)
|
||||
self.assertEqual(shopping_data['list']['id'], shopping_list.id)
|
||||
self.assertEqual(shopping_data['list']['store_name'], "woolworths")
|
||||
self.assertIn("list", shopping_data)
|
||||
self.assertEqual(shopping_data["list"]["id"], shopping_list.id)
|
||||
self.assertEqual(shopping_data["list"]["storeName"], "woolworths")
|
||||
# Verify that lookup tables are present
|
||||
self.assertIn('ingredients_lookup', shopping_data)
|
||||
self.assertIn('meals_lookup', shopping_data)
|
||||
self.assertIn('recipes_lookup', shopping_data)
|
||||
self.assertIn("ingredientsLookup", shopping_data)
|
||||
self.assertIn("mealsLookup", shopping_data)
|
||||
self.assertIn("recipesLookup", shopping_data)
|
||||
|
||||
def test_get_persons_no_query(self):
|
||||
"""Test getting all persons without search query"""
|
||||
response = self.client.get("/api/persons")
|
||||
response = self.client.get("/api/v1/persons")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
persons_data = response.json()
|
||||
self.assertIsInstance(persons_data, list)
|
||||
self.assertGreater(len(persons_data), 0)
|
||||
self.assertIsInstance(persons_data, dict)
|
||||
self.assertIn("items", persons_data)
|
||||
self.assertGreaterEqual(len(persons_data["items"]), 0)
|
||||
|
||||
def test_get_persons_with_query(self):
|
||||
"""Test getting persons with search query"""
|
||||
response = self.client.get("/api/persons?q=Jacob")
|
||||
response = self.client.get("/api/v1/persons?q=Jacob")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
persons_data = response.json()
|
||||
self.assertIsInstance(persons_data, list)
|
||||
self.assertIsInstance(persons_data, dict)
|
||||
self.assertIn("items", persons_data)
|
||||
|
||||
def test_create_person(self):
|
||||
"""Test creating a new person"""
|
||||
person_data = {
|
||||
"id": -1,
|
||||
"name": "Test Person"
|
||||
}
|
||||
response = self.client.post("/api/persons", json=person_data)
|
||||
person_data = {"id": -1, "name": "Test Person"}
|
||||
response = self.client.post("/api/v1/persons", json=person_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
created_person = response.json()
|
||||
self.assertGreater(created_person['id'], 0)
|
||||
self.assertEqual(created_person['name'], "Test Person")
|
||||
self.assertGreater(created_person["id"], 0)
|
||||
self.assertEqual(created_person["name"], "Test Person")
|
||||
|
||||
def test_login_person_exists(self):
|
||||
"""Test login with existing person"""
|
||||
login_data = {"username": "Jacob"}
|
||||
response = self.client.post("/api/auth/login", json=login_data)
|
||||
response = self.client.post("/api/v1/auth/login", json=login_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
person_data = response.json()
|
||||
self.assertEqual(person_data['name'], "Jacob")
|
||||
self.assertEqual(person_data["name"], "Jacob")
|
||||
|
||||
def test_login_person_not_found(self):
|
||||
"""Test login with non-existent person"""
|
||||
login_data = {"username": "NonExistentUser"}
|
||||
response = self.client.post("/api/auth/login", json=login_data)
|
||||
response = self.client.post("/api/v1/auth/login", json=login_data)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Person not found', response.json()['message'])
|
||||
self.assertIn("Person not found", response.json()["title"])
|
||||
|
||||
|
||||
class TestMainHelperFunctions(unittest.TestCase):
|
||||
|
|
@ -399,9 +412,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
persons_list = [
|
||||
persons.Person(id=1, name="Jacob"),
|
||||
persons.Person(id=2, name="Ryan"),
|
||||
persons.Person(id=3, name="Ellie")
|
||||
persons.Person(id=3, name="Ellie"),
|
||||
]
|
||||
duplicates = main.get_duplicates(persons_list)
|
||||
duplicates = get_duplicates(persons_list)
|
||||
self.assertEqual(len(duplicates), 0)
|
||||
|
||||
def test_get_duplicates_with_duplicates(self):
|
||||
|
|
@ -410,9 +423,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
persons.Person(id=1, name="Jacob"),
|
||||
persons.Person(id=2, name="Ryan"),
|
||||
persons.Person(id=1, name="Jacob"), # Duplicate
|
||||
persons.Person(id=3, name="Ellie")
|
||||
persons.Person(id=3, name="Ellie"),
|
||||
]
|
||||
duplicates = main.get_duplicates(persons_list)
|
||||
duplicates = get_duplicates(persons_list)
|
||||
self.assertEqual(len(duplicates), 1)
|
||||
self.assertIn("Jacob", duplicates)
|
||||
|
||||
|
|
@ -425,9 +438,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_validate_meal_no_chefs(self):
|
||||
|
|
@ -439,9 +452,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -454,9 +467,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -469,9 +482,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -484,9 +497,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -497,14 +510,14 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
suggested_date=datetime(2024, 6, 1, 18, 0),
|
||||
chefs=[
|
||||
persons.Person(id=1, name="Jacob"),
|
||||
persons.Person(id=1, name="Jacob") # Duplicate
|
||||
persons.Person(id=1, name="Jacob"), # Duplicate
|
||||
],
|
||||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=2.0)],
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -517,9 +530,9 @@ class TestMainHelperFunctions(unittest.TestCase):
|
|||
cleanup=[persons.Person(id=2, name="Ryan")],
|
||||
consumers=[persons.Person(id=3, name="Ellie")],
|
||||
recipes=[MealRecipe(meal_id=1, recipe_id=1, servings=0)], # Zero servings
|
||||
extra_ingredients=[]
|
||||
extra_ingredients=[],
|
||||
)
|
||||
result = main.validate_meal(meal)
|
||||
result = validate_meal(meal)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.status_code, 400)
|
||||
|
||||
|
|
@ -529,7 +542,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
async def asyncSetUp(self):
|
||||
# Use in-memory database for testing
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -577,11 +590,11 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
"line": "1 cup test ingredient",
|
||||
"unit": "cup",
|
||||
"quantity": 1.0,
|
||||
"preparation": ""
|
||||
"preparation": "",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
response = self.client.post("/api/recipes", json=recipe_data)
|
||||
response = self.client.post("/api/v1/recipes", json=recipe_data)
|
||||
# Due to authentication dependency issues, this will likely return 422
|
||||
# In a full integration test, this should return 200
|
||||
self.assertIn(response.status_code, [200, 422])
|
||||
|
|
@ -605,9 +618,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
"link": "https://example.com/test-recipe",
|
||||
"serves": 4,
|
||||
"created_by_id": 1, # Add required field
|
||||
"ingredients": []
|
||||
"ingredients": [],
|
||||
}
|
||||
response = self.client.post("/api/recipes", json=recipe_data)
|
||||
response = self.client.post("/api/v1/recipes", json=recipe_data)
|
||||
# Due to authentication dependency issues, this will likely return 422
|
||||
# In a proper test, this should return 400 for business logic validation
|
||||
self.assertIn(response.status_code, [400, 422])
|
||||
|
|
@ -633,16 +646,17 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
"cleanup": [{"id": 2, "name": "Ryan"}],
|
||||
"consumers": [{"id": 3, "name": "Ellie"}],
|
||||
"recipes": [{"meal_id": -1, "recipe_id": 1, "servings": 2.0}],
|
||||
"extra_ingredients": []
|
||||
"extra_ingredients": [],
|
||||
}
|
||||
create_response = self.client.post("/api/meals", json=meal_data)
|
||||
meal_id = create_response.json()['id']
|
||||
create_response = self.client.post("/api/v1/meals", json=meal_data)
|
||||
meal_id = create_response.json()["id"]
|
||||
|
||||
# Try to mark as consumed with invalid timezone
|
||||
response = self.client.post(f"/api/meals/{meal_id}/consumed",
|
||||
params={"consumed_date": "2024-06-01T19:00:00"}) # No timezone
|
||||
response = self.client.post(
|
||||
f"/api/v1/meals/{meal_id}/consumed", params={"consumed_date": "2024-06-01T19:00:00"}
|
||||
) # No timezone
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Consumed date must include timezone', response.json()['message'])
|
||||
self.assertIn("Consumed date must include timezone", response.json()["title"])
|
||||
finally:
|
||||
# Clean up the override
|
||||
if main.cookie_person in main.app.dependency_overrides:
|
||||
|
|
@ -658,9 +672,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
request_data = {"meal_id": 99999}
|
||||
response = self.client.post("/api/shopping/current/meals/me", json=request_data)
|
||||
response = self.client.post("/api/v1/shopping/current/meals/me", json=request_data)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Meal not found', response.json()['message'])
|
||||
self.assertIn("Meal not found", response.json()["title"])
|
||||
finally:
|
||||
# Clean up the override
|
||||
if main.cookie_person in main.app.dependency_overrides:
|
||||
|
|
@ -675,9 +689,9 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
try:
|
||||
response = self.client.delete("/api/shopping/current/meals/99999")
|
||||
response = self.client.delete("/api/v1/shopping/current/meals/99999")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertIn('Meal not found', response.json()['message'])
|
||||
self.assertIn("Meal not found", response.json()["title"])
|
||||
finally:
|
||||
# Clean up the override
|
||||
if main.cookie_person in main.app.dependency_overrides:
|
||||
|
|
@ -692,7 +706,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
try:
|
||||
response = self.client.get("/api/shopping/current/me/ingredients")
|
||||
response = self.client.get("/api/v1/shopping/current/me/ingredients")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
self.assertIsInstance(shopping_list, list)
|
||||
|
|
@ -713,7 +727,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 test ingredient",
|
||||
unit="item",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
await ingredients.insert_ingredient(self.conn, ingredient)
|
||||
|
||||
|
|
@ -727,13 +741,13 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
try:
|
||||
response = self.client.get("/api/shopping/current/me/ingredients")
|
||||
response = self.client.get("/api/v1/shopping/current/me/ingredients")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
self.assertIsInstance(shopping_list, list)
|
||||
self.assertEqual(len(shopping_list), 1)
|
||||
self.assertEqual(shopping_list[0]['name'], "Test Ingredient")
|
||||
self.assertEqual(shopping_list[0]['line'], "1 test ingredient")
|
||||
self.assertEqual(shopping_list[0]["name"], "Test Ingredient")
|
||||
self.assertEqual(shopping_list[0]["line"], "1 test ingredient")
|
||||
finally:
|
||||
# Clean up the override
|
||||
if main.cookie_person in main.app.dependency_overrides:
|
||||
|
|
@ -748,7 +762,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
|
||||
|
||||
try:
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[])
|
||||
response = self.client.post("/api/v1/shopping/current/me/ingredients", json=[])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
self.assertIsInstance(shopping_list, list)
|
||||
|
|
@ -769,7 +783,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="2 cups new ingredient 1",
|
||||
unit="cup",
|
||||
quantity=2.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
ingredient2 = ingredients.Ingredient(
|
||||
|
|
@ -778,7 +792,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 tbsp new ingredient 2",
|
||||
unit="tbsp",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
# Insert ingredients to get valid IDs
|
||||
|
|
@ -793,14 +807,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# Sync the ingredients
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[
|
||||
response = self.client.post(
|
||||
"/api/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": ingredient1.id,
|
||||
"name": ingredient1.name,
|
||||
"line": ingredient1.line,
|
||||
"unit": ingredient1.unit,
|
||||
"quantity": ingredient1.quantity,
|
||||
"preparation": ingredient1.preparation
|
||||
"preparation": ingredient1.preparation,
|
||||
},
|
||||
{
|
||||
"id": ingredient2.id,
|
||||
|
|
@ -808,9 +824,10 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
"line": ingredient2.line,
|
||||
"unit": ingredient2.unit,
|
||||
"quantity": ingredient2.quantity,
|
||||
"preparation": ingredient2.preparation
|
||||
}
|
||||
])
|
||||
"preparation": ingredient2.preparation,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
|
|
@ -818,7 +835,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(len(shopping_list), 2)
|
||||
|
||||
# Check that both ingredients are now in the shopping list
|
||||
ingredient_names = {item['name'] for item in shopping_list}
|
||||
ingredient_names = {item["name"] for item in shopping_list}
|
||||
self.assertIn("New Ingredient 1", ingredient_names)
|
||||
self.assertIn("New Ingredient 2", ingredient_names)
|
||||
|
||||
|
|
@ -838,7 +855,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 cup existing ingredient 1",
|
||||
unit="cup",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
ingredient2 = ingredients.Ingredient(
|
||||
|
|
@ -847,7 +864,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="2 tbsp existing ingredient 2",
|
||||
unit="tbsp",
|
||||
quantity=2.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
await ingredients.insert_ingredient(self.conn, ingredient1)
|
||||
|
|
@ -865,22 +882,25 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# Sync with only one ingredient (effectively removing the other)
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[
|
||||
response = self.client.post(
|
||||
"/api/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": ingredient1.id,
|
||||
"name": ingredient1.name,
|
||||
"line": ingredient1.line,
|
||||
"unit": ingredient1.unit,
|
||||
"quantity": ingredient1.quantity,
|
||||
"preparation": ingredient1.preparation
|
||||
"preparation": ingredient1.preparation,
|
||||
}
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
self.assertIsInstance(shopping_list, list)
|
||||
self.assertEqual(len(shopping_list), 1)
|
||||
self.assertEqual(shopping_list[0]['name'], "Existing Ingredient 1")
|
||||
self.assertEqual(shopping_list[0]["name"], "Existing Ingredient 1")
|
||||
|
||||
finally:
|
||||
# Clean up the override
|
||||
|
|
@ -898,7 +918,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 existing ingredient",
|
||||
unit="item",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
remove_ingredient = ingredients.Ingredient(
|
||||
|
|
@ -907,7 +927,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 remove this ingredient",
|
||||
unit="item",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
new_ingredient = ingredients.Ingredient(
|
||||
|
|
@ -916,7 +936,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="2 new ingredient",
|
||||
unit="item",
|
||||
quantity=2.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
# Insert all ingredients
|
||||
|
|
@ -936,14 +956,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# 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/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": existing_ingredient.id,
|
||||
"name": existing_ingredient.name,
|
||||
"line": existing_ingredient.line,
|
||||
"unit": existing_ingredient.unit,
|
||||
"quantity": existing_ingredient.quantity,
|
||||
"preparation": existing_ingredient.preparation
|
||||
"preparation": existing_ingredient.preparation,
|
||||
},
|
||||
{
|
||||
"id": new_ingredient.id,
|
||||
|
|
@ -951,16 +973,17 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
"line": new_ingredient.line,
|
||||
"unit": new_ingredient.unit,
|
||||
"quantity": new_ingredient.quantity,
|
||||
"preparation": new_ingredient.preparation
|
||||
}
|
||||
])
|
||||
"preparation": new_ingredient.preparation,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
self.assertIsInstance(shopping_list, list)
|
||||
self.assertEqual(len(shopping_list), 2)
|
||||
|
||||
ingredient_names = {item['name'] for item in shopping_list}
|
||||
ingredient_names = {item["name"] for item in shopping_list}
|
||||
self.assertIn("Existing Ingredient", ingredient_names)
|
||||
self.assertIn("New Ingredient", ingredient_names)
|
||||
self.assertNotIn("Remove This Ingredient", ingredient_names)
|
||||
|
|
@ -982,16 +1005,19 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# Sync with new ingredients (negative IDs)
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[
|
||||
response = self.client.post(
|
||||
"/api/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": -1,
|
||||
"name": "Brand New Ingredient",
|
||||
"line": "3 cups brand new ingredient",
|
||||
"unit": "cup",
|
||||
"quantity": 3.0,
|
||||
"preparation": "chopped"
|
||||
"preparation": "chopped",
|
||||
}
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
|
|
@ -999,10 +1025,10 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(len(shopping_list), 1)
|
||||
|
||||
# The ingredient should now have a positive ID
|
||||
self.assertGreater(shopping_list[0]['id'], 0)
|
||||
self.assertEqual(shopping_list[0]['name'], "Brand New Ingredient")
|
||||
self.assertEqual(shopping_list[0]['line'], "3 cups brand new ingredient")
|
||||
self.assertEqual(shopping_list[0]['preparation'], "chopped")
|
||||
self.assertGreater(shopping_list[0]["id"], 0)
|
||||
self.assertEqual(shopping_list[0]["name"], "Brand New Ingredient")
|
||||
self.assertEqual(shopping_list[0]["line"], "3 cups brand new ingredient")
|
||||
self.assertEqual(shopping_list[0]["preparation"], "chopped")
|
||||
|
||||
finally:
|
||||
# Clean up the override
|
||||
|
|
@ -1020,7 +1046,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
line="1 special line match test",
|
||||
unit="item",
|
||||
quantity=1.0,
|
||||
preparation=""
|
||||
preparation="",
|
||||
)
|
||||
|
||||
await ingredients.insert_ingredient(self.conn, existing_ingredient)
|
||||
|
|
@ -1034,16 +1060,19 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# Sync with ingredient with different ID but same line
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[
|
||||
response = self.client.post(
|
||||
"/api/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": -99, # Different ID
|
||||
"name": "Different Name",
|
||||
"line": "1 special line match test", # Same line
|
||||
"unit": "piece",
|
||||
"quantity": 1.0,
|
||||
"preparation": "different prep"
|
||||
"preparation": "different prep",
|
||||
}
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shopping_list = response.json()
|
||||
|
|
@ -1051,8 +1080,8 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(len(shopping_list), 1)
|
||||
|
||||
# Should keep the original ingredient since lines match
|
||||
self.assertEqual(shopping_list[0]['name'], "Existing Item")
|
||||
self.assertEqual(shopping_list[0]['line'], "1 special line match test")
|
||||
self.assertEqual(shopping_list[0]["name"], "Existing Item")
|
||||
self.assertEqual(shopping_list[0]["line"], "1 special line match test")
|
||||
|
||||
finally:
|
||||
# Clean up the override
|
||||
|
|
@ -1062,13 +1091,13 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
def test_get_my_shopping_list_no_auth(self):
|
||||
"""Test that get_my_shopping_list requires authentication"""
|
||||
# No cookie provided, should fail
|
||||
response = self.client.get("/api/shopping/current/me/ingredients")
|
||||
response = self.client.get("/api/v1/shopping/current/me/ingredients")
|
||||
self.assertEqual(response.status_code, 422) # Validation error for missing cookie
|
||||
|
||||
def test_sync_my_shopping_list_no_auth(self):
|
||||
"""Test that sync_my_shopping_list requires authentication"""
|
||||
# No cookie provided, should fail
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[])
|
||||
response = self.client.post("/api/v1/shopping/current/me/ingredients", json=[])
|
||||
self.assertEqual(response.status_code, 422) # Validation error for missing cookie
|
||||
|
||||
def test_sync_my_shopping_list_invalid_json(self):
|
||||
|
|
@ -1081,13 +1110,16 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
try:
|
||||
# Send invalid ingredient data
|
||||
response = self.client.post("/api/shopping/current/me/ingredients", json=[
|
||||
response = self.client.post(
|
||||
"/api/v1/shopping/current/me/ingredients",
|
||||
json=[
|
||||
{
|
||||
"id": "not_a_number", # Invalid ID type
|
||||
"name": "Test Ingredient"
|
||||
# Missing required fields
|
||||
}
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 422) # Validation error
|
||||
|
||||
|
|
@ -1097,5 +1129,5 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
|
|||
del main.app.dependency_overrides[main.cookie_person]
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ import importlib
|
|||
|
||||
import tests.test_data as test_data
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
from db import connect, create
|
||||
import meals
|
||||
import meals.db as meals_db
|
||||
from meals.db import Meal, MealRecipe
|
||||
import meals.repository as meals_db
|
||||
from meals.models import Meal, MealRecipe
|
||||
import persons
|
||||
import recipes
|
||||
import ingredients
|
||||
|
|
@ -23,7 +25,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test the meals data models"""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -39,7 +41,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 1, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris]
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
)
|
||||
|
||||
self.assertEqual(meal.id, -1) # Default ID
|
||||
|
|
@ -53,11 +55,7 @@ class TestMealsModels(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
def test_meal_recipe_creation(self):
|
||||
"""Test basic MealRecipe creation"""
|
||||
meal_recipe = MealRecipe(
|
||||
meal_id=1,
|
||||
recipe_id=2,
|
||||
servings=4.0
|
||||
)
|
||||
meal_recipe = MealRecipe(meal_id=1, recipe_id=2, servings=4.0)
|
||||
|
||||
self.assertEqual(meal_recipe.meal_id, 1)
|
||||
self.assertEqual(meal_recipe.recipe_id, 2)
|
||||
|
|
@ -69,7 +67,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test meals CRUD operations"""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -85,7 +83,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 1, 15, 19, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -110,19 +108,14 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
recipe.id = -1 # Reset ID
|
||||
await recipes.insert_recipe(self.conn, recipe)
|
||||
|
||||
meal_recipe = MealRecipe(
|
||||
meal_id=-1,
|
||||
recipe_id=recipe.id,
|
||||
servings=3.0,
|
||||
recipe=recipe
|
||||
)
|
||||
meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=3.0, recipe=recipe)
|
||||
|
||||
meal = Meal(
|
||||
suggested_date=datetime(2024, 2, 1, 18, 30),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie, test_data.Persons.chris],
|
||||
recipes=[meal_recipe]
|
||||
recipes=[meal_recipe],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -143,7 +136,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
# Create a new product for testing
|
||||
product = products.Product(
|
||||
id=-1,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Test Garlic Bread",
|
||||
product_id="test_294517",
|
||||
quantity=1,
|
||||
|
|
@ -163,7 +156,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
unit="loaf",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=product.id
|
||||
product_id=product.id,
|
||||
)
|
||||
|
||||
meal = Meal(
|
||||
|
|
@ -171,7 +164,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie],
|
||||
extra_ingredients=[extra_ingredient]
|
||||
extra_ingredients=[extra_ingredient],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -196,7 +189,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 4, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -228,7 +221,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 5, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -250,7 +243,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 6, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -272,7 +265,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 7, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -304,21 +297,21 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 8, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
meal2 = Meal(
|
||||
suggested_date=datetime(2024, 8, 15, 18, 0),
|
||||
chefs=[test_data.Persons.ryan],
|
||||
cleanup=[test_data.Persons.jacob],
|
||||
consumers=[test_data.Persons.chris]
|
||||
consumers=[test_data.Persons.chris],
|
||||
)
|
||||
|
||||
meal3 = Meal(
|
||||
suggested_date=datetime(2024, 9, 1, 18, 0),
|
||||
chefs=[test_data.Persons.ellie],
|
||||
cleanup=[test_data.Persons.chris],
|
||||
consumers=[test_data.Persons.jacob]
|
||||
consumers=[test_data.Persons.jacob],
|
||||
)
|
||||
|
||||
# Create a consumed meal (should not appear in upcoming)
|
||||
|
|
@ -327,7 +320,7 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
consumed_date=datetime(2024, 8, 10, 19, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal1)
|
||||
|
|
@ -343,7 +336,9 @@ class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
|
|||
end_date = datetime(2024, 8, 31)
|
||||
|
||||
upcoming_meals = []
|
||||
async for meal in meals_db.find_upcoming_meals_by_date_range(self.conn, start_date, end_date):
|
||||
async for meal in meals_db.find_upcoming_meals_by_date_range(
|
||||
self.conn, start_date, end_date
|
||||
):
|
||||
upcoming_meals.append(meal)
|
||||
|
||||
# Should find meal1 and meal2, but not meal3 (outside range) or consumed_meal (consumed)
|
||||
|
|
@ -357,7 +352,7 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test meal participant management"""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -373,14 +368,14 @@ class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 10, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
||||
# Update participants
|
||||
new_chefs = [test_data.Persons.ryan, test_data.Persons.ellie]
|
||||
await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, 'chef')
|
||||
await meals_db.sync_meal_participants(self.conn, meal.id, new_chefs, "chef")
|
||||
|
||||
# Verify participants were updated
|
||||
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
|
||||
|
|
@ -401,7 +396,7 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test meal recipe management"""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -431,15 +426,13 @@ class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 11, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
||||
# Add recipes to meal
|
||||
meal_recipes = [
|
||||
MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0)
|
||||
]
|
||||
meal_recipes = [MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=4.0)]
|
||||
|
||||
await meals_db.sync_meal_recipes(self.conn, meal.id, meal_recipes)
|
||||
|
||||
|
|
@ -453,7 +446,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
"""Test meal extra ingredients management"""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
|
@ -468,7 +461,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
# Create a new product for testing
|
||||
product = products.Product(
|
||||
id=-1,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Test Bread Roll",
|
||||
product_id="test_bread_123",
|
||||
quantity=1,
|
||||
|
|
@ -484,7 +477,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
suggested_date=datetime(2024, 12, 1, 18, 0),
|
||||
chefs=[test_data.Persons.jacob],
|
||||
cleanup=[test_data.Persons.ryan],
|
||||
consumers=[test_data.Persons.ellie]
|
||||
consumers=[test_data.Persons.ellie],
|
||||
)
|
||||
|
||||
await meals_db.insert_meal(self.conn, meal)
|
||||
|
|
@ -497,7 +490,7 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
unit="roll",
|
||||
quantity=1.0,
|
||||
preparation="",
|
||||
product_id=product.id
|
||||
product_id=product.id,
|
||||
)
|
||||
|
||||
await meals_db.sync_extra_ingredients(self.conn, meal.id, [extra_ingredient])
|
||||
|
|
@ -508,5 +501,5 @@ class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(found_meal.extra_ingredients[0].name, "Test Bread Roll")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -2,17 +2,20 @@ import unittest
|
|||
|
||||
import tests.test_data as test_data
|
||||
|
||||
import products.db as products_db
|
||||
import products.repository as products_db
|
||||
from db import connect, create
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
class TestProductsDb(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
reload_test_data()
|
||||
return await super().asyncSetUp()
|
||||
|
|
@ -39,31 +42,35 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase):
|
|||
from . import httpx_mocks
|
||||
from products import woolworths
|
||||
|
||||
|
||||
class TestWoolworths(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
local_path = './tests/sample_files/woolworths'
|
||||
local_path = "./tests/sample_files/woolworths"
|
||||
woolworths._get_client = lambda: httpx_mocks.MockAsyncClient(local_path)
|
||||
# woolworths._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def test_get_product_id(self) -> None:
|
||||
params = [
|
||||
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', '144607'),
|
||||
('https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas', '133211'),
|
||||
('https://www.coles.com.au/product/coles-strawberries-250g-5191256', None),
|
||||
("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", "144607"),
|
||||
(
|
||||
"https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas",
|
||||
"133211",
|
||||
),
|
||||
("https://www.coles.com.au/product/coles-strawberries-250g-5191256", None),
|
||||
]
|
||||
|
||||
for url, id in params:
|
||||
self.assertEqual(woolworths.get_product_id(url), id)
|
||||
|
||||
async def test_get_strawberries(self) -> None:
|
||||
details, raw_data = await woolworths.scrape('144607')
|
||||
details, raw_data = await woolworths.scrape("144607")
|
||||
expected = {
|
||||
'name': 'Strawberries',
|
||||
'quantity': 250,
|
||||
'unit': 'g Punnet',
|
||||
'img_small': 'https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg',
|
||||
'img_large': 'https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg'
|
||||
"name": "Strawberries",
|
||||
"quantity": 250,
|
||||
"unit": "g Punnet",
|
||||
"img_small": "https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg",
|
||||
"img_large": "https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg",
|
||||
}
|
||||
|
||||
for key, value in expected.items():
|
||||
|
|
@ -72,33 +79,39 @@ class TestWoolworths(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
from products import coles
|
||||
|
||||
|
||||
class TestColes(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
local_path = './tests/sample_files/coles'
|
||||
local_path = "./tests/sample_files/coles"
|
||||
coles._get_client = lambda: httpx_mocks.MockAsyncClient(local_path)
|
||||
# coles._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def test_get_product_id(self) -> None:
|
||||
params = [
|
||||
('https://www.coles.com.au/product/coles-strawberries-250g-5191256', 'coles-strawberries-250g-5191256'),
|
||||
('https://www.coles.com.au/product/coles-blueberries-170g-3571948', 'coles-blueberries-170g-3571948'),
|
||||
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', None),
|
||||
(
|
||||
"https://www.coles.com.au/product/coles-strawberries-250g-5191256",
|
||||
"coles-strawberries-250g-5191256",
|
||||
),
|
||||
(
|
||||
"https://www.coles.com.au/product/coles-blueberries-170g-3571948",
|
||||
"coles-blueberries-170g-3571948",
|
||||
),
|
||||
("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", None),
|
||||
]
|
||||
|
||||
for url, id in params:
|
||||
self.assertEqual(coles.get_product_id(url), id)
|
||||
|
||||
async def test_get_strawberries(self) -> None:
|
||||
details, raw_data = await coles.scrape('coles-strawberries-250g-5191256')
|
||||
details, raw_data = await coles.scrape("coles-strawberries-250g-5191256")
|
||||
expected = {
|
||||
'name': 'Strawberries',
|
||||
'quantity': 250,
|
||||
'unit': 'g',
|
||||
'img_small': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg',
|
||||
'img_large': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg'
|
||||
"name": "Strawberries",
|
||||
"quantity": 250,
|
||||
"unit": "g",
|
||||
"img_small": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg",
|
||||
"img_large": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg",
|
||||
}
|
||||
|
||||
for key, value in expected.items():
|
||||
self.assertEqual(details[key], value, msg=key)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
115
tests/test_v1.py
Normal file
115
tests/test_v1.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import unittest
|
||||
import importlib
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import tests.test_data as test_data
|
||||
|
||||
from db import connect, create
|
||||
import main
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
class TestV1API(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
def test_v1_recipes_page_envelope(self):
|
||||
resp = self.client.get("/api/v1/recipes")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, dict)
|
||||
assert "items" in body
|
||||
assert isinstance(body["items"], list)
|
||||
assert len(body["items"]) >= 0
|
||||
|
||||
def test_v1_persons_page_envelope(self):
|
||||
resp = self.client.get("/api/v1/persons")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, dict)
|
||||
assert "items" in body
|
||||
assert isinstance(body["items"], list)
|
||||
|
||||
def test_v1_recipe_not_found_problem(self):
|
||||
resp = self.client.get("/api/v1/recipes/999999")
|
||||
assert resp.status_code == 404
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 404
|
||||
assert "title" in prob
|
||||
assert "type" in prob
|
||||
|
||||
def test_v1_meal_create_no_chefs_problem(self):
|
||||
meal_data = {
|
||||
"id": -1,
|
||||
"suggestedDate": "2024-06-01T18:00:00+00:00",
|
||||
"chefs": [],
|
||||
"cleanup": [{"id": 1, "name": "Ryan"}],
|
||||
"consumers": [{"id": 1, "name": "Ellie"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [],
|
||||
}
|
||||
resp = self.client.post("/api/v1/meals", json=meal_data)
|
||||
assert resp.status_code == 400
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 400
|
||||
assert "title" in prob
|
||||
|
||||
def test_v1_login_not_found_problem(self):
|
||||
resp = self.client.post("/api/v1/auth/login", json={"username": "nope"})
|
||||
assert resp.status_code == 404
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 404
|
||||
assert prob.get("title")
|
||||
|
||||
def test_v1_camel_case_keys(self):
|
||||
# persons endpoint should return camelCase in v1
|
||||
resp = self.client.get("/api/v1/persons")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "items" in body # Page envelope
|
||||
if body["items"]:
|
||||
# pick first person
|
||||
person = body["items"][0]
|
||||
assert "id" in person
|
||||
assert "name" in person
|
||||
|
||||
def test_v1_cursor_edge_cases(self):
|
||||
# invalid cursor should be treated as start
|
||||
resp = self.client.get("/api/v1/recipes?cursor=notanint&limit=1")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "items" in body
|
||||
# end-of-list cursor
|
||||
# get all to compute a large cursor
|
||||
all_resp = self.client.get("/api/v1/recipes?limit=200")
|
||||
items = all_resp.json()["items"]
|
||||
if items:
|
||||
last_id = items[-1]["id"]
|
||||
after_last = self.client.get(f"/api/v1/recipes?cursor={last_id}&limit=200")
|
||||
after_body = after_last.json()
|
||||
assert after_body["items"] == [] or after_body.get("nextCursor") is None
|
||||
24
units.py
24
units.py
|
|
@ -1,5 +1,3 @@
|
|||
from typing import Union
|
||||
|
||||
class Unit:
|
||||
def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0):
|
||||
self.name = name
|
||||
|
|
@ -15,6 +13,7 @@ class Unit:
|
|||
"""Converts a quantity from the base unit to this unit."""
|
||||
return quantity / self.conversion_to_base
|
||||
|
||||
|
||||
# Define common base units in SI units
|
||||
ITEMS = Unit("Items", ["item", "items"], "count", 1)
|
||||
LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1)
|
||||
|
|
@ -35,9 +34,26 @@ MILLIGRAM = Unit("Milligram", ["milligram", "milligrams", "mg"], "weight", 1)
|
|||
KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000)
|
||||
|
||||
# Big list of units
|
||||
ALL_UNITS = [ITEMS, LITRE, GRAM, CUP, TABLESPOON, TEASPOON, OUNCE, POUND, FLUID_OUNCE, PINT, QUART, GALLON, MILLILITRE, MILLIGRAM, KILOGRAM]
|
||||
ALL_UNITS = [
|
||||
ITEMS,
|
||||
LITRE,
|
||||
GRAM,
|
||||
CUP,
|
||||
TABLESPOON,
|
||||
TEASPOON,
|
||||
OUNCE,
|
||||
POUND,
|
||||
FLUID_OUNCE,
|
||||
PINT,
|
||||
QUART,
|
||||
GALLON,
|
||||
MILLILITRE,
|
||||
MILLIGRAM,
|
||||
KILOGRAM,
|
||||
]
|
||||
|
||||
def get_unit(alias: str) -> Union[Unit, None]:
|
||||
|
||||
def get_unit(alias: str) -> Unit | None:
|
||||
"""Returns the corresponding unit based on alias or abbreviation."""
|
||||
alias_lower = alias.lower()
|
||||
for unit in ALL_UNITS:
|
||||
|
|
|
|||
Loading…
Reference in a new issue