Compare commits

...

29 commits

Author SHA1 Message Date
0be2d1a2b0 Avoid optional arrays 2025-11-01 12:21:12 +11:00
5a9242ccc9 Tighten api endpoints 2025-10-26 15:14:05 +11:00
a6eb819057 cookie_person now mandatory 2025-10-21 19:59:29 +11:00
5a85250574 Remove nulls from public api 2025-10-21 19:42:23 +11:00
7b6f4e2a3b Squashed commit of the following:
commit 21a17b771743b23ee41d11a90ed8fdc3433468ce
Author: jableader <jacobdunk@gmail.com>
Date:   Mon Oct 20 00:12:02 2025 +1100

    Completed tooling improvements, fixed remaining errors

commit 7db48e222e3aa1065c326197c33ba6439720f65a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:05:37 2025 +1100

    autoformat

commit 5705ce24b64c2aa6f0b9426730a479165fa97e2a
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 22:05:29 2025 +1100

    tooling changes

commit f0a6b2fd147bb86b484927afd57b9ba0ac07bf47
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 21:25:49 2025 +1100

    Plan
2025-10-20 00:12:16 +11:00
5c33e9c2a4 Remove unused CI 2025-10-19 21:24:23 +11:00
5be4e89c4e Squashed commit of the following:
commit fcd005b8624023547f28b7b28e59e6099bcfc7d4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 20:24:07 2025 +1100

    Openapi tightening

commit f93bd8f641d561052c7bd075bae321b4ff3b676d
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 19:03:52 2025 +1100

    Removed refactor strategy doc

commit 0c5a61092f522be0c47cbbe86917c8a7e4e2d339
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 17:48:33 2025 +1100

    mypy & ruff checks

commit 23d66d6b18984127e17c73c3063f6120385935e9
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 16:49:35 2025 +1100

    Final removal of db.py files

commit f454aed1ca9783cc558cc203f29a7fe31b62a975
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:42:31 2025 +1100

    Finalise restructure, remove db.py files

commit 7187f6dd89489521538791c6bdebb426514beb99
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:34:54 2025 +1100

commit 6fea227ae20d32b8eb1e7a4885006a620fcc7bb1
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:32:53 2025 +1100

commit 27415e7e02d89195ad514cb017a9dbbf84d7a5e4
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:31:10 2025 +1100

commit b773428033d855f9ad82005602e049c1a2e3c585
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:28:58 2025 +1100

commit 116592c95278d995f4c516e87f2cea43cf5b7735
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:25:21 2025 +1100

commit 03ec565faea088971968ee2f9bb83e2de16b21f3
Author: jableader <jacobdunk@gmail.com>
Date:   Sun Oct 19 15:21:29 2025 +1100

    Plan
2025-10-19 20:24:23 +11:00
50a1fcaeee Openapi refactor complete 2025-10-19 15:20:36 +11:00
865c02b195 Fixed loc header 2025-10-19 15:06:51 +11:00
45ff778112 Move inline API request/response models out of main.py into feature modules, Add enums/constants for participant roles 2025-10-19 13:51:27 +11:00
589eb5380c Batch-load meal participants (fetch IDs once, bulk load persons) 2025-10-19 13:21:56 +11:00
322e14c26c Centralize transaction scoping per-request (dependency) and remove scattered conn.commit() in handlers 2025-10-19 13:12:28 +11:00
99b699eec9 Phase 2 — API design and OpenAPI 2025-10-18 18:21:04 +11:00
89cfbe9abc Expose for test 2025-10-18 17:54:43 +11:00
a3c0a2701e Removed redundant code from master and route to new submodules 2025-10-18 17:52:02 +11:00
f51c90f922 - [x] Wire routers in main with minimal app code
- [x] Create api/deps module for get_db, cookie_person, and error_response
- [x] Use settings.py (DOOF_DB) for DB path in main and deps
2025-10-18 17:14:03 +11:00
53b07343d1 API extractions 2025-10-18 17:05:48 +11:00
2bbed57313 Persons api extraction 2025-10-18 17:00:01 +11:00
edfc341b4b extract recipes 2025-10-18 16:54:35 +11:00
3d7afa3765 Scaffolding 2025-10-18 16:50:43 +11:00
1b76ea44b6 Initial plan and strategy 2025-10-18 16:48:32 +11:00
4c3f370ddc Squashed commit of the following:
commit 4189d9f824f681b480f797b109e963762eb22e9c
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:41:57 2025 +1100

    Openapi complete

commit bebf8c30cba0b85a889198fe44879614065a0c34
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:35:39 2025 +1100

    Removed unversioned api

commit dd9cc2eae75d66fceebe14c918c3ed8498376ee6
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:07:03 2025 +1100

    Spec updates

commit b993c4530688f79ea983278283f984e9d8e83860
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:01:50 2025 +1100

    docs(spec): update doof-back-spec with v1 RFC7807 422, reusable Problem* responses, and shopping/current aliasing; tests passing; openapi.json refreshed

commit 30bac7e57367b14ce924667a7955845de949d779
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 16:00:13 2025 +1100

    openapi polish

commit eb7f7f224f7085fa5b3fadc7716db0ebb7f47eb0
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:54:52 2025 +1100

    OpenAPI reusable responses: Added components.responses for `Problem400`, `Problem404`, and `Problem422`; v1 routes reference these consistently.
     - Units enum: Exposed advisory enum in schema for `Ingredient.unit` using existing units list (no runtime enforcement).

commit 037037e17d684a89b264f2406377970a0de7ec99
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:43:01 2025 +1100

    Add `total` counts to v1 page responses for recipes/persons; push persons name filter into SQL for v1 when `q` is provided.

commit 07e7735076aae8cbd04bb10f9aa324c1a3d80ae4
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:40:17 2025 +1100

    Add parameter descriptions for `cursor`, `limit`, and `q` on v1 list endpoints; include example `Page` envelopes in 200 responses.

commit e5bf9396b0fe870501d1b4712cba2555dd2ef9b1
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:36:27 2025 +1100

    DB pagination

commit 782315cd2a0c18cc50e4deaf28e7ec6e4b58c6e2
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:29:38 2025 +1100

    camelcase tests

commit b207c33e2844c00fc9e531fb9cf8c07a3f5cd543
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:24:57 2025 +1100

    OpenAPI enrichment, Error responses

commit dc84681ab743008e5cd8bec7f3ccb0d05b557518
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:16:39 2025 +1100

    v1 tests: Added basic tests to assert `Page` envelopes and RFC7807 responses for v1 endpoints without affecting legacy tests.

commit 1524b7a98ffe04af11c2731c8125ac9348751cff
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:14:51 2025 +1100

    Pagination

commit 92e91d7acf15c09b14bc76f7b16a7a47e65129ec
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:03:50 2025 +1100

    Use middleware for naming case changes

commit c68f964f9b8e3d8f8ef020661e747e0990459c81
Author: jableader <jacobdunk@gmail.com>
Date:   Sat Oct 18 15:00:09 2025 +1100

    Openapi gen
2025-10-18 16:44:36 +11:00
18f784665f Added ruff, black and mypy 2025-10-18 14:26:42 +11:00
255ebd4613 Squashed commit of the following:
commit 1c707260a4059cbdebb3e2720d3899b8773a1c37
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 22:03:48 2025 +1000

    Sync tests

commit 5a71077121921a2cea9f3be9c1d1f7770691520a
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:57:00 2025 +1000

    minor refactorings

commit 0bafec1b40bf5cf6f1554f6e5ec6e8f4e043d1cc
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:39:52 2025 +1000

    Remove ids from shopping list

commit c1ee30d1b591b241dfa152d8a0fa9af1f6aaaed1
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 21:04:48 2025 +1000

    Added more cases to shopping tests

commit ad3d598bdb574b1aeaabc354bb825ee428aaaa41
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:26:36 2025 +1000

    Added 404 response when list not found

commit 336538dc47e12736f7f57d2c80252a700dc1e83b
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:15:52 2025 +1000

    Tests for main

commit 238c0f89b2671348091a162ee519e5f1bf2cd66c
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 20:07:12 2025 +1000

    Meals tests

commit b92f2432ee13f5384b6f1a9b8efe76bec9dbd920
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:57:45 2025 +1000

    Shopping tests

commit b3c7ae20e6207cf0f9f04bdec5cb6145b04638d9
Author: jableader <jacobdunk@gmail.com>
Date:   Wed Jul 30 19:40:19 2025 +1000

    product tests passing
2025-07-30 22:05:29 +10:00
2a66ff39be Vibe coded some tests -_- 2025-07-30 18:55:51 +10:00
54f0dcbbb9 Remove request when meal purchased 2025-07-30 18:25:07 +10:00
410b097d5c Fixed purchased item matching 2025-07-30 18:19:20 +10:00
4fa88335d1 Fixed async generator crash 2025-07-29 17:15:38 +10:00
1706809458 Allowed for null products, move ingredient spreading server-side (#1)
Reviewed-on: #1
Co-authored-by: jableader <jacobdunk@gmail.com>
Co-committed-by: jableader <jacobdunk@gmail.com>
2025-07-28 23:23:09 +00:00
65 changed files with 10190 additions and 1877 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# Database configuration
DOOF_DB=./data/doof.sqlite
# Production flag (set to "true" in production)
DOOF_PROD=false
# Frontend development server URL (used when DOOF_PROD=false)
FRONTEND_DEV_URL=http://localhost:8080/
# Server port (used in containerized deployments)
# DOOF_PORT=8000

44
.gitignore vendored
View file

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

29
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,29 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
# Run the linter
- id: ruff
args: [--fix]
# Run the formatter
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies:
- pydantic==2.9.2
- fastapi==0.115.0
- httpx==0.27.2
- aiosqlite==0.20.0
args: [--config-file=pyproject.toml]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict

186
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,186 @@
# Contributing to Doof Backend
Thank you for your interest in contributing! This document provides guidelines and workflows for contributing to the project.
## Getting Started
### First-time setup
1. Clone the repository
2. Run the setup script:
```bash
make install
```
This will:
- Create a virtual environment in `.venv`
- Install all dependencies
- Install pre-commit hooks
3. Activate the virtual environment:
```bash
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
4. Run tests to verify everything works:
```bash
make test
```
## Development Workflow
### Running the application
Start the development server:
```bash
make dev
```
The server will run at `http://localhost:8000` with auto-reload enabled.
### Code Quality
Before committing, ensure your code passes all checks:
```bash
make all-checks
```
This runs:
- `ruff check` - Linting
- `ruff format --check` - Format verification
- `mypy` - Type checking
- `pytest` - Tests
- OpenAPI export
Individual checks can be run separately:
```bash
make format # Auto-format code
make lint # Lint code
make typecheck # Type check
make test # Run tests
```
### Pre-commit Hooks
Pre-commit hooks are automatically installed with `make install`. They run on every commit to:
- Format code with ruff
- Lint code with ruff
- Type check with mypy
- Fix trailing whitespace
- Ensure files end with a newline
If pre-commit fails, fix the issues and commit again.
To run pre-commit manually:
```bash
pre-commit run --all-files
```
## Code Style
- **Formatting**: We use [ruff](https://docs.astral.sh/ruff/) for both linting and formatting
- **Line length**: 100 characters (configured in `pyproject.toml`)
- **Type hints**: Use type hints where possible; mypy is configured for basic type checking
- **Imports**: Organized automatically by ruff (isort-style)
## Testing
- All tests live in the `tests/` directory
- We use `pytest` as the test runner
- Tests use in-memory SQLite for fast, hermetic execution
- HTTP requests are mocked using fixtures in `tests/httpx_mocks.py`
### Writing tests
```python
import unittest
from db import connect, create
class TestMyFeature(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
# Add test data if needed
return await super().asyncSetUp()
async def asyncTearDown(self):
await self.conn.close()
return await super().asyncTearDown()
async def test_something(self):
# Your test here
pass
```
Run tests:
```bash
make test
```
## Project Structure
```
.
├── api/ # FastAPI routers and endpoints
├── ingredients/ # Ingredient models and repository
├── meals/ # Meal models, repository, and service logic
├── persons/ # Person models and repository
├── products/ # Product models, repository, and scrapers
├── recipes/ # Recipe models, repository, and scraping
├── shopping/ # Shopping list models and repository
├── scripts/ # Utility scripts (e.g., OpenAPI export)
├── tests/ # Test suite
├── main.py # FastAPI application entry point
├── db.py # Database connection and schema bootstrap
└── settings.py # Configuration and environment variables
```
## Environment Variables
Copy `.env.example` to `.env` and customize:
```bash
cp .env.example .env
```
Key variables:
- `DOOF_DB` - Database file path (default: `./data/doof.sqlite`)
- `DOOF_PROD` - Production mode flag (default: `false`)
- `FRONTEND_DEV_URL` - Frontend dev server URL for reverse proxy
## OpenAPI Schema
Export the OpenAPI schema:
```bash
make openapi
```
This generates `openapi.json` from the FastAPI application.
## Commit Guidelines
- Write clear, descriptive commit messages
- Keep commits focused and atomic
- Reference issue numbers if applicable
- Pre-commit hooks will enforce code quality
Example commit message:
```
Add meal duplication detection
- Implement get_duplicates function in meals service
- Add test coverage for duplicate detection
- Update API endpoint to return duplicates
Fixes #123
```
## Need Help?
- Check the [README.md](README.md) for basic setup and usage
- Review [tooling-spec.md](tooling-spec.md) for tooling details
- Open an issue for bugs or feature requests
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.

63
Makefile Normal file
View file

@ -0,0 +1,63 @@
.PHONY: help install format lint typecheck test openapi dev all-checks clean
# Use activated environment if available, otherwise use .venv explicitly
PYTHON := $(shell command -v python 2>/dev/null || echo .venv/bin/python)
PIP := $(shell command -v pip 2>/dev/null || echo .venv/bin/pip)
help:
@echo "Available targets:"
@echo " install - Create venv and install dependencies"
@echo " format - Format code with ruff"
@echo " lint - Lint code with ruff"
@echo " typecheck - Type check with mypy"
@echo " test - Run tests with pytest"
@echo " openapi - Export OpenAPI schema"
@echo " dev - Run development server"
@echo " all-checks - Run all quality checks"
@echo " clean - Remove venv and caches"
@echo ""
@echo "Note: Make sure to activate your venv first: source .venv/bin/activate"
install:
@echo "Creating virtual environment..."
python3 -m venv .venv
@echo "Installing dependencies..."
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -r dev-requirements.txt
@echo "Installing pre-commit hooks..."
.venv/bin/pre-commit install
@echo "Done! Activate with: source .venv/bin/activate"
format:
$(PYTHON) -m ruff format .
lint:
$(PYTHON) -m ruff check .
typecheck:
$(PYTHON) -m mypy .
test:
$(PYTHON) -m pytest -q
openapi:
$(PYTHON) scripts/export_openapi.py
dev:
$(PYTHON) -m uvicorn main:app --reload
all-checks: lint typecheck test
@echo "Running format check..."
$(PYTHON) -m ruff format --check .
@echo "Exporting OpenAPI schema..."
$(PYTHON) scripts/export_openapi.py
@echo ""
@echo "✓ All checks passed!"
clean:
rm -rf .venv
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type d -name .pytest_cache -exec rm -rf {} +
find . -type d -name .mypy_cache -exec rm -rf {} +
find . -type d -name .ruff_cache -exec rm -rf {} +

131
README.md
View file

@ -1,11 +1,132 @@
Meal planner backend Meal planner backend
Install packages ## Quickstart
```
pip install -r ./requirements.txt First time setup:
```bash
make install
``` ```
Run with Run the development server:
```bash
make dev
``` ```
uvicorn main:app
Run tests:
```bash
make test
``` ```
Run all quality checks (lint, typecheck, test, format check, OpenAPI export):
```bash
make all-checks
```
## 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
### Manual setup (alternative to make install)
Create and activate virtual environment:
```bash
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
Install packages:
```bash
pip install -r ./requirements.txt
pip install -r ./dev-requirements.txt
```
Install pre-commit hooks:
```bash
pip install pre-commit
pre-commit install
```
### Running the application
Run API (dev):
```bash
make dev
# or: uvicorn main:app --reload
```
Run tests:
```bash
make test
# or: pytest -q
```
### Available Make targets
- `make install` - Create venv and install all dependencies
- `make dev` - Run development server
- `make test` - Run tests
- `make format` - Format code with ruff
- `make lint` - Lint code with ruff
- `make typecheck` - Type check with mypy
- `make openapi` - Export OpenAPI schema
- `make all-checks` - Run all quality checks
- `make clean` - Remove venv and caches
## Tooling
This repo includes baseline configs in `pyproject.toml`:
- ruff (format and lint)
- mypy (type check)
Pre-commit hooks are configured to run:
- ruff (lint + format)
- mypy
- trailing whitespace fixer
- end-of-file fixer
Quality checks (run locally):
```bash
make format # Format code
make lint # Lint code
make typecheck # Type check
make all-checks # Run all checks
```
## Environment variables
Copy `.env.example` to `.env` and customize as needed:
- DOOF_DB: Path to sqlite database (default: `./data/doof.sqlite`)
- DOOF_PROD: Set to `true` in production (default: `false`)
- FRONTEND_DEV_URL: Frontend dev server URL for reverse proxy (default: `http://localhost:8080/`)
- 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
View file

@ -0,0 +1,2 @@
# API package for FastAPI routers.
# Routers will be split by feature: recipes, meals, persons, shopping, auth.

53
api/auth.py Normal file
View file

@ -0,0 +1,53 @@
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 | Response:
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

79
api/deps.py Normal file
View file

@ -0,0 +1,79 @@
from __future__ import annotations
from typing import AsyncGenerator, Optional
import aiosqlite
from fastapi import Cookie, Depends, HTTPException, 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: int = Cookie(..., alias="user_id"),
conn: aiosqlite.Connection = Depends(get_db),
) -> persons.Person:
"""Return the authenticated user from the user_id cookie or raise 401.
When the cookie is missing, FastAPI will raise 422 (validation error).
"""
person = await persons.get_by_id(conn, user_id)
if not person:
raise HTTPException(status_code=401, detail="Unauthorized")
return person
async def cookie_person_optional(
user_id: Optional[int] = Cookie(default=None, alias="user_id"),
conn: aiosqlite.Connection = Depends(get_db),
) -> Optional[persons.Person]:
"""Return the authenticated user if cookie present; otherwise None.
Use for endpoints that want to return 401 for missing auth themselves.
"""
if user_id is None:
return None
person = await persons.get_by_id(conn, user_id)
return person
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",
)

220
api/meals.py Normal file
View file

@ -0,0 +1,220 @@
from __future__ import annotations
import datetime
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import ingredients
import persons
import shopping
from api.deps import cookie_person, error_response, get_db
from common import ProblemDetails, ApiModel, Field
router = APIRouter(prefix="/meals", tags=["meals"])
class MealOut(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
cleanup: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
consumers: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0})
recipes: List[meals.MealRecipe] = Field(min_length=0, json_schema_extra={"minItems": 0})
extra_ingredients: List[ingredients.Ingredient] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
purchase_date: Optional[datetime.datetime] = None
@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=MealOut,
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=MealOut,
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=MealOut,
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=MealOut,
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=MealOut,
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

146
api/openapi.py Normal file
View file

@ -0,0 +1,146 @@
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": []})
# Ensure the cookie parameter is documented as required integer (non-null)
params = op.get("parameters")
if isinstance(params, list):
for p in params:
if not isinstance(p, dict):
continue
if p.get("in") == "cookie" and p.get("name") == "user_id":
p["required"] = True
schema = p.setdefault("schema", {})
if isinstance(schema, dict):
schema.clear()
schema.update({"type": "integer", "title": "User Id"})
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
# Normalize outward-facing shopping list storeName enum to avoid empty-string value
schemas = components.setdefault("schemas", {})
# Define outward-only enum for store names
schemas.setdefault(
"StoreNameOut",
{
"type": "string",
"enum": ["woolworths", "coles", "home"],
"title": "StoreNameOut",
},
)
# Replace any storeName prop that points to StoreEnum (which includes "") with outward StoreNameOut
for schema in schemas.values():
if not isinstance(schema, dict):
continue
props = schema.get("properties")
if not isinstance(props, dict):
continue
store = props.get("storeName")
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
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]

93
api/persons.py Normal file
View file

@ -0,0 +1,93 @@
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
View 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

278
api/recipes.py Normal file
View file

@ -0,0 +1,278 @@
from __future__ import annotations
import datetime
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import ingredients as ingredients_mod
import persons
import recipes
from api.deps import cookie_person, error_response, get_db
from common import Page, ProblemDetails, ApiModel, Field
router = APIRouter(prefix="/recipes", tags=["recipes"])
# Outward DTO with required arrays in the schema
class RecipeOut(ApiModel):
id: int = -1
name: str
link: str
serves: int
image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0})
ingredients: List[ingredients_mod.Ingredient] = Field(
min_length=0, json_schema_extra={"minItems": 0}
)
based_on_recipe: Optional[int] = None
date_created: datetime.datetime
created_by_id: int
created_by: Optional[persons.Person] = None
date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None
hidden_by: Optional[persons.Person] = None
@router.get(
"/parse",
response_model=RecipeOut,
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_mod.Ingredient]:
had_links = False
result: List[ingredients_mod.Ingredient] = []
for line in lines:
ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line)
if ingredient:
result.append(ingredient)
had_links = True
continue
ingredient = ingredients_mod.parse_ingredient_from_nlp(line)
if ingredient:
result.append(ingredient)
continue
if had_links:
# Transaction will commit at end of request
pass
await ingredients_mod.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_mod.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
@router.get(
"",
operation_id="listRecipes",
response_model=Page[RecipeOut],
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_mod.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=RecipeOut,
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=RecipeOut,
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_mod.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

378
api/shopping.py Normal file
View file

@ -0,0 +1,378 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Dict, List, Literal
import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
import ingredients
import meals
import persons
import recipes
import shopping
from shopping.models import StoreEnum
from api.deps import cookie_person, error_response, get_db
from common import ApiModel, Field, ProblemDetails
# Outward-facing models to reduce unnecessary nulls in API responses
class ListIngredientItem(ApiModel):
kind: Literal["ingredient"] = "ingredient"
id: int = -1
ingredient_id: int
person_id: int
created_date: datetime
# These may be present when the ingredient is part of a requested meal
list_id: int | None = None
meal_id: int | None = None
recipe_id: int | None = None
class RequestedMealItem(ApiModel):
kind: Literal["requestedMeal"] = "requestedMeal"
id: int = -1
person_id: int
meal_id: int
created_date: datetime
# Input DTOs (separate from internal DB/domain models)
class IngredientPurchaseItemIn(ApiModel):
ingredient_id: int
person_id: int
created_date: datetime | None = None
meal_id: int | None = None
recipe_id: int | None = None
class PurchaseListIn(ApiModel):
store_name: StoreEnum
items: List[IngredientPurchaseItemIn]
# Output DTOs for purchased lists
class StoreNameOut(str, Enum):
woolworths = "woolworths"
coles = "coles"
home = "home"
class ShoppingListOut(ApiModel):
id: int
created_date: datetime
# outward-only enum values: include "home" instead of an empty string
store_name: Literal["woolworths", "coles", "home"]
purchased_by_id: int
purchased_by: persons.Person | None = None
# Make items required in the schema; callers must always send an array (possibly empty)
items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
router = APIRouter(prefix="/shopping", tags=["shopping"])
class CurrentShoppingList(ApiModel):
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
# Make all collections required to avoid undefined/null semantics in clients
purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
ingredients_lookup: Dict[int, ingredients.Ingredient]
meals_lookup: Dict[int, meals.Meal]
shopping_list_lookup: Dict[int, ShoppingListOut]
recipes_lookup: Dict[int, recipes.Recipe]
# Mapping helpers from domain -> outward API
def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem:
return ListIngredientItem(
id=item.id,
ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1,
person_id=item.person_id,
created_date=item.created_date,
list_id=item.list_id,
meal_id=item.meal_id,
recipe_id=item.recipe_id,
)
def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem:
return RequestedMealItem(
id=item.id,
person_id=item.person_id,
meal_id=item.meal_id if item.meal_id is not None else -1,
created_date=item.created_date,
)
def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut:
# Map internal enum value "" to outward-friendly "home"
outward_store = "home" if sl.store_name == StoreEnum.home else sl.store_name.value
return ShoppingListOut(
id=sl.id,
created_date=sl.created_date,
store_name=outward_store,
purchased_by_id=sl.purchased_by_id,
purchased_by=sl.purchased_by,
items=[_to_ingredient_item(i) for i in sl.items],
)
@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}
# Load full lists for additional lookups
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
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:
other_lists_domain[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
# Convert domain shopping lists to outward form for response
shopping_list_lookup: Dict[int, ShoppingListOut] = {
k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()
}
return CurrentShoppingList(
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
requested_meals=[_to_meal_item(i) for i in meal_requests],
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
class PurchasedShoppingList(ApiModel):
list: ShoppingListOut
# Lookup maps are required to be present (may be empty)
meals_lookup: Dict[int, meals.Meal]
ingredients_lookup: Dict[int, ingredients.Ingredient]
recipes_lookup: Dict[int, recipes.Recipe]
@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=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredients",
summary="Purchase ingredients for a shopping list",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
},
401: {
"model": ProblemDetails,
"description": "Unauthorized (invalid or unknown user)",
"content": {"application/problem+json": {}},
},
},
)
async def purchase_ingredients(
shopping_list: PurchaseListIn,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> PurchasedShoppingList | Response:
# Ensure the caller is authenticated and maps to a known user
if not person:
return error_response(request, 401, "Unauthorized")
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(
purchased_by=person, items=domain_items, store_name=shopping_list.store_name
)
try:
await shopping.purchase(conn, domain_list)
except ValueError as e:
# Map domain validation errors to a proper Problem Details response
return error_response(request, 400, str(e))
# Build lookup maps and construct the outward response with required collections
meals_lookup: Dict[int, meals.Meal]
recipes_lookup: Dict[int, recipes.Recipe]
ingredients_lookup: Dict[int, ingredients.Ingredient]
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, domain_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(domain_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@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=RequestedMealItem,
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),
) -> RequestedMealItem | 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 _to_meal_item(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

56
common.py Normal file
View file

@ -0,0 +1,56 @@
from typing import Any, Dict, Generic, List, Optional, TypeVar
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]:
if isinstance(data, dict):
for key, value in data.copy().items():
if not key.endswith("_id") and hasattr(value, "id") and value is not None:
id_key = key + "_id"
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}")
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] = Field(min_length=0, json_schema_extra={"minItems": 0})
next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
total: int = Field(default=0, description="Total count")

28
db.py
View file

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

10
dev-requirements.txt Normal file
View file

@ -0,0 +1,10 @@
# Development dependencies
# Install with: pip install -r dev-requirements.txt
# Code quality tools
ruff>=0.6.9
mypy>=1.11.2
pre-commit>=3.8.0
# Testing (pytest already in main requirements via fastapi dev deps)
pytest>=7.0

View file

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

View file

@ -1,81 +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 < 0:
raise ValueError('Product must be inserted before ingredient')
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_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
View 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

159
ingredients/repository.py Normal file
View file

@ -0,0 +1,159 @@
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,),
)

564
main.py
View file

@ -1,381 +1,187 @@
import sqlite3 from contextlib import asynccontextmanager
import products, recipes, db, meals, persons, ingredients, shopping from typing import Any, Dict
import datetime
from pydantic import BaseModel from fastapi import FastAPI, Request
from typing import List, Annotated, Optional, Union from fastapi.exceptions import RequestValidationError
from fastapi import FastAPI, Depends, Query, Cookie
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute
from pydantic import ValidationError
app = FastAPI() from starlette.exceptions import HTTPException as StarletteHTTPException
import os from api import (
DATABASE_PATH = os.environ.get('DOOF_DB', './data/doof.sqlite') auth as auth_router,
meals as meals_router,
# Dependency to create SQLite connection persons as persons_router,
async def get_db(): products as products_router,
sql_db = await db.connect(DATABASE_PATH) recipes as recipes_router,
try: shopping as shopping_router,
yield sql_db )
finally: from api.deps import (
await sql_db.close() cookie_person as cookie_person, # noqa: F401 - re-exported for tests
error_response as error_response, # noqa: F401 - re-exported for completeness
async def cookie_person(user_id: Annotated[int, Cookie(alias='user_id')], conn: sqlite3.Connection = Depends(get_db)) -> persons.Person: get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
return await persons.get_by_id(conn, user_id) )
from api.openapi import extend_with_problem_and_cookie_auth
@app.get("/api/recipes/parse") from common import ApiModel, ProblemDetails
async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db), person = Depends(cookie_person)) -> recipes.Recipe: from settings import settings
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
return JSONResponse(status_code=400, content={'message': 'Recipe not found'}) class CamelCaseRoute(APIRoute):
return parsed def __init__(self, *args, **kwargs):
kwargs.setdefault("response_model_by_alias", True)
@app.get("/api/recipes/ingredients/parse") kwargs.setdefault("response_model_exclude_none", True)
async def parse_ingredients(lines: Annotated[ super().__init__(*args, **kwargs)
List[str],
Query(alias="ingredients",
title="Array of ingredients to parse")], @asynccontextmanager
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]: async def app_lifespan(app: FastAPI):
client = None
had_links = False if not settings.prod:
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'})
for ingredient in recipe.ingredients:
if not ingredient.product:
return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'})
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
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.unrequest_meal(conn, 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)) -> 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 meals.delete_meal(conn, meal.id)
await shopping.unrequest_meal(conn, meal)
await conn.commit()
return meal
class CurrentShoppingList(BaseModel):
requests: List[shopping.ShoppingListRequest]
overlapping_previous_shops: List[shopping.ShoppingList]
@app.get("/api/shopping/current")
async def get_current_shopping_list(conn: sqlite3.Connection = Depends(get_db)) -> CurrentShoppingList:
current_requests = [r async for r in shopping.get_current_requests(conn)]
requested_meals = [r.meal_id for r in current_requests if r.meal_id]
upcoming_meals = [m.id async for m in meals.find_upcoming_meals_by_date_range(conn, datetime.datetime.now().astimezone(), datetime.datetime.now().astimezone() + datetime.timedelta(days=14))]
overlapping = {}
for meal_id in set(requested_meals + upcoming_meals):
async for r in shopping.get_shopping_list_with_meal(conn, meal_id):
overlapping[r.id] = r
return CurrentShoppingList(requests=current_requests, overlapping_previous_shops=list(overlapping.values()))
@app.get("/api/shopping/{list_id}")
async def get_shopping_list(list_id: int, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
return await shopping.load_shopping_list(conn, list_id)
class ShoppingListPurchase(shopping.ShoppingList):
completed_requests: List[shopping.ShoppingListRequest] = []
@app.post("/api/shopping/")
async def purchase_ingredients(lst: ShoppingListPurchase, conn: sqlite3.Connection = Depends(get_db)) -> shopping.ShoppingList:
await shopping.insert_shopping_list(conn, lst)
for request in lst.completed_requests:
if request.meal_id:
meal = await meals.find_meal_by_id(conn, request.meal_id)
if meal:
await meals.mark_purchased(conn, meal)
await shopping.remove_request(conn, request)
await conn.commit()
return lst
@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[shopping.ShoppingListRequest]:
return [r async for r in shopping.get_current_requests(conn) if r.ingredient and r.person_id == 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[shopping.ShoppingListRequest]:
result = [r async for r in shopping.sync_persons_requested_ingredients(conn, person, requests) if r.ingredient]
await conn.commit()
return result
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.ShoppingListRequest:
meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal:
return JSONResponse(status_code=404, content={'message': 'Meal not found'})
response = await shopping.request_meal(conn, person, 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.unrequest_meal(conn, 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
import httpx 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, # RFC7807 Problem Details handlers (standalone functions, registered in factory)
query=request.url.query.encode("utf-8")) async def http_exc_handler(request: Request, exc: Exception):
rp_req = client.build_request(request.method, url, # Narrow to StarletteHTTPException at runtime
headers=request.headers.raw, assert isinstance(exc, StarletteHTTPException)
content=request.stream()) 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"))
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
try:
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
except Exception:
is_shopping_post = False
if is_shopping_post:
if any(
isinstance(e.get("loc"), (list, tuple))
and len(e.get("loc")) >= 2
and e.get("loc")[0] == "cookie"
and e.get("loc")[1] == "user_id"
for e in exc.errors()
):
body = ProblemDetails(
title="Unauthorized",
status=401,
type="https://httpstatuses.com/401",
instance=str(request.url),
errors=errors,
)
return JSONResponse(
content=body.model_dump(by_alias=True),
status_code=401,
media_type="application/problem+json",
)
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) rp_resp = await client.send(rp_req, stream=True)
return StreamingResponse( return StreamingResponse(
rp_resp.aiter_raw(), rp_resp.aiter_raw(),
@ -384,4 +190,14 @@ else:
background=BackgroundTask(rp_resp.aclose), background=BackgroundTask(rp_resp.aclose),
) )
app.add_route("/{path:path}",_reverse_proxy, ["GET", "POST"]) app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
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

View file

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

View file

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

358
meals/repository.py Normal file
View file

@ -0,0 +1,358 @@
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
View 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
View 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

2863
openapi.json Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

196
persons/repository.py Normal file
View file

@ -0,0 +1,196 @@
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

View file

@ -1,25 +1,30 @@
import json import json
from typing import List, Optional, Tuple
from products.db import Product, find_product_by_tag, find_product_by_key, insert_product, get_tags, add_tag, find_product_by_id from products import coles, woolworths
from products.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(): for shop_code, shop_scraper in SCRAPERS.items():
product_id = shop_scraper.get_product_id(link) product_id = shop_scraper.get_product_id(link)
if product_id: if product_id:
return shop_code, product_id return shop_code, product_id
return None, None return None, None
async def add_missing_tags(conn, product: Product, tags: List[str]):
existing_tags = set()
async for tag in get_tags(conn, product):
existing_tags.add(tag)
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 remaining_tags = set(tags) - existing_tags
if not remaining_tags: if not remaining_tags:
return False return False
@ -29,9 +34,10 @@ async def add_missing_tags(conn, product: Product, tags: List[str]):
return product return product
async def get_or_create(conn, url: str, tags: List[str]) -> Product:
async def get_or_create(conn, url: str, tags: List[str]) -> Optional[Product]:
shop_code, product_id = _get_shop_key(url) shop_code, product_id = _get_shop_key(url)
if not product_id: if not shop_code or not product_id:
return None return None
existing = await find_product_by_key(conn, shop_code, product_id) existing = await find_product_by_key(conn, shop_code, product_id)
@ -39,30 +45,34 @@ async def get_or_create(conn, url: str, tags: List[str]) -> Product:
await add_missing_tags(conn, existing, tags) await add_missing_tags(conn, existing, tags)
return existing return existing
product_data, raw_response = await SCRAPERS[shop_code].scrape(product_id) scraper = SCRAPERS[shop_code]
product = Product( product_data, raw_response = await scraper.scrape(product_id)
id=-1, product = Product(id=-1, shop_code=shop_code, product_id=product_id, link=url, **product_data)
shop_code=shop_code,
product_id=product_id,
link=url,
**product_data
)
await insert_product(conn, product, raw_response) await insert_product(conn, product, raw_response)
await add_missing_tags(conn, product, tags) await add_missing_tags(conn, product, tags)
return product return product
def _dump_json_data_to_log(data: dict, product_id: str) -> str: def _dump_json_data_to_log(data: dict, product_id: str) -> str:
import os, re import os
dir = './data/dump' import re
dir = "./data/dump"
if not os.path.exists(dir): if not os.path.exists(dir):
os.makedirs(dir) os.makedirs(dir)
prefix = f'product_{product_id}' prefix = f"product_{product_id}"
suffix = '.json' suffix = ".json"
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)] file_ids = [
int(re.findall(r"\d+", f)[0])
for f in os.listdir(dir)
if re.match(prefix + r"\d+" + suffix, f)
]
id = max(file_ids) + 1 if file_ids else 0 id = max(file_ids) + 1 if file_ids else 0
filename = f'{prefix}{id}{suffix}' filename = f"{prefix}{id}{suffix}"
with open(os.path.join(dir, filename), 'w') as f: full_path = os.path.join(dir, filename)
with open(full_path, "w") as f:
json.dump(data, f, indent=4) json.dump(data, f, indent=4)
return full_path

View file

@ -1,46 +1,58 @@
import re, httpx import re
from typing import Union from typing import Optional, Tuple
import httpx
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
'Accept-Language': 'en-US,en;q=0.5', "Accept-Language": "en-US,en;q=0.5",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'DNT': '1', "DNT": "1",
'Sec-GPC': '1', "Sec-GPC": "1",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Upgrade-Insecure-Requests': '1', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Dest': 'document', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-Site': 'none', "Sec-Fetch-Site": "none",
'Sec-Fetch-User': '?1', "Sec-Fetch-User": "?1",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
} }
async def _get_cookies(client): async def _get_cookies(client):
# Make a request to https://www.coles.com.au/ as if we were a normal browser, then return the cookies # Make a request to https://www.coles.com.au/ as if we were a normal browser, then return the cookies
response = await client.get('https://www.coles.com.au/product/coles-strawberries-250g-5191256', headers=HEADERS, follow_redirects=True) response = await client.get(
version = re.findall(r'202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d', response.text) "https://www.coles.com.au/product/coles-strawberries-250g-5191256",
headers=HEADERS,
follow_redirects=True,
)
version = re.findall(r"202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d", response.text)
if len(version) == 0: if len(version) == 0:
raise Exception('Could not find the Coles API') raise Exception("Could not find the Coles API")
return dict(response.cookies), version[0] return dict(response.cookies), version[0]
def _get_package_size(size: str) -> Union[int, str]:
def _get_package_size(size: str) -> Tuple[int, str]:
if size: if size:
match = re.match(r'(\d+)(.*)', size) match = re.match(r"(\d+)(.*)", size)
if match: if match:
return int(match.group(1)), match.group(2) return int(match.group(1)), match.group(2)
return 1, 'items' return 1, "items"
def _get_client(): def _get_client():
return httpx.AsyncClient() return httpx.AsyncClient()
api_details = None api_details = None
async def _request_details(product_id: str) -> dict:
async def _request_details(product_id: str) -> Optional[dict]:
global api_details global api_details
async with _get_client() as client: async with _get_client() as client:
@ -51,7 +63,9 @@ async def _request_details(product_id: str) -> dict:
url = _get_product_details_url(api_version, product_id) url = _get_product_details_url(api_version, product_id)
try: try:
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies) response = await client.get(
url, headers=HEADERS, follow_redirects=True, cookies=cookies
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPError as ne: except httpx.HTTPError as ne:
@ -60,35 +74,40 @@ async def _request_details(product_id: str) -> dict:
return None return None
def _get_product_details_url(version: str, product_id: str) -> str: def _get_product_details_url(version: str, product_id: str) -> str:
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/cadbury-favourites-boxed-chocolate-340g-3571992.json?slug=cadbury-favourites-boxed-chocolate-340g-3571992 # https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/cadbury-favourites-boxed-chocolate-340g-3571992.json?slug=cadbury-favourites-boxed-chocolate-340g-3571992
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/coles-blueberries-170g-3571948.json?slug=coles-blueberries-170g-3571948 # https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/coles-blueberries-170g-3571948.json?slug=coles-blueberries-170g-3571948
# #
return f'https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json' return f"https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json"
def get_product_id(url: str) -> str:
def get_product_id(url: str) -> Optional[str]:
# https://www.coles.com.au/product/cadbury-favourites-boxed-chocolate-340g-3571992 # https://www.coles.com.au/product/cadbury-favourites-boxed-chocolate-340g-3571992
regex = r'https://www.coles.com.au/product/([^/]+)/?.*' regex = r"https://www.coles.com.au/product/([^/]+)/?.*"
match = re.match(regex, url) match = re.match(regex, url)
if match: if match:
return match.group(1) return match.group(1)
return None return None
async def scrape(product_id: str) -> Union[dict, dict]:
async def scrape(product_id: str) -> Tuple[dict, dict]:
raw_data = await _request_details(product_id) raw_data = await _request_details(product_id)
if raw_data is None:
raw_data = {"pageProps": {"product": {"size": "", "images": []}}}
product = raw_data['pageProps']['product'] product = raw_data["pageProps"]["product"]
quantity, unit = _get_package_size(product['size']) quantity, unit = _get_package_size(product["size"])
img_prefix = 'https://shop.coles.com.au' img_prefix = "https://shop.coles.com.au"
images = product['images'][0] images = product["images"][0]
product_data = { product_data = {
'name': product['name'], "name": product["name"],
'quantity': quantity, "quantity": quantity,
'unit': unit, "unit": unit,
'img_small': (img_prefix + images['thumb']['path']) if images else None, "img_small": (img_prefix + images["thumb"]["path"]) if images else None,
'img_large': (img_prefix + images['full']['path']) if images else None, "img_large": (img_prefix + images["full"]["path"]) if images else None,
} }
return product_data, raw_data return product_data, raw_data

42
products/models.py Normal file
View file

@ -0,0 +1,42 @@
from __future__ import annotations
from typing import ClassVar, List, Optional
from pydantic import PrivateAttr
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 (not part of public schema)
_raw_data: Optional[dict] = PrivateAttr(default=None)
@property
def raw_data(self) -> Optional[dict]:
return self._raw_data
@raw_data.setter
def raw_data(self, value: Optional[dict]) -> None:
self._raw_data = value

View file

@ -1,24 +1,12 @@
from typing import AsyncIterator, List, ClassVar
from pydantic import BaseModel
import json import json
from typing import AsyncIterator, Optional
class Product(BaseModel): from products.models import Product
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
async def create(conn): async def create(conn):
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS Product ( CREATE TABLE IF NOT EXISTS Product (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
product_id TEXT UNIQUE NOT NULL, product_id TEXT UNIQUE NOT NULL,
@ -30,70 +18,101 @@ async def create(conn):
img_small TEXT, img_small TEXT,
img_large TEXT, img_large TEXT,
raw_data TEXT raw_data TEXT
);''') );"""
)
await conn.execute(''' await conn.execute(
"""
CREATE TABLE IF NOT EXISTS ProductTag ( CREATE TABLE IF NOT EXISTS ProductTag (
food_item_id INTEGER, food_item_id INTEGER,
tag TEXT COLLATE NOCASE, tag TEXT COLLATE NOCASE,
PRIMARY KEY (food_item_id, tag), PRIMARY KEY (food_item_id, tag),
FOREIGN KEY (food_item_id) REFERENCES Product(id) FOREIGN KEY (food_item_id) REFERENCES Product(id)
);''') );"""
)
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]: async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
async with conn.execute(f''' async with conn.execute(
SELECT {','.join(Product.KEYS)} FROM Product f"""
SELECT {",".join(Product.KEYS)} FROM Product
WHERE id IN ( WHERE id IN (
SELECT food_item_id FROM ProductTag SELECT food_item_id FROM ProductTag
WHERE tag = ? WHERE tag = ?
) )
''', (tag,)) as cursor: """,
(tag,),
) as cursor:
async for row in cursor: async for row in cursor:
yield Product(**{k:v for k,v in zip(Product.KEYS, row)}) yield Product(**{k: v for k, v in zip(Product.KEYS, row)})
async def find_product_by_id(conn, product_id: str) -> Product:
async with conn.execute(f''' async def find_product_by_id(conn, product_id: int) -> Optional[Product]:
SELECT {','.join(Product.KEYS)} FROM Product async with conn.execute(
f"""
SELECT {",".join(Product.KEYS)} FROM Product
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
''', (product_id,)) as cursor: """,
(product_id,),
) as cursor:
async for row in cursor: async for row in cursor:
return Product(**{k:v for k,v in zip(Product.KEYS, row)}) return Product(**{k: v for k, v in zip(Product.KEYS, row)})
return None
async def find_product_by_key(conn, shop_code: str, product_id: str) -> Product:
async with conn.execute(f''' async def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]:
SELECT {','.join(Product.KEYS)} FROM Product async with conn.execute(
f"""
SELECT {",".join(Product.KEYS)} FROM Product
WHERE shop_code = ? AND product_id = ? WHERE shop_code = ? AND product_id = ?
LIMIT 1 LIMIT 1
''', (shop_code, product_id,)) as cursor: """,
(
shop_code,
product_id,
),
) as cursor:
async for row in cursor: async for row in cursor:
return Product(**{k:v for k,v in zip(Product.KEYS, row)}) return Product(**{k: v for k, v in zip(Product.KEYS, row)})
return None
async def insert_product(conn, product: Product, data: dict): async def insert_product(conn, product: Product, data: dict):
insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS] insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS]
insert_values = [getattr(product, k) for k in insert_keys] insert_values = [getattr(product, k) for k in insert_keys]
async with conn.execute(f''' async with conn.execute(
INSERT INTO Product ({','.join(insert_keys)}, raw_data) f"""
VALUES ({','.join(['?'] * len(insert_keys))}, ?) INSERT INTO Product ({",".join(insert_keys)}, raw_data)
''', (*insert_values, json.dumps(data))) as cursor: VALUES ({",".join(["?"] * len(insert_keys))}, ?)
""",
(*insert_values, json.dumps(data)),
) as cursor:
product.id = cursor.lastrowid product.id = cursor.lastrowid
await conn.commit() # Commit handled by outer transaction
async def add_tag(conn, product: Product, tag: str): async def add_tag(conn, product: Product, tag: str):
await conn.execute(''' await conn.execute(
"""
INSERT INTO ProductTag (food_item_id, tag) INSERT INTO ProductTag (food_item_id, tag)
VALUES (?, ?) VALUES (?, ?)
''', (product.id, tag)) """,
(product.id, tag),
)
# Commit handled by outer transaction
await conn.commit()
async def get_tags(conn, product: Product) -> AsyncIterator[str]: async def get_tags(conn, product: Product) -> AsyncIterator[str]:
async with conn.execute(''' async with conn.execute(
"""
SELECT tag FROM ProductTag SELECT tag FROM ProductTag
WHERE food_item_id = ? WHERE food_item_id = ?
''', (product.id,)) as cursor: """,
(product.id,),
) as cursor:
async for row in cursor: async for row in cursor:
yield row[0] yield row[0]

View file

@ -1,43 +1,52 @@
import re, httpx import re
from typing import Optional, Tuple
from typing import Union import httpx
HEADERS = { HEADERS = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0', "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
'Accept-Language': 'en-US,en;q=0.5', "Accept-Language": "en-US,en;q=0.5",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Encoding": "gzip, deflate, br",
'DNT': '1', "DNT": "1",
'Sec-GPC': '1', "Sec-GPC": "1",
'Connection': 'keep-alive', "Connection": "keep-alive",
'Upgrade-Insecure-Requests': '1', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Dest': 'document', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-Site': 'none', "Sec-Fetch-Site": "none",
'Sec-Fetch-User': '?1', "Sec-Fetch-User": "?1",
'Pragma': 'no-cache', "Pragma": "no-cache",
'Cache-Control': 'no-cache', "Cache-Control": "no-cache",
} }
async def _get_cookies(client): async def _get_cookies(client):
# Make a request to https://www.woolworths.com.au/ as if we were a normal browser, then return the cookies # Make a request to https://www.woolworths.com.au/ as if we were a normal browser, then return the cookies
response = await client.get('https://www.woolworths.com.au/', headers=HEADERS, follow_redirects=True) response = await client.get(
"https://www.woolworths.com.au/", headers=HEADERS, follow_redirects=True
)
return dict(response.cookies) return dict(response.cookies)
def _get_package_size(data: dict) -> str:
size = data['Product']['PackageSize'] def _get_package_size(data: dict) -> Tuple[int, str]:
size = data["Product"]["PackageSize"]
if size: if size:
match = re.match(r'(\d+)(.*)', size) match = re.match(r"(\d+)(.*)", size)
if match: if match:
return int(match.group(1)), match.group(2) return int(match.group(1)), match.group(2)
return 1, 'items' return 1, "items"
def _get_client() -> httpx.AsyncClient: def _get_client() -> httpx.AsyncClient:
return httpx.AsyncClient() return httpx.AsyncClient()
cached_cookies = None cached_cookies = None
async def _request_url(url: str) -> dict:
async def _request_url(url: str) -> Optional[dict]:
global cached_cookies global cached_cookies
async with _get_client() as client: async with _get_client() as client:
@ -46,7 +55,9 @@ async def _request_url(url: str) -> dict:
cookies = cached_cookies cookies = cached_cookies
try: try:
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies) response = await client.get(
url, headers=HEADERS, follow_redirects=True, cookies=cookies
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except httpx.HTTPError as ne: except httpx.HTTPError as ne:
@ -54,27 +65,33 @@ async def _request_url(url: str) -> dict:
cached_cookies = None cached_cookies = None
return None return None
def _get_product_details_url(product_id) -> str:
return f'https://www.woolworths.com.au/apis/ui/product/detail/{product_id}'
def get_product_id(url: str) -> str: def _get_product_details_url(product_id: str) -> str:
woolies_regex = r'https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*' return f"https://www.woolworths.com.au/apis/ui/product/detail/{product_id}"
def get_product_id(url: str) -> Optional[str]:
woolies_regex = r"https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*"
match = re.match(woolies_regex, url) match = re.match(woolies_regex, url)
if match: if match:
return match.group(1) return match.group(1)
return None return None
async def scrape(product_id: str) -> Union[dict, dict]:
async def scrape(product_id: str) -> Tuple[dict, dict]:
details_url = _get_product_details_url(product_id) details_url = _get_product_details_url(product_id)
raw_data = await _request_url(details_url) raw_data = await _request_url(details_url)
if raw_data is None:
# Return a minimal structure; callers treat this as raw payload for logging
raw_data = {}
quantity, unit = _get_package_size(raw_data) quantity, unit = _get_package_size(raw_data)
product_data = { product_data = {
'name': raw_data['Product']['Name'], "name": raw_data["Product"]["Name"],
'quantity': quantity, "quantity": quantity,
'unit': unit, "unit": unit,
'img_small': raw_data['Product']['SmallImageFile'], "img_small": raw_data["Product"]["SmallImageFile"],
'img_large': raw_data['Product']['LargeImageFile'], "img_large": raw_data["Product"]["LargeImageFile"],
} }
return product_data, raw_data return product_data, raw_data

40
pyproject.toml Normal file
View file

@ -0,0 +1,40 @@
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E203", "E501", "I001"]
[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 = ["."]

View file

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

View file

@ -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
View 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: int
created_by: Optional[Person] = None
date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None
hidden_by: Optional[Person] = None

222
recipes/repository.py Normal file
View file

@ -0,0 +1,222 @@
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

View file

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

15
scripts/export_openapi.py Normal file
View 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")

27
settings.py Normal file
View file

@ -0,0 +1,27 @@
"""
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()

View file

@ -1,2 +1,140 @@
from shopping.db import ShoppingList, ShoppingListRequest, ShoppingListResult, sync_persons_requested_ingredients, load_shopping_list, get_current_requests, request_meal, unrequest_meal, insert_shopping_list, get_shopping_list_with_meal, remove_request from typing import Any, Dict, Iterable, Iterator, List, Tuple
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,
)
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 {}
await _ensure_lookups_populated(conn, items, meals_lookup, recipes_lookup, ingredients_lookup)
return meals_lookup, recipes_lookup, ingredients_lookup
async def _ensure_lookups_populated(
conn, items: List[ShoppingListItem], meals_lookup, recipes_lookup, ingredients_lookup
):
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:
meals_lookup[item.meal_id] = await meals.find_meal_by_id(conn, item.meal_id)
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
)
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 [
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: Iterable[ShoppingListItem], meals_lookup: Dict[int, Any]
) -> Iterator[ShoppingListItem]:
for item in items:
if item.meal_id and item.meal_id in meals_lookup:
meal = meals_lookup[item.meal_id]
for mealRecipe in meal.recipes:
for ingredient in mealRecipe.recipe.ingredients:
yield ShoppingListItem(
ingredient_id=ingredient.id,
meal_id=item.meal_id,
recipe_id=mealRecipe.recipe.id,
person_id=item.person_id,
created_date=item.created_date,
)
for ingredient in meal.extra_ingredients:
yield ShoppingListItem(
ingredient_id=ingredient.id,
meal_id=item.meal_id,
person_id=item.person_id,
created_date=item.created_date,
)
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],
]:
current_requests = [r async for r in _find_items_by_list_id(conn, None)]
meal_requests = [r for r in current_requests if r.meal_id is not None and r.meal_id > 0]
# Get lookups for meals to enable flattening
meals_lookup, recipes_lookup, ingredients_lookup = await to_lookups(conn, current_requests)
meal_ids = [r.meal_id for r in meal_requests if r.meal_id]
purchased_ingredients = {
(r.ingredient_id, r.meal_id, r.recipe_id): r
async for r in _get_purchased_ingredients(conn, meal_ids)
}
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
)
for r in flattened:
# Meal ingredients may have already been purchased
if r.meal_id is not None and r.meal_id > 0:
purchased_item = purchased_ingredients.get((r.ingredient_id, r.meal_id, r.recipe_id))
if purchased_item:
purchased_items.append(purchased_item)
continue
outstanding_items.append(r)
return (
outstanding_items,
purchased_items,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
)

View file

@ -1,296 +0,0 @@
from meals import Meal, find_meal_by_id
from ingredients import Ingredient, insert_ingredient
from persons import Person
from products import Product
from pydantic import BaseModel
from typing import AsyncIterator, List, ClassVar, Optional
from datetime import datetime
class ShoppingListRequest(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'ingredient_id', 'list_id', 'person_id', 'meal_id', 'created_date']
id: int = -1
list_id: Optional[int] = None
ingredient_id: Optional[int] = None
ingredient: Optional[Ingredient] = None
person_id: Optional[int] = None
person: Optional[Person] = None
meal_id: Optional[int] = None
meal: Optional[Meal] = None
created_date: datetime = datetime.now().astimezone()
class ShoppingListResult(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'list_id', 'quantity', 'unit' ]
id: int = -1
list_id: int
product_id: int
product: Optional[Product] = None
quantity: float
unit: str
from enum import Enum
class StoreEnum(str, Enum):
woolworths = 'woolworths'
coles = 'coles'
home = ''
class ShoppingList(BaseModel):
KEYS: ClassVar[List[str]] = ['id', 'created_date', 'store_name']
id: int = -1
created_date: datetime = datetime.now().astimezone()
store_name: StoreEnum = ''
requests: List[ShoppingListRequest] = []
results: List[ShoppingListResult] = []
async def create(conn):
await conn.execute('''
CREATE TABLE IF NOT EXISTS ShoppingList (
id INTEGER PRIMARY KEY,
created_date DATETIME NOT NULL,
store_name TEXT NOT NULL,
);''')
await conn.execute('''
CREATE TABLE IF NOT EXISTS ShoppingListRequest (
id INTEGER PRIMARY KEY,
ingredient_id INTEGER,
list_id INTEGER,
person_id INTEGER,
meal_id INTEGER,
created_date DATETIME NOT NULL,
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id),
FOREIGN KEY(person_id) REFERENCES Person(id),
FOREIGN KEY(meal_id) REFERENCES Meal(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id)
);''')
await conn.execute('''
CREATE TABLE IF NOT EXISTS ShoppingListResult (
id INTEGER PRIMARY KEY,
product_id INTEGER,
list_id INTEGER,
quantity REAL,
unit TEXT,
FOREIGN KEY(product_id) REFERENCES Product(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id)
);''')
def validate_request(request: ShoppingListRequest) -> None:
# A request must always have a list id
if request.list_id < 0:
raise ValueError('Request must have a list id')
# A request must have either an ingredient or a meal, but not both
if not request.ingredient and not request.meal:
raise ValueError('Request must have either an ingredient or a meal')
if request.ingredient and request.meal:
raise ValueError('Request cannot have both an ingredient and a meal')
# If an ingredient is provided, it must have a person
if request.ingredient and not request.person:
raise ValueError('Ingredient requests must have a person')
async def insert_shopping_list(conn, shopping_list: ShoppingList):
shopping_list.created_date = datetime.now().astimezone()
async with conn.execute('''
INSERT INTO ShoppingList (created_date, store_name)
VALUES (?, ?)
''', (shopping_list.created_date.isoformat(), shopping_list.store_name,)) as cursor:
shopping_list.id = cursor.lastrowid
for request in shopping_list.requests:
request.list_id = shopping_list.id
validate_request(request)
if request.ingredient and request.ingredient.id < 0:
await insert_ingredient(conn, request.ingredient)
if request.ingredient:
request.ingredient_id = request.ingredient.id
if request.meal:
request.meal_id = request.meal.id
async with conn.execute('''
INSERT INTO ShoppingListRequest (ingredient_id, list_id, person_id, meal_id, created_date)
VALUES (?, ?, ?, ?, ?)
''', (request.ingredient_id, shopping_list.id, request.person_id, request.meal_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
for item in shopping_list.results:
item.product_id = item.product.id
item.list_id = shopping_list.id
async with conn.execute('''
INSERT INTO ShoppingListResult (product_id, list_id, quantity, unit)
VALUES (?, ?, ?, ?)
''', (item.product_id, item.list_id, item.quantity, item.unit)) as cursor:
item.id = cursor.lastrowid
async def remove_request(conn, request: ShoppingListRequest) -> None:
if request.list_id != None:
raise ValueError('Request is already completed')
if request.meal and not request.meal_id:
raise ValueError('Meal request must have a meal id')
if request.meal_id != None:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE meal_id = ? AND list_id IS NULL
''', (request.meal_id,))
elif request.person_id != None and request.ingredient_id != None:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE person_id = ? AND ingredient_id = ? AND list_id IS NULL
''', (request.person_id, request.ingredient_id))
else:
raise ValueError('Request is invalid')
async def find_requests_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListRequest]:
# Join Ingredient and Product to also load ingredient and product
ingredient_keys = [f'ingredient.{key}' for key in Ingredient.KEYS]
product_keys = [f'product.{key}' for key in Product.KEYS]
request_keys = [f'shoppinglistrequest.{key}' for key in ShoppingListRequest.KEYS]
person_keys = [f'person.{key}' for key in Person.KEYS]
select = f'''
SELECT {','.join(ingredient_keys + product_keys + request_keys + person_keys)}
FROM ShoppingListRequest
LEFT JOIN Ingredient ON ShoppingListRequest.ingredient_id = Ingredient.id
LEFT JOIN Product ON Ingredient.product_id = Product.id
LEFT JOIN Person ON ShoppingListRequest.person_id = Person.id
'''
where, params = ' WHERE list_id IS NULL', ()
if list_id is not None:
where, params = ' WHERE list_id = ?', (list_id,)
cursor = await conn.execute(select + where, params)
async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[len(Ingredient.KEYS):len(Ingredient.KEYS) + len(Product.KEYS)])}
product = Product(**product_keys) if product_keys['id'] else None
ingredient_keys = {k:v for k,v in zip(Ingredient.KEYS, row[:len(Ingredient.KEYS)])}
ingredient = Ingredient(**ingredient_keys, product=product) if ingredient_keys['id'] else None
person_keys = {k:v for k,v in zip(Person.KEYS, row[-len(Person.KEYS):])}
person = Person(**person_keys) if person_keys['id'] else None
request_keys = {k:v for k,v in zip(ShoppingListRequest.KEYS, row[len(Ingredient.KEYS) + len(Product.KEYS):-len(Person.KEYS)])}
request = ShoppingListRequest(**request_keys, ingredient=ingredient, person=person)
if request.meal_id is not None:
request.meal = await find_meal_by_id(conn, request.meal_id)
yield request
async def find_results_by_list_id(conn, list_id: int) -> AsyncIterator[ShoppingListResult]:
product_keys = [f'product.{key}' for key in Product.KEYS]
result_keys = [f'shoppinglistresult.{key}' for key in ShoppingListResult.KEYS]
async with conn.execute(f'''
SELECT {','.join(product_keys + result_keys)}
FROM ShoppingListResult
LEFT JOIN Product ON ShoppingListResult.product_id = Product.id
WHERE list_id = ?
''', (list_id,)) as cursor:
async for row in cursor:
product_keys = {k:v for k,v in zip(Product.KEYS, row[:len(Product.KEYS)])}
product = Product(**product_keys) if product_keys['id'] else None
result_keys = {k:v for k,v in zip(ShoppingListResult.KEYS, row[len(Product.KEYS):])}
result = ShoppingListResult(**result_keys, product=product)
yield result
async def fill_related(conn, shopping_list: ShoppingList) -> ShoppingList:
async for request in find_requests_by_list_id(conn, shopping_list.id):
shopping_list.requests.append(request)
async for item in find_results_by_list_id(conn, shopping_list.id):
shopping_list.results.append(item)
async def load_shopping_list(conn, id: int) -> ShoppingList:
shopping_list = None
async with conn.execute(f'''
SELECT {','.join(ShoppingList.KEYS)} FROM ShoppingList
WHERE id = ?
LIMIT 1
''', (id,)) as cursor:
async for row in cursor:
shopping_list = ShoppingList(**{k:v for k,v in zip(ShoppingList.KEYS, row)})
break
if shopping_list:
await fill_related(conn, shopping_list)
return shopping_list
async def request_ingredient(conn, person: Person, ingredient: Ingredient) -> ShoppingListRequest:
if ingredient.id >= 0:
raise ValueError('How did you get an existing ingredient?')
await insert_ingredient(conn, ingredient)
request = ShoppingListRequest(ingredient_id=ingredient.id, ingredient=ingredient, person_id=person.id, created_date=datetime.now().astimezone())
async with conn.execute('''
INSERT INTO ShoppingListRequest (ingredient_id, person_id, created_date)
VALUES (?, ?, ?)
''', (request.ingredient_id, request.person_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
return request
async def request_meal(conn, person: Person, meal: Meal) -> ShoppingListRequest:
request = ShoppingListRequest(meal_id=meal.id, meal=meal, person_id=person.id, created_date=datetime.now().astimezone())
async with conn.execute('''
INSERT INTO ShoppingListRequest (meal_id, person_id, created_date)
VALUES (?, ?, ?)
''', (request.meal_id, request.person_id, request.created_date.isoformat())) as cursor:
request.id = cursor.lastrowid
return request
async def unrequest_meal(conn, meal: Meal) -> None:
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE list_id IS NULL AND meal_id = ?
''', (meal.id,))
def get_current_requests(conn) -> AsyncIterator[ShoppingListRequest]:
return find_requests_by_list_id(conn, None)
async def sync_persons_requested_ingredients(conn, person: Person, requests: List[Ingredient]) -> AsyncIterator[ShoppingListRequest]:
# Delete existing and insert all as new
await conn.execute('''
DELETE FROM ShoppingListRequest
WHERE list_id IS NULL AND person_id = ? AND ingredient_id IS NOT NULL
''', (person.id,))
for ingredient in requests:
ingredient.id = -1
yield await request_ingredient(conn, person, ingredient)
async def get_shopping_list_with_meal(conn, meal_id: int) -> AsyncIterator[ShoppingList]:
async with conn.execute('''
SELECT list_id FROM ShoppingListRequest
WHERE meal_id = ? AND list_id IS NOT NULL
''', (meal_id,)) as cursor:
async for row in cursor:
list_id = row[0]
yield await load_shopping_list(conn, list_id)

48
shopping/models.py Normal file
View 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)

307
shopping/repository.py Normal file
View file

@ -0,0 +1,307 @@
from typing import Any, AsyncIterator, List, Optional
from shopping.models import ShoppingList, ShoppingListItem
async def create(conn):
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(
"""
CREATE TABLE IF NOT EXISTS ShoppingListItem (
id INTEGER PRIMARY KEY,
ingredient_id INTEGER,
list_id INTEGER,
person_id INTEGER,
meal_id INTEGER,
recipe_id INTEGER,
created_date DATETIME NOT NULL,
FOREIGN KEY(ingredient_id) REFERENCES Ingredient(id),
FOREIGN KEY(list_id) REFERENCES ShoppingList(id),
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")
# 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")
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")
if shopping_list.items is None or len(shopping_list.items) == 0:
raise ValueError("Shopping list must have items")
from datetime import datetime
shopping_list.created_date = datetime.now().astimezone()
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.id = cursor.lastrowid
for item in shopping_list.items:
item.list_id = shopping_list.id
validate_request(item)
if item.ingredient_id is None or item.ingredient_id < 0:
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")
if isPersonRequest:
# Update existing request from its null id, or throw
async with conn.execute(
"""
UPDATE ShoppingListItem
SET list_id = ?
WHERE ingredient_id = ?
AND list_id IS NULL
AND person_id = ?
AND meal_id IS NULL
AND recipe_id IS NULL
""",
(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"
)
elif isMeal:
# Insert new request for meal
if item.meal_id is None or item.meal_id < 0:
raise ValueError("Meal request must have a valid meal id")
async with conn.execute(
"""
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.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
}
)
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)
}
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)
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, person=None, meal=meal)
async def is_requested(conn, meal) -> bool:
if meal.id < 0:
return False
async with conn.execute(
"""
SELECT COUNT(*) FROM ShoppingListItem
WHERE meal_id = ? AND list_id IS NULL
""",
(meal.id,),
) as cursor:
row = await cursor.fetchone()
return row[0] > 0
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")
if ingredient is None and meal is None:
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")
if ingredient is not None and ingredient.id < 0:
await insert_ingredient(conn, ingredient)
ingredient_id = ingredient.id if ingredient else None
meal_id = meal.id if meal else None
item = ShoppingListItem(ingredient_id=ingredient_id, person_id=person.id, meal_id=meal_id)
validate_request(item)
if meal is not None and await is_requested(conn, meal):
raise ValueError("Meal is already requested")
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.id = cursor.lastrowid
return item
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(
"""
DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND meal_id = ?
""",
(meal.id,),
) as cursor:
return cursor.rowcount > 0
elif ingredient is not None:
async with conn.execute(
"""
DELETE FROM ShoppingListItem
WHERE list_id IS NULL AND ingredient_id = ? AND person_id = ?
""",
(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")
async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[ShoppingListItem]:
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
select = f"""
SELECT {",".join(request_cols)}
FROM ShoppingListItem
"""
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,)
cursor = await conn.execute(select + where, params)
async for row in cursor:
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) -> 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:
async for row in cursor:
shopping_list = ShoppingList(**{k: v for k, v in zip(ShoppingList.KEYS, row)})
break
if shopping_list:
async for item in find_items_by_list_id(conn, shopping_list.id):
shopping_list.items.append(item)
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"""
SELECT {",".join(ShoppingListItem.KEYS)}
FROM ShoppingListItem
WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL
""",
meal_ids,
) as cursor:
async for row in cursor:
yield ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})

View file

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

View file

@ -0,0 +1,41 @@
{
"request": {
"method": "GET",
"url": "https://www.coles.com.au/",
"headers": {
"host": "www.coles.com.au",
"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"
},
"content": ""
},
"response": {
"status_code": 200,
"headers": {
"content-type": "text/html",
"cache-control": "no-cache, no-store",
"connection": "close",
"content-length": "3345",
"x-iinfo": "7-26769261-0 0CNN RT(1727584618151 22) q(0 -1 -1 1) r(0 -1) B10(14,0,0)",
"strict-transport-security": "max-age=31536000; includeSubDomains",
"set-cookie": "visid_incap_2800108=vNYK15gzRoOHbzyO31mNh2rZ+GYAAAAAQUIPAAAAAABwIQoipD3Nw8q38f6JHIb5; expires=Sun, 28 Sep 2025 12:16:28 GMT; HttpOnly; path=/; Domain=.coles.com.au; Secure; SameSite=None, incap_ses_808_2800108=AWejGplhXmNxgGAG25c2C2rZ+GYAAAAAr91ZD4bl3op+nNtxHCpveg==; path=/; Domain=.coles.com.au; Secure; SameSite=None"
},
"content": "<!DOCTYPE html><html><head><title>Coles Product Page</title></head><body><div>Mock Coles product page with version 20240926.02_v4.18.0 for testing</div></body></html>",
"cookies": {
"visid_incap_2800108": "vNYK15gzRoOHbzyO31mNh2rZ+GYAAAAAQUIPAAAAAABwIQoipD3Nw8q38f6JHIb5",
"incap_ses_808_2800108": "AWejGplhXmNxgGAG25c2C2rZ+GYAAAAAr91ZD4bl3op+nNtxHCpveg=="
}
}
}

View file

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

View file

@ -0,0 +1,76 @@
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

593
tests/test_ingredients.py Normal file
View file

@ -0,0 +1,593 @@
import unittest
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.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:")
await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_ingredient_creation(self):
"""Test basic ingredient creation"""
ingredient = ingredients_db.Ingredient(
name="Broccoli",
line="500g fresh broccoli",
unit="g",
quantity=500.0,
preparation="chopped",
)
self.assertEqual(ingredient.name, "Broccoli")
self.assertEqual(ingredient.line, "500g fresh broccoli")
self.assertEqual(ingredient.unit, "g")
self.assertEqual(ingredient.quantity, 500.0)
self.assertEqual(ingredient.preparation, "chopped")
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"
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
self.assertGreater(ingredient.id, 0)
# Verify it was inserted correctly
found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id)
self.assertIsNotNone(found_ingredient)
self.assertEqual(found_ingredient.name, "Garlic")
self.assertEqual(found_ingredient.quantity, 2.0)
async def test_insert_ingredient_with_product(self):
"""Test inserting an ingredient with an associated product"""
# First create and insert a product
product = test_data.Products.broccoli
await products_db.insert_product(self.conn, product, {})
ingredient = ingredients_db.Ingredient(
name="Fresh Broccoli",
line="1 piece fresh broccoli",
unit="Items",
quantity=1.0,
preparation="",
product_id=product.id,
product=product,
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
# Verify the ingredient was inserted with the product reference
found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id)
self.assertIsNotNone(found_ingredient)
self.assertEqual(found_ingredient.product_id, product.id)
self.assertIsNotNone(found_ingredient.product)
self.assertEqual(found_ingredient.product.name, product.name)
async def test_find_ingredient_by_id_not_found(self):
"""Test finding a non-existent ingredient returns None"""
result = await ingredients_db.find_ingredient_by_id(self.conn, 999)
self.assertIsNone(result)
async def test_find_ingredients_by_recipe_id(self):
"""Test finding ingredients by recipe ID"""
# Create ingredients with the same recipe_id
recipe_id = 1
ingredient1 = ingredients_db.Ingredient(
name="Flour",
line="2 cups flour",
unit="cups",
quantity=2.0,
preparation="",
recipe_id=recipe_id,
)
ingredient2 = ingredients_db.Ingredient(
name="Sugar",
line="1 cup sugar",
unit="cups",
quantity=1.0,
preparation="",
recipe_id=recipe_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient1)
await ingredients_db.insert_ingredient(self.conn, ingredient2)
# Find ingredients by recipe ID
ingredients_list = []
async for ingredient in ingredients_db.find_ingredients_by_recipe_id(self.conn, recipe_id):
ingredients_list.append(ingredient)
self.assertEqual(len(ingredients_list), 2)
names = [ing.name for ing in ingredients_list]
self.assertIn("Flour", names)
self.assertIn("Sugar", names)
async def test_find_ingredients_by_meal_id(self):
"""Test finding ingredients by meal ID"""
# Create ingredients with the same meal_id
meal_id = 1
ingredient1 = ingredients_db.Ingredient(
name="Chicken",
line="1 lb chicken breast",
unit="lb",
quantity=1.0,
preparation="diced",
meal_id=meal_id,
)
ingredient2 = ingredients_db.Ingredient(
name="Rice",
line="2 cups rice",
unit="cups",
quantity=2.0,
preparation="",
meal_id=meal_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient1)
await ingredients_db.insert_ingredient(self.conn, ingredient2)
# Find ingredients by meal ID
ingredients_list = []
async for ingredient in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ingredient)
self.assertEqual(len(ingredients_list), 2)
names = [ing.name for ing in ingredients_list]
self.assertIn("Chicken", names)
self.assertIn("Rice", names)
async def test_delete_ingredients_by_meal_id(self):
"""Test deleting ingredients by meal ID"""
meal_id = 1
ingredient = ingredients_db.Ingredient(
name="Tomato",
line="2 tomatoes",
unit="Items",
quantity=2.0,
preparation="sliced",
meal_id=meal_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
# Verify ingredient exists
ingredients_list = []
async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ing)
self.assertEqual(len(ingredients_list), 1)
# Delete ingredients by meal ID
await ingredients_db.delete_ingredients_by_meal_id(self.conn, meal_id)
# Verify ingredients are deleted
ingredients_list = []
async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ing)
self.assertEqual(len(ingredients_list), 0)
import unittest
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.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:")
await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_ingredient_creation(self):
"""Test basic ingredient creation"""
ingredient = ingredients_db.Ingredient(
name="Broccoli",
line="500g fresh broccoli",
unit="g",
quantity=500.0,
preparation="chopped",
)
self.assertEqual(ingredient.name, "Broccoli")
self.assertEqual(ingredient.line, "500g fresh broccoli")
self.assertEqual(ingredient.unit, "g")
self.assertEqual(ingredient.quantity, 500.0)
self.assertEqual(ingredient.preparation, "chopped")
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"
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
self.assertGreater(ingredient.id, 0)
# Verify it was inserted correctly
found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id)
self.assertIsNotNone(found_ingredient)
self.assertEqual(found_ingredient.name, "Garlic")
self.assertEqual(found_ingredient.quantity, 2.0)
async def test_insert_ingredient_with_product(self):
"""Test inserting an ingredient with an associated product"""
# First create and insert a product
product = test_data.Products.broccoli
await products_db.insert_product(self.conn, product, {})
ingredient = ingredients_db.Ingredient(
name="Fresh Broccoli",
line="1 piece fresh broccoli",
unit="Items",
quantity=1.0,
preparation="",
product_id=product.id,
product=product,
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
# Verify the ingredient was inserted with the product reference
found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id)
self.assertIsNotNone(found_ingredient)
self.assertEqual(found_ingredient.product_id, product.id)
self.assertIsNotNone(found_ingredient.product)
self.assertEqual(found_ingredient.product.name, product.name)
async def test_find_ingredient_by_id_not_found(self):
"""Test finding a non-existent ingredient returns None"""
result = await ingredients_db.find_ingredient_by_id(self.conn, 999)
self.assertIsNone(result)
async def test_find_ingredients_by_recipe_id(self):
"""Test finding ingredients by recipe ID"""
# Create ingredients with the same recipe_id
recipe_id = 1
ingredient1 = ingredients_db.Ingredient(
name="Flour",
line="2 cups flour",
unit="cups",
quantity=2.0,
preparation="",
recipe_id=recipe_id,
)
ingredient2 = ingredients_db.Ingredient(
name="Sugar",
line="1 cup sugar",
unit="cups",
quantity=1.0,
preparation="",
recipe_id=recipe_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient1)
await ingredients_db.insert_ingredient(self.conn, ingredient2)
# Find ingredients by recipe ID
ingredients_list = []
async for ingredient in ingredients_db.find_ingredients_by_recipe_id(self.conn, recipe_id):
ingredients_list.append(ingredient)
self.assertEqual(len(ingredients_list), 2)
names = [ing.name for ing in ingredients_list]
self.assertIn("Flour", names)
self.assertIn("Sugar", names)
async def test_find_ingredients_by_meal_id(self):
"""Test finding ingredients by meal ID"""
# Create ingredients with the same meal_id
meal_id = 1
ingredient1 = ingredients_db.Ingredient(
name="Chicken",
line="1 lb chicken breast",
unit="lb",
quantity=1.0,
preparation="diced",
meal_id=meal_id,
)
ingredient2 = ingredients_db.Ingredient(
name="Rice",
line="2 cups rice",
unit="cups",
quantity=2.0,
preparation="",
meal_id=meal_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient1)
await ingredients_db.insert_ingredient(self.conn, ingredient2)
# Find ingredients by meal ID
ingredients_list = []
async for ingredient in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ingredient)
self.assertEqual(len(ingredients_list), 2)
names = [ing.name for ing in ingredients_list]
self.assertIn("Chicken", names)
self.assertIn("Rice", names)
async def test_delete_ingredients_by_meal_id(self):
"""Test deleting ingredients by meal ID"""
meal_id = 1
ingredient = ingredients_db.Ingredient(
name="Tomato",
line="2 tomatoes",
unit="Items",
quantity=2.0,
preparation="sliced",
meal_id=meal_id,
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
# Verify ingredient exists
ingredients_list = []
async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ing)
self.assertEqual(len(ingredients_list), 1)
# Delete ingredients by meal ID
await ingredients_db.delete_ingredients_by_meal_id(self.conn, meal_id)
# Verify ingredients are deleted
ingredients_list = []
async for ing in ingredients_db.find_ingredients_by_meal_id(self.conn, meal_id):
ingredients_list.append(ing)
self.assertEqual(len(ingredients_list), 0)
async def test_ingredient_with_negative_product_id(self):
"""Test that negative product_id is converted to None during insertion"""
ingredient = ingredients_db.Ingredient(
name="Test Ingredient",
line="1 test ingredient",
unit="Items",
quantity=1.0,
preparation="",
product_id=-1,
)
await ingredients_db.insert_ingredient(self.conn, ingredient)
found_ingredient = await ingredients_db.find_ingredient_by_id(self.conn, ingredient.id)
self.assertIsNone(found_ingredient.product_id)
class TestIngredientParsing(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_parse_ingredient_from_link_invalid_format(self):
"""Test parsing ingredient from invalid link format returns None"""
invalid_links = [
"invalid link format",
"just a url https://example.com",
"no quantity https://example.com",
"",
"abc https://example.com",
]
for invalid_link in invalid_links:
result = await ingredients.parse_ingredient_from_link(self.conn, invalid_link)
self.assertIsNone(result, f"Should return None for: {invalid_link}")
async def test_parse_ingredient_from_link_valid_format_no_quantity(self):
"""Test parsing ingredient from valid link format without explicit quantity"""
link = "https://www.woolworths.com.au/shop/productdetails/134681/fresh-broccoli"
result = await ingredients.parse_ingredient_from_link(self.conn, link)
# The scraper actually works for this URL, so we should get a result
self.assertIsNotNone(result)
self.assertEqual(result.quantity, 1.0) # Default quantity when none specified
self.assertEqual(result.unit, units.ITEMS.name)
self.assertIsInstance(result, ingredients_db.Ingredient)
async def test_parse_ingredient_from_link_regex_parsing(self):
"""Test that the regex correctly parses quantity and URL from valid links"""
import re
# Test the regex pattern used in parse_ingredient_from_link
test_cases = [
("2 https://example.com", "2", "https://example.com"),
(
"10 https://www.woolworths.com.au/product",
"10",
"https://www.woolworths.com.au/product",
),
("https://example.com", None, "https://example.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)
if match:
quantity = int(match.group(1)) if match.group(1) else 1
url = match.group(2)
if expected_qty:
self.assertEqual(quantity, int(expected_qty))
else:
self.assertEqual(quantity, 1) # Default quantity
self.assertEqual(url, expected_url)
async def test_parse_ingredient_from_nlp_simple_cases(self):
"""Test parsing simple ingredient cases that don't require external dependencies"""
# Test the basic structure without relying on ingredient_parser
# Since ingredient_parser is an external dependency, we'll test what we can
# We can test that the function exists and handles basic error cases
try:
result = ingredients.parse_ingredient_from_nlp("2 cups flour")
# The function may fail due to missing ingredient_parser, but it should not crash
# If it works, result should be an Ingredient object
if result is not None:
self.assertIsInstance(result, ingredients_db.Ingredient)
except ImportError:
# If ingredient_parser is not available, that's expected
self.skipTest("ingredient_parser not available")
except Exception as e:
# Other exceptions should not occur in normal operation
self.fail(f"Unexpected exception: {e}")
class TestIngredientMatching(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_match_existing_products_with_real_data(self):
"""Test matching ingredients to existing products using real operations"""
# Setup: Create and insert a product with tags
product = test_data.Products.broccoli
await products_db.insert_product(self.conn, product, {})
await products_db.add_tag(self.conn, product, "broccoli")
# Create ingredients without products
ingredient1 = ingredients_db.Ingredient(
name="broccoli", line="1 piece broccoli", unit="Items", quantity=1.0, preparation=""
)
ingredient2 = ingredients_db.Ingredient(
name="unknown vegetable",
line="1 piece unknown vegetable",
unit="Items",
quantity=1.0,
preparation="",
)
ingredients_list = [ingredient1, ingredient2]
result = await ingredients.match_existing_products(self.conn, ingredients_list)
# Check that first ingredient got matched
self.assertEqual(result[0].product_id, product.id)
self.assertIsNotNone(result[0].product)
self.assertEqual(result[0].product.name, product.name)
# Check that second ingredient remained unmatched
self.assertIsNone(result[1].product)
async def test_match_existing_products_already_has_product(self):
"""Test that ingredients with existing products are not re-matched"""
product = test_data.Products.broccoli
ingredient = ingredients_db.Ingredient(
name="broccoli",
line="1 piece broccoli",
unit="Items",
quantity=1.0,
preparation="",
product=product,
product_id=product.id,
)
ingredients_list = [ingredient]
result = await ingredients.match_existing_products(self.conn, ingredients_list)
# Should remain unchanged
self.assertEqual(result[0].product_id, product.id)
self.assertEqual(result[0].product, product)
async def test_match_existing_products_empty_list(self):
"""Test matching empty ingredients list"""
result = await ingredients.match_existing_products(self.conn, [])
self.assertEqual(result, [])
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",
]
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=""
)
self.assertEqual(ingredient.id, -1)
self.assertIsNone(ingredient.product_id)
self.assertIsNone(ingredient.recipe_id)
self.assertIsNone(ingredient.meal_id)
self.assertIsNone(ingredient.product)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

505
tests/test_meals.py Normal file
View file

@ -0,0 +1,505 @@
import unittest
import asyncio
from datetime import datetime
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.repository as meals_db
from meals.models import Meal, MealRecipe
import persons
import recipes
import ingredients
import products
class TestMealsModels(unittest.IsolatedAsyncioTestCase):
"""Test the meals data models"""
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
def test_meal_creation(self):
"""Test basic Meal creation"""
meal = Meal(
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],
)
self.assertEqual(meal.id, -1) # Default ID
self.assertEqual(meal.suggested_date, datetime(2024, 1, 1, 18, 0))
self.assertIsNone(meal.consumed_date)
self.assertEqual(len(meal.chefs), 1)
self.assertEqual(len(meal.cleanup), 1)
self.assertEqual(len(meal.consumers), 2)
self.assertEqual(len(meal.recipes), 0)
self.assertEqual(len(meal.extra_ingredients), 0)
def test_meal_recipe_creation(self):
"""Test basic MealRecipe creation"""
meal_recipe = MealRecipe(meal_id=1, recipe_id=2, servings=4.0)
self.assertEqual(meal_recipe.meal_id, 1)
self.assertEqual(meal_recipe.recipe_id, 2)
self.assertEqual(meal_recipe.servings, 4.0)
self.assertIsNone(meal_recipe.recipe)
class TestMealsCRUD(unittest.IsolatedAsyncioTestCase):
"""Test meals CRUD operations"""
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_test_data(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_insert_meal_basic(self):
"""Test inserting a basic meal with participants"""
meal = Meal(
suggested_date=datetime(2024, 1, 15, 19, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
# Verify meal was inserted and got an ID
self.assertGreater(meal.id, 0)
# Verify we can find it by ID
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(found_meal.suggested_date, meal.suggested_date)
self.assertEqual(len(found_meal.chefs), 1)
self.assertEqual(found_meal.chefs[0].name, "Jacob")
self.assertEqual(len(found_meal.cleanup), 1)
self.assertEqual(found_meal.cleanup[0].name, "Ryan")
self.assertEqual(len(found_meal.consumers), 1)
self.assertEqual(found_meal.consumers[0].name, "Ellie")
async def test_insert_meal_with_recipes(self):
"""Test inserting a meal with recipes"""
# First create a recipe
recipe = test_data.Recipes.broccoli_soup
recipe.id = -1 # Reset ID
await recipes.insert_recipe(self.conn, recipe)
meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=3.0, recipe=recipe)
meal = 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],
)
await meals_db.insert_meal(self.conn, meal)
# Verify meal was inserted
self.assertGreater(meal.id, 0)
# Verify recipe was associated
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(len(found_meal.recipes), 1)
self.assertEqual(found_meal.recipes[0].recipe_id, recipe.id)
self.assertEqual(found_meal.recipes[0].servings, 3.0)
self.assertIsNotNone(found_meal.recipes[0].recipe)
self.assertEqual(found_meal.recipes[0].recipe.name, recipe.name)
async def test_insert_meal_with_extra_ingredients(self):
"""Test inserting a meal with extra ingredients"""
# Create a new product for testing
product = products.Product(
id=-1,
shop_code="woolworths",
name="Test Garlic Bread",
product_id="test_294517",
quantity=1,
unit="Loaf",
link="https://example.com/test-garlic-bread",
img_small="https://example.com/test-small.jpg",
img_large="https://example.com/test-large.jpg",
raw_data={},
)
await products.insert_product(self.conn, product, {})
# Create an ingredient
extra_ingredient = ingredients.Ingredient(
id=-1,
name="Test Garlic Bread",
line="1 loaf test garlic bread",
unit="loaf",
quantity=1.0,
preparation="",
product_id=product.id,
)
meal = Meal(
suggested_date=datetime(2024, 3, 1, 19, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
extra_ingredients=[extra_ingredient],
)
await meals_db.insert_meal(self.conn, meal)
# Verify meal was inserted
self.assertGreater(meal.id, 0)
# Verify extra ingredients were associated
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(len(found_meal.extra_ingredients), 1)
self.assertEqual(found_meal.extra_ingredients[0].name, "Test Garlic Bread")
async def test_find_meal_by_id_not_found(self):
"""Test finding a meal that doesn't exist"""
result = await meals_db.find_meal_by_id(self.conn, 999)
self.assertIsNone(result)
async def test_update_meal(self):
"""Test updating a meal"""
# Create and insert initial meal
meal = Meal(
suggested_date=datetime(2024, 4, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
original_id = meal.id
# Update the meal
meal.suggested_date = datetime(2024, 4, 2, 19, 0)
meal.chefs = [test_data.Persons.ryan] # Change chef
meal.cleanup = [test_data.Persons.ellie] # Change cleanup
meal.consumers = [test_data.Persons.jacob, test_data.Persons.chris] # Change consumers
await meals_db.update_meal(self.conn, meal)
# Verify updates
found_meal = await meals_db.find_meal_by_id(self.conn, original_id)
self.assertEqual(found_meal.suggested_date, datetime(2024, 4, 2, 19, 0))
self.assertEqual(len(found_meal.chefs), 1)
self.assertEqual(found_meal.chefs[0].name, "Ryan")
self.assertEqual(len(found_meal.cleanup), 1)
self.assertEqual(found_meal.cleanup[0].name, "Ellie")
self.assertEqual(len(found_meal.consumers), 2)
consumer_names = {p.name for p in found_meal.consumers}
self.assertIn("Jacob", consumer_names)
self.assertIn("Chris", consumer_names)
async def test_mark_consumed(self):
"""Test marking a meal as consumed"""
meal = Meal(
suggested_date=datetime(2024, 5, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
# Mark as consumed
consumed_date = datetime(2024, 5, 1, 19, 30)
await meals_db.mark_consumed(self.conn, meal, consumed_date)
# Verify consumed date was set
self.assertEqual(meal.consumed_date, consumed_date)
# Verify in database
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(found_meal.consumed_date, consumed_date)
async def test_mark_purchased(self):
"""Test marking a meal as purchased"""
meal = Meal(
suggested_date=datetime(2024, 6, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
# Mark as purchased
updated_meal = await meals_db.mark_purchased(self.conn, meal)
# Verify purchase date was set
self.assertIsNotNone(updated_meal.purchase_date)
self.assertIsNotNone(meal.purchase_date)
# Verify in database
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertIsNotNone(found_meal.purchase_date)
async def test_delete_meal(self):
"""Test soft deleting a meal"""
meal = Meal(
suggested_date=datetime(2024, 7, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
meal_id = meal.id
# Verify meal exists and is in upcoming meals before deletion
start_date = datetime(2024, 7, 1)
end_date = datetime(2024, 7, 31)
upcoming_meals_before = []
async for m in meals_db.find_upcoming_meals_by_date_range(self.conn, start_date, end_date):
if m.id == meal_id:
upcoming_meals_before.append(m)
self.assertEqual(len(upcoming_meals_before), 1)
# Delete the meal
await meals_db.delete_meal(self.conn, meal_id)
# Verify meal no longer appears in upcoming meals (soft deleted)
upcoming_meals_after = []
async for m in meals_db.find_upcoming_meals_by_date_range(self.conn, start_date, end_date):
if m.id == meal_id:
upcoming_meals_after.append(m)
self.assertEqual(len(upcoming_meals_after), 0)
async def test_find_upcoming_meals_by_date_range(self):
"""Test finding upcoming meals within a date range"""
# Create several meals with different dates
meal1 = Meal(
suggested_date=datetime(2024, 8, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
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],
)
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],
)
# Create a consumed meal (should not appear in upcoming)
consumed_meal = Meal(
suggested_date=datetime(2024, 8, 10, 18, 0),
consumed_date=datetime(2024, 8, 10, 19, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal1)
await meals_db.insert_meal(self.conn, meal2)
await meals_db.insert_meal(self.conn, meal3)
await meals_db.insert_meal(self.conn, consumed_meal)
# Mark consumed meal as consumed in DB
await meals_db.mark_consumed(self.conn, consumed_meal, consumed_meal.consumed_date)
# Find meals in August 2024
start_date = datetime(2024, 8, 1)
end_date = datetime(2024, 8, 31)
upcoming_meals = []
async for meal in meals_db.find_upcoming_meals_by_date_range(
self.conn, start_date, end_date
):
upcoming_meals.append(meal)
# Should find meal1 and meal2, but not meal3 (outside range) or consumed_meal (consumed)
self.assertEqual(len(upcoming_meals), 2)
meal_dates = [meal.suggested_date for meal in upcoming_meals]
self.assertIn(datetime(2024, 8, 1, 18, 0), meal_dates)
self.assertIn(datetime(2024, 8, 15, 18, 0), meal_dates)
class TestMealParticipants(unittest.IsolatedAsyncioTestCase):
"""Test meal participant management"""
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_test_data(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_sync_meal_participants(self):
"""Test syncing meal participants"""
meal = Meal(
suggested_date=datetime(2024, 10, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
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")
# Verify participants were updated
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(len(found_meal.chefs), 2)
chef_names = {chef.name for chef in found_meal.chefs}
self.assertIn("Ryan", chef_names)
self.assertIn("Ellie", chef_names)
self.assertNotIn("Jacob", chef_names)
# Cleanup and consumers should remain unchanged
self.assertEqual(len(found_meal.cleanup), 1)
self.assertEqual(found_meal.cleanup[0].name, "Ryan")
self.assertEqual(len(found_meal.consumers), 1)
self.assertEqual(found_meal.consumers[0].name, "Ellie")
class TestMealRecipes(unittest.IsolatedAsyncioTestCase):
"""Test meal recipe management"""
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_test_data(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_insert_meal_recipe_validation(self):
"""Test meal recipe validation during insertion"""
# Try to insert meal recipe without valid meal_id
meal_recipe = MealRecipe(meal_id=-1, recipe_id=1, servings=2.0)
with self.assertRaises(ValueError) as context:
await meals_db.insert_meal_recipe(self.conn, meal_recipe)
self.assertIn("Meal must be inserted", str(context.exception))
async def test_sync_meal_recipes(self):
"""Test syncing meal recipes"""
# Create a recipe first
recipe = test_data.Recipes.broccoli_soup
recipe.id = -1 # Reset ID
await recipes.insert_recipe(self.conn, recipe)
meal = Meal(
suggested_date=datetime(2024, 11, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
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)]
await meals_db.sync_meal_recipes(self.conn, meal.id, meal_recipes)
# Verify recipes were added
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(len(found_meal.recipes), 1)
self.assertEqual(found_meal.recipes[0].servings, 4.0)
class TestMealIngredients(unittest.IsolatedAsyncioTestCase):
"""Test meal extra ingredients management"""
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_test_data(self.conn)
reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
return await super().asyncTearDown()
async def test_sync_extra_ingredients(self):
"""Test syncing extra ingredients"""
# Create a new product for testing
product = products.Product(
id=-1,
shop_code="woolworths",
name="Test Bread Roll",
product_id="test_bread_123",
quantity=1,
unit="Roll",
link="https://example.com/test-bread-roll",
img_small="https://example.com/test-small.jpg",
img_large="https://example.com/test-large.jpg",
raw_data={},
)
await products.insert_product(self.conn, product, {})
meal = Meal(
suggested_date=datetime(2024, 12, 1, 18, 0),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
)
await meals_db.insert_meal(self.conn, meal)
# Add extra ingredients
extra_ingredient = ingredients.Ingredient(
id=-1,
name="Test Bread Roll",
line="1 roll test bread",
unit="roll",
quantity=1.0,
preparation="",
product_id=product.id,
)
await meals_db.sync_extra_ingredients(self.conn, meal.id, [extra_ingredient])
# Verify ingredients were added
found_meal = await meals_db.find_meal_by_id(self.conn, meal.id)
self.assertEqual(len(found_meal.extra_ingredients), 1)
self.assertEqual(found_meal.extra_ingredients[0].name, "Test Bread Roll")
if __name__ == "__main__":
unittest.main()

View file

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

View file

@ -1,156 +1,453 @@
from datetime import datetime, timedelta
import importlib import importlib
import unittest import unittest
import tests.test_data as test_data from datetime import datetime
import shopping
import ingredients, products import tests.test_data as test_data
from db import connect, create from db import connect, create
import shopping
from shopping.models import ShoppingList, ShoppingListItem, StoreEnum
import ingredients.repository as ingredients_repo
import ingredients
import meals
import recipes
def reload_test_data(): def reload_test_data():
global test_data global test_data
test_data = importlib.reload(test_data) test_data = importlib.reload(test_data)
def first(iterable: list, predicate: callable):
for item in iterable:
if predicate(item):
return item
return None
class TestShopping(unittest.IsolatedAsyncioTestCase): class TestShopping(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conn = await connect(':memory:') self.conn = await connect(":memory:")
await create(self.conn) await create(self.conn)
await test_data.create_persons(self.conn)
reload_test_data() reload_test_data()
return await super().asyncSetUp()
async def asyncTearDown(self) -> None: async def asyncTearDown(self) -> None:
await self.conn.close() await self.conn.close()
return await super().asyncTearDown()
async def test_current_shopping_list(self): def test_validate_request(self):
shopping_list = await shopping.current_shopping_list(self.conn) ing = ingredients_repo.Ingredient(
self.assertEqual(len(shopping_list.requests), 0) id=1,
self.assertEqual(len(shopping_list.results), 0) name="Broccoli",
line="500g fresh broccoli",
unit="g",
quantity=500.0,
preparation="chopped",
)
ok_item = ShoppingListItem(ingredient_id=ing.id, person_id=1)
shopping.validate_request(ok_item)
async def test_get_current_adds_upcoming_meals(self): bad_person = ShoppingListItem(ingredient_id=ing.id, person_id=-1)
import meals, recipes with self.assertRaises(ValueError):
shopping.validate_request(bad_person)
meal = test_data.Meals.broccoli_soup_for_jacob missing_both = ShoppingListItem(person_id=1)
for product in [i.product for i in meal.extra_ingredients] + [i.product for r in meal.recipes for i in r.recipe.ingredients]: with self.assertRaises(ValueError):
if product.id < 0: shopping.validate_request(missing_both)
await products.insert_product(self.conn, product, {})
for mr in meal.recipes: async def test_request_and_purchase_ingredient(self):
await recipes.insert_recipe(self.conn, mr.recipe) ing = ingredients_repo.Ingredient(
mr.recipe_id = mr.recipe.id name="Broccoli",
line="500g fresh broccoli",
unit="g",
quantity=500.0,
preparation="chopped",
)
await ingredients_repo.insert_ingredient(self.conn, ing)
person = test_data.Persons.jacob
req_item = await shopping.request(self.conn, person, ingredient=ing)
self.assertIsNotNone(req_item.id)
meal.suggested_date = datetime.now().astimezone() + timedelta(days=1) shop_item = ShoppingListItem(ingredient=ing, person_id=person.id)
s_list = ShoppingList(
store_name=StoreEnum.woolworths,
purchased_by_id=person.id,
items=[shop_item],
)
await shopping.purchase(self.conn, s_list)
self.assertIsNotNone(s_list.id)
loaded = await shopping.load_shopping_list(self.conn, s_list.id)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.id, s_list.id)
async def test_to_lookups_and_is_requested(self):
# Insert ingredient, recipe, meal
ing = ingredients_repo.Ingredient(
name="Carrot",
line="1 carrot",
unit="Items",
quantity=1.0,
preparation="",
)
await ingredients_repo.insert_ingredient(self.conn, ing)
recipe = recipes.Recipe(
id=1,
name="Test Recipe",
link="http://example.com",
serves=4,
created_by_id=1,
)
await recipes.insert_recipe(self.conn, recipe)
meal = meals.Meal(id=1, suggested_date=datetime.now())
await meals.insert_meal(self.conn, meal) await meals.insert_meal(self.conn, meal)
shopping_list = await shopping.current_shopping_list(self.conn) items = [ShoppingListItem(ingredient_id=ing.id, meal_id=meal.id, recipe_id=recipe.id)]
self.assertEqual(len(shopping_list.requests), 1) meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
self.assertEqual(len(shopping_list.results), 0) self.conn, items
)
request = shopping_list.requests[0] self.assertIn(meal.id, meals_lookup)
self.assertEqual(request.meal_id, meal.id) self.assertIn(recipe.id, recipes_lookup)
self.assertIn(ing.id, ingredients_lookup)
async def test_sync_persons_requests(self):
ingredient = test_data.Ingredients.one_apple
person = test_data.Persons.jacob
await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [ingredient]):
pass
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.requests), 1)
self.assertEqual(len(shopping_list.results), 0)
request = shopping_list.requests[0]
self.assertEqual(request.person_id, person.id)
self.assertEqual(request.ingredient.line, ingredient.line)
async def test_sync_persons_requests_multiple_add_item(self):
first = test_data.Ingredients.one_apple
second = test_data.Ingredients.salt
person = test_data.Persons.jacob person = test_data.Persons.jacob
self.assertFalse(await shopping.is_requested(self.conn, meal))
await shopping.request(self.conn, person, meal=meal)
self.assertTrue(await shopping.is_requested(self.conn, meal))
await products.insert_product(self.conn, first.product, {}) async def test_complete_meal_purchase_unrequests_and_marks_purchased(self):
await products.insert_product(self.conn, second.product, {}) # Create a simple recipe with 2 ingredients
recipe = recipes.Recipe(
id=-1,
name="Simple Recipe",
link="http://example.com/simple",
serves=2,
created_by_id=test_data.Persons.jacob.id,
)
await recipes.insert_recipe(self.conn, recipe)
shopping_list = await shopping.current_shopping_list(self.conn) # Create ingredients for the recipe
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first]): ingredient1 = ingredients.Ingredient(
pass name="Bread",
line="2 slices bread",
unit="slices",
quantity=2.0,
preparation="",
recipe_id=recipe.id,
)
ingredient2 = ingredients.Ingredient(
name="Butter",
line="10g butter",
unit="g",
quantity=10.0,
preparation="",
recipe_id=recipe.id,
)
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second]): await ingredients.insert_ingredient(self.conn, ingredient1)
pass await ingredients.insert_ingredient(self.conn, ingredient2)
shopping_list = await shopping.current_shopping_list(self.conn) # Load the recipe with its ingredients
self.assertEqual(len(shopping_list.requests), 2) await recipes.load_recipe_ingredients(self.conn, recipe)
self.assertEqual(len(shopping_list.results), 0)
request_by_line = {r.ingredient.line: r for r in shopping_list.requests} # Create and insert a meal
self.assertEqual(len(request_by_line), 2) meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe)
meal = meals.Meal(
id=-1,
suggested_date=datetime.now(),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
recipes=[meal_recipe],
)
await meals.insert_meal(self.conn, meal)
for requested_ingredient in [first, second]: # Request the meal
request = request_by_line[requested_ingredient.line]
self.assertEqual(request.person_id, person.id)
self.assertEqual(request.ingredient.line, requested_ingredient.line)
async def test_mark_found(self):
ingredient = test_data.Ingredients.one_apple
await products.insert_product(self.conn, ingredient.product, {})
shopping_list = await shopping.current_shopping_list(self.conn)
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now().astimezone())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 1)
self.assertEqual(shopping_list.results[0].product_id, ingredient.product.id)
self.assertEqual(shopping_list.results[0].quantity, 1)
self.assertEqual(shopping_list.results[0].unit, ingredient.unit)
ingredient.quantity = 2
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now().astimezone())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 1)
self.assertEqual(shopping_list.results[0].product_id, ingredient.product.id)
self.assertEqual(shopping_list.results[0].quantity, 2)
self.assertEqual(shopping_list.results[0].unit, ingredient.unit)
ingredient.unit = 'kg'
await shopping.mark_found(self.conn, ingredient, date_found=datetime.now().astimezone())
shopping_list = await shopping.current_shopping_list(self.conn)
self.assertEqual(len(shopping_list.results), 2)
results_by_unit = {r.unit: r for r in shopping_list.results}
self.assertEqual(len(results_by_unit), 2)
self.assertIn('kg', results_by_unit)
self.assertIn('Items', results_by_unit)
self.assertEqual(results_by_unit['kg'].product_id, results_by_unit['Items'].product_id)
async def test_purchase(self):
ingredient = test_data.Ingredients.one_apple
person = test_data.Persons.jacob person = test_data.Persons.jacob
await shopping.request(self.conn, person, meal=meal)
self.assertTrue(await shopping.is_requested(self.conn, meal))
await products.insert_product(self.conn, ingredient.product, {}) # Get initial outstanding requests
(
outstanding_before,
purchased_before,
meal_requests_before,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_before), 2)
self.assertEqual(len(meal_requests_before), 1)
shopping_list = await shopping.current_shopping_list(self.conn) # Purchase all ingredients from the meal
self.assertIsNone(shopping_list.purchased_date) shopping_items = [
ShoppingListItem(
ingredient_id=item.ingredient_id,
person_id=person.id,
meal_id=meal.id,
recipe_id=item.recipe_id,
)
for item in outstanding_before
]
shopping_list = ShoppingList(
store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items
)
await shopping.purchase(self.conn, shopping_list)
shopping_list = await shopping.mark_purchased(self.conn) # Verify meal is no longer requested and is marked as purchased
self.assertIsNotNone(shopping_list.purchased_date) self.assertFalse(await shopping.is_requested(self.conn, meal))
self.assertLessEqual(shopping_list.purchased_date - datetime.now().astimezone(), timedelta(seconds=1)) found_meal_after = await meals.find_meal_by_id(self.conn, meal.id)
self.assertIsNotNone(found_meal_after.purchase_date)
new_shopping_list = await shopping.current_shopping_list(self.conn) # After complete purchase, there should be no outstanding/purchased items for that meal
self.assertNotEqual(shopping_list.id, new_shopping_list.id) (
self.assertIsNone(new_shopping_list.purchased_date) outstanding_after,
purchased_after,
meal_requests_after,
_,
_,
_,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_after), 0)
self.assertEqual(len(purchased_after), 0)
self.assertEqual(len(meal_requests_after), 0)
async def test_complete_meal_with_extra_ingredients_purchase(self):
# Create a recipe with 1 ingredient
recipe = recipes.Recipe(
id=-1,
name="Recipe with Extra",
link="http://example.com/extra",
serves=2,
created_by_id=test_data.Persons.jacob.id,
)
await recipes.insert_recipe(self.conn, recipe)
# Recipe ingredient
recipe_ingredient = ingredients.Ingredient(
name="Main Ingredient",
line="200g main ingredient",
unit="g",
quantity=200.0,
preparation="",
recipe_id=recipe.id,
)
await ingredients.insert_ingredient(self.conn, recipe_ingredient)
await recipes.load_recipe_ingredients(self.conn, recipe)
# Extra ingredient (not part of recipe)
extra_ingredient = ingredients.Ingredient(
name="Side Dish", line="1 side dish", unit="item", quantity=1.0, preparation=""
)
meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe)
meal = meals.Meal(
id=-1,
suggested_date=datetime.now(),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
recipes=[meal_recipe],
extra_ingredients=[extra_ingredient],
)
await meals.insert_meal(self.conn, meal)
person = test_data.Persons.jacob
await shopping.request(self.conn, person, meal=meal)
(
outstanding_before,
_,
_,
_,
_,
_,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_before), 2) # recipe + extra
shopping_items = [
ShoppingListItem(
ingredient_id=item.ingredient_id,
person_id=person.id,
meal_id=meal.id,
recipe_id=item.recipe_id,
)
for item in outstanding_before
]
shopping_list = ShoppingList(
store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items
)
await shopping.purchase(self.conn, shopping_list)
self.assertFalse(await shopping.is_requested(self.conn, meal))
found_meal_after = await meals.find_meal_by_id(self.conn, meal.id)
self.assertIsNotNone(found_meal_after.purchase_date)
(
outstanding_after,
purchased_after,
meal_requests_after,
_,
_,
_,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_after), 0)
self.assertEqual(len(purchased_after), 0)
self.assertEqual(len(meal_requests_after), 0)
async def test_individual_ingredient_purchase_without_meal(self):
# Create individual ingredients
ingredient1 = ingredients.Ingredient(
name="Milk", line="1L milk", unit="L", quantity=1.0, preparation=""
)
ingredient2 = ingredients.Ingredient(
name="Eggs", line="12 eggs", unit="dozen", quantity=1.0, preparation=""
)
await ingredients.insert_ingredient(self.conn, ingredient1)
await ingredients.insert_ingredient(self.conn, ingredient2)
# Request individual ingredients
person = test_data.Persons.jacob
await shopping.request(self.conn, person, ingredient=ingredient1)
await shopping.request(self.conn, person, ingredient=ingredient2)
(
outstanding_before,
purchased_before,
meal_requests_before,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_before), 2)
self.assertEqual(len(purchased_before), 0)
self.assertEqual(len(meal_requests_before), 0)
# Purchase only Milk
milk_item = next(
ShoppingListItem(ingredient_id=i.ingredient_id, person_id=person.id)
for i in outstanding_before
if ingredients_lookup.get(i.ingredient_id).name == "Milk"
)
shopping_list = ShoppingList(
store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[milk_item]
)
await shopping.purchase(self.conn, shopping_list)
(
outstanding_after,
purchased_after,
meal_requests_after,
meals_lookup_after,
recipes_lookup_after,
ingredients_lookup_after,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_after), 1)
self.assertEqual(len(purchased_after), 0)
self.assertEqual(len(meal_requests_after), 0)
self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Eggs")
self.assertIsNone(outstanding_after[0].meal_id)
# Purchase remaining Eggs
eggs_item = ShoppingListItem(
ingredient_id=outstanding_after[0].ingredient_id, person_id=person.id
)
shopping_list2 = ShoppingList(
store_name=StoreEnum.coles, purchased_by_id=person.id, items=[eggs_item]
)
await shopping.purchase(self.conn, shopping_list2)
(
outstanding_final,
purchased_final,
meal_requests_final,
_,
_,
_,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_final), 0)
self.assertEqual(len(purchased_final), 0)
self.assertEqual(len(meal_requests_final), 0)
async def test_mixed_meal_and_individual_requests(self):
# Create recipe and meal
recipe = recipes.Recipe(
id=-1,
name="Simple Pasta",
link="http://example.com/pasta",
serves=2,
created_by_id=test_data.Persons.jacob.id,
)
await recipes.insert_recipe(self.conn, recipe)
pasta_ingredient = ingredients.Ingredient(
name="Pasta",
line="200g pasta",
unit="g",
quantity=200.0,
preparation="",
recipe_id=recipe.id,
)
await ingredients.insert_ingredient(self.conn, pasta_ingredient)
await recipes.load_recipe_ingredients(self.conn, recipe)
meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe)
meal = meals.Meal(
id=-1,
suggested_date=datetime.now(),
chefs=[test_data.Persons.jacob],
cleanup=[test_data.Persons.ryan],
consumers=[test_data.Persons.ellie],
recipes=[meal_recipe],
)
await meals.insert_meal(self.conn, meal)
snack_ingredient = ingredients.Ingredient(
name="Chips", line="1 bag chips", unit="bag", quantity=1.0, preparation=""
)
await ingredients.insert_ingredient(self.conn, snack_ingredient)
person = test_data.Persons.jacob
await shopping.request(self.conn, person, meal=meal)
await shopping.request(self.conn, person, ingredient=snack_ingredient)
(
outstanding_before,
purchased_before,
meal_requests_before,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_before), 2)
self.assertEqual(len(purchased_before), 0)
self.assertEqual(len(meal_requests_before), 1)
# Identify items
pasta_item = next(
i for i in outstanding_before if ingredients_lookup.get(i.ingredient_id).name == "Pasta"
)
chips_item = next(
i for i in outstanding_before if ingredients_lookup.get(i.ingredient_id).name == "Chips"
)
self.assertIsNotNone(pasta_item)
self.assertIsNotNone(chips_item)
self.assertEqual(pasta_item.meal_id, meal.id)
self.assertIsNone(chips_item.meal_id)
# Purchase only Chips
chips_shopping_item = ShoppingListItem(
ingredient_id=chips_item.ingredient_id, person_id=person.id
)
shopping_list = ShoppingList(
store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[chips_shopping_item]
)
await shopping.purchase(self.conn, shopping_list)
(
outstanding_after,
purchased_after,
meal_requests_after,
meals_lookup_after,
recipes_lookup_after,
ingredients_lookup_after,
) = await shopping.get_outstanding_requests(self.conn)
self.assertEqual(len(outstanding_after), 1)
self.assertEqual(len(purchased_after), 0)
self.assertEqual(len(meal_requests_after), 1)
self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Pasta")
self.assertEqual(outstanding_after[0].meal_id, meal.id)

View file

@ -0,0 +1,61 @@
import importlib
import unittest
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 TestShoppingAPI(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_purchase_unauthorized_without_cookie(self):
# Do NOT override cookie_person; no cookie provided => 401
body = {"storeName": "woolworths", "items": []}
resp = self.client.post("/api/v1/shopping", json=body)
assert resp.status_code == 401
assert "application/problem+json" in resp.headers.get("content-type", "")
prob = resp.json()
assert prob.get("status") == 401
assert prob.get("title")
def test_purchase_validation_error_returns_problem(self):
# Override cookie_person to simulate authenticated user
async def override_cookie_person():
return test_data.Persons.jacob
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
# Empty items triggers domain validation error => 400 with Problem Details
body = {"storeName": "woolworths", "items": []}
resp = self.client.post("/api/v1/shopping", json=body)
assert resp.status_code == 400
assert "application/problem+json" in resp.headers.get("content-type", "")
prob = resp.json()
assert prob.get("status") == 400
assert "items" in prob.get("title", "").lower() or prob.get("title")

115
tests/test_v1.py Normal file
View 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

113
tighten-api-spec.md Normal file
View file

@ -0,0 +1,113 @@
# Tighten Public API Nullability
Make the external API more consistent and predictable by eliminating unnecessary nulls (nullable fields) in models and responses. This plan lists concrete, low-risk changes, their rationale, and exact files/lines to modify. Each task is checkable and includes verification steps.
Date: 2025-10-21
## Principles
- Prefer non-nullable types where domain requires a value (DB constraints, logic always sets it).
- Keep optional only when a field truly may be absent by design (e.g., hiddenBy, prevCursor when first page).
- Preserve backward compatibility where feasible. When changing response shapes, update tests and OpenAPI examples.
- Pydantic already excludes None on serialization in some places; we still tighten model types to improve OpenAPI and client SDKs.
## Quick wins (low risk)
- [x] Page.total is non-nullable with default 0
- Why: Pagination always returns a number. Current `Optional[int]` leads to `null` in schema and potential nulls in responses.
- Change: in `common.py`, change `total: Optional[int]` to `total: int = Field(default=0, description="Total count")`.
- Verify:
- [ ] mypy/pyright/ruff pass.
- [ ] Tests for persons/recipes list remain green.
- [ ] OpenAPI shows `total` as `integer` (no anyOf null).
- [x] Recipe.created_by_id non-nullable
- Why: DB enforces NOT NULL and creation flow always sets it.
- Change: in `recipes/models.py` set `created_by_id: int` (remove Optional). Keep `created_by: Optional[Person]` (hydrated field).
- Knock-on: `api/recipes.load_full_recipe` can drop the `if r.created_by_id is not None` guard.
- Verify:
- [ ] All recipe-related tests green.
- [ ] OpenAPI for Recipe shows `createdById` required.
- [x] Product.raw_data excluded from public schema
- Why: Internal/testing helper currently typed as `Optional[dict]` -> visible as nullable in OpenAPI.
- Change: in `products/models.py` use a `PrivateAttr` (with a `raw_data` property) so it stays out of the schema without creating Input/Output variants.
- Verify:
- [ ] Product schema in OpenAPI does not include `rawData`.
- [ ] Tests referencing raw_data still pass (field remains available in code, excluded from schema/response).
## Shopping models and endpoints
`ShoppingListItem` currently represents two cases (ingredient request vs meal request), so several linking fields are nullable. We can reduce nulls in the public API by introducing outward-facing variants while keeping the DB model as-is.
- [ ] Optional: Introduce discriminated union for API returns (medium change)
- Rationale: Return `oneOf` in OpenAPI with variant-specific required fields; eliminates irrelevant nullable properties for each variant.
- Approach (sketch):
- Define `ListIngredientItem` and `RequestedMealItem` pydantic models with a `kind` discriminator.
- Update `api/shopping.py` response models (CurrentShoppingList and PurchasedShoppingList) to use `Union[ListIngredientItem, RequestedMealItem]` for item arrays.
- Conversion helpers in `shopping` module to map from `ShoppingListItem` DB model to the outward union.
- Verify:
- [ ] Update tests in `tests/test_shopping_api.py` to accept the new shape while preserving field meanings.
- [ ] OpenAPI shows `oneOf` for shopping list items.
- [ ] Tighten invariants without breaking shape (keep for now)
- Keep model but document invariants (only one of ingredient_id/meal_id required; recipe_id optional when meal request). Repository already validates; consider pydantic validators later.
## Persons and Recipes listings
- [ ] Ensure total is populated for Person and Recipe lists
- Already implemented in `api/persons.py` and `api/recipes.py` using repository `count_*` helpers. After making `Page.total` non-nullable, nothing else required.
## Authentication dependency
- [x] Provide strict non-null person dependency for protected endpoints
- Why: Many endpoints assume an authenticated user; typing as non-null simplifies signatures and docs.
- Change:
- Consolidated on a single dependency `cookie_person` (strict): `Cookie(..., alias="user_id")` and raises 401 if missing/unknown.
- Removed `require_cookie_person` and switched usages to `cookie_person`.
- Verify:
- [x] Endpoint signatures updated.
- [x] Unauthorized behavior covered by handlers; overall tests still pass.
## File-by-file checklist (edits)
- [x] `common.py`
- [x] Page.total -> `int = Field(default=0, ...)`
- [x] `recipes/models.py`
- [x] `created_by_id: int`
- [x] `api/recipes.py`
- [x] In `load_full_recipe`, set `r.created_by = await persons.get_by_id(conn, r.created_by_id)` unconditionally.
- [x] `products/models.py`
- [x] `raw_data` moved to `PrivateAttr` with property; kept out of schema.
- [x] `api/deps.py`
- [x] Added `require_cookie_person(...) -> persons.Person` that raises 401.
- [x] Updated protected endpoints to depend on `require_cookie_person`.
- [ ] (Optional) Shopping API union types
- [x] Add outward-facing union models and mapping helpers.
- [x] Update `api/shopping.py` response models to use union.
## Tests and validation
- [x] Run format/lint/typecheck
- make format && make lint && make typecheck
- [x] Run tests
- make test
- [x] Export OpenAPI and inspect schema
- make openapi (confirm: Recipe.createdById required; Product has single schema; Page.total non-nullable; cookie param required on protected ops.)
## Rollout notes
- API change in OpenAPI (non-null total; createdById required). Client SDKs generated from the spec may need re-gen. Runtime remains compatible because server fills these fields.
- Even without the optional union refactor, the quick wins remove several unnecessary nulls.
## Acceptance criteria
- OpenAPI no longer marks Page.total and Recipe.createdById as nullable.
- Product.rawData does not appear in the public schema.
- All tests pass; no runtime regressions.
- Optional: Shopping list items use `oneOf` variants.

View file

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