Compare commits
29 commits
nullproduc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be2d1a2b0 | |||
| 5a9242ccc9 | |||
| a6eb819057 | |||
| 5a85250574 | |||
| 7b6f4e2a3b | |||
| 5c33e9c2a4 | |||
| 5be4e89c4e | |||
| 50a1fcaeee | |||
| 865c02b195 | |||
| 45ff778112 | |||
| 589eb5380c | |||
| 322e14c26c | |||
| 99b699eec9 | |||
| 89cfbe9abc | |||
| a3c0a2701e | |||
| f51c90f922 | |||
| 53b07343d1 | |||
| 2bbed57313 | |||
| edfc341b4b | |||
| 3d7afa3765 | |||
| 1b76ea44b6 | |||
| 4c3f370ddc | |||
| 18f784665f | |||
| 255ebd4613 | |||
| 2a66ff39be | |||
| 54f0dcbbb9 | |||
| 410b097d5c | |||
| 4fa88335d1 | |||
| 1706809458 |
65 changed files with 10190 additions and 1877 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
11
.env.example
Normal file
11
.env.example
Normal 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
44
.gitignore
vendored
|
|
@ -1,3 +1,47 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Caches
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# SQLite databases & dumps
|
||||
*.sqlite
|
||||
/data/dump/
|
||||
|
||||
# Environments
|
||||
.venv/
|
||||
.env
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage*
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.pytest_cache/
|
||||
.mypy_cache/
|
||||
.pytype/
|
||||
.venv/
|
||||
.env
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Local data
|
||||
/data/
|
||||
/front-dist/
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage*
|
||||
.venv/
|
||||
__pycache__
|
||||
data/
|
||||
|
|
|
|||
29
.pre-commit-config.yaml
Normal file
29
.pre-commit-config.yaml
Normal 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
186
CONTRIBUTING.md
Normal 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
63
Makefile
Normal 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
131
README.md
|
|
@ -1,11 +1,132 @@
|
|||
Meal planner backend
|
||||
|
||||
Install packages
|
||||
```
|
||||
pip install -r ./requirements.txt
|
||||
## Quickstart
|
||||
|
||||
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
2
api/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# API package for FastAPI routers.
|
||||
# Routers will be split by feature: recipes, meals, persons, shopping, auth.
|
||||
53
api/auth.py
Normal file
53
api/auth.py
Normal 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
79
api/deps.py
Normal 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
220
api/meals.py
Normal 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
146
api/openapi.py
Normal 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
93
api/persons.py
Normal 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
33
api/products.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from pydantic import Field
|
||||
|
||||
import products
|
||||
from api.deps import get_db
|
||||
from common import ApiModel
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
|
||||
class ProductUrl(ApiModel):
|
||||
url: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createProduct",
|
||||
summary="Create or fetch a product from a URL",
|
||||
response_model=products.Product,
|
||||
)
|
||||
async def create_product(
|
||||
url: ProductUrl, response: Response, conn: aiosqlite.Connection = Depends(get_db)
|
||||
) -> Optional[products.Product]:
|
||||
product = await products.get_or_create(conn, url.url, url.tags)
|
||||
if product:
|
||||
response.headers["Location"] = f"/api/v1/products/{product.id}"
|
||||
return product
|
||||
278
api/recipes.py
Normal file
278
api/recipes.py
Normal 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
378
api/shopping.py
Normal 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
56
common.py
Normal 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")
|
||||
34
db.py
34
db.py
|
|
@ -1,29 +1,39 @@
|
|||
import asyncio
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def create(conn: aiosqlite.Connection):
|
||||
import products.db as product_db
|
||||
import products.repository as product_db
|
||||
|
||||
await product_db.create(conn)
|
||||
|
||||
import ingredients.db as ingredient_db
|
||||
import ingredients.repository as ingredient_db
|
||||
|
||||
await ingredient_db.create(conn)
|
||||
|
||||
import recipes.db as recipe_db
|
||||
import recipes.repository as recipe_db
|
||||
|
||||
await recipe_db.create(conn)
|
||||
|
||||
import persons.db as person_db
|
||||
import persons.repository as person_db
|
||||
|
||||
await person_db.create(conn)
|
||||
|
||||
import meals.db as meals_db
|
||||
import meals.repository as meals_db
|
||||
|
||||
await meals_db.create(conn)
|
||||
|
||||
import shopping.db as shopping_db
|
||||
|
||||
import shopping.repository as shopping_db
|
||||
|
||||
await shopping_db.create(conn)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
if __name__ == "__main__":
|
||||
from tests.test_data import create_test_data
|
||||
|
||||
async def main():
|
||||
|
|
@ -33,5 +43,5 @@ if __name__ == '__main__':
|
|||
await create_test_data(conn)
|
||||
await conn.commit()
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
asyncio.run(main())
|
||||
|
|
|
|||
10
dev-requirements.txt
Normal file
10
dev-requirements.txt
Normal 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
|
||||
|
|
@ -1,37 +1,48 @@
|
|||
from ingredients.db import Ingredient, find_ingredients_by_meal_id, find_ingredients_by_recipe_id, insert_ingredient, delete_ingredients_by_meal_id
|
||||
|
||||
import units
|
||||
from products import Product, find_product_by_tag, get_or_create, add_missing_tags
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from ingredient_parser import parse_ingredient
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
import units
|
||||
from ingredients.models import Ingredient
|
||||
from ingredients.repository import (
|
||||
delete_ingredients_by_meal_id as delete_ingredients_by_meal_id,
|
||||
find_ingredient_by_id as find_ingredient_by_id,
|
||||
find_ingredients_by_meal_id as find_ingredients_by_meal_id,
|
||||
find_ingredients_by_recipe_id as find_ingredients_by_recipe_id,
|
||||
find_ingredients_by_recipe_ids as find_ingredients_by_recipe_ids,
|
||||
insert_ingredient as insert_ingredient,
|
||||
)
|
||||
from products import Product, add_missing_tags, find_product_by_tag, get_or_create
|
||||
|
||||
async def parse_ingredient_from_link(conn, link: str) -> Ingredient:
|
||||
match = re.match(r'^(\d+)?\s*(http.*)$', link)
|
||||
|
||||
async def parse_ingredient_from_link(conn, link: str) -> Optional[Ingredient]:
|
||||
match = re.match(r"^(\d+)?\s*(http.*)$", link)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
|
||||
quantity = int(match.group(1)) if match.group(1) else 1
|
||||
url = match.group(2)
|
||||
product = await get_or_create(conn, url, [])
|
||||
if product:
|
||||
await add_missing_tags(conn, product, [product.name])
|
||||
|
||||
return Ingredient(id=-1,
|
||||
return Ingredient(
|
||||
id=-1,
|
||||
name=product.name,
|
||||
line=f"{quantity}x {product.name}",
|
||||
unit=units.ITEMS.name,
|
||||
quantity=quantity,
|
||||
preparation='',
|
||||
preparation="",
|
||||
product_id=product.id,
|
||||
product=product
|
||||
product=product,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
|
||||
ingredient = parse_ingredient(ingredient_string)
|
||||
name = ingredient.name.text if ingredient.name else ''
|
||||
name = ingredient.name.text if ingredient.name else ""
|
||||
|
||||
quantity, unit = None, None
|
||||
for amount in ingredient.amount:
|
||||
|
|
@ -54,20 +65,22 @@ def parse_ingredient_from_nlp(ingredient_string: str) -> Ingredient:
|
|||
|
||||
if unit is None:
|
||||
unit = units.ITEMS.name
|
||||
|
||||
return Ingredient(id=0,
|
||||
|
||||
return Ingredient(
|
||||
id=-1,
|
||||
line=ingredient.sentence,
|
||||
name=name,
|
||||
quantity=quantity,
|
||||
unit=unit,
|
||||
preparation=ingredient.preparation.text if ingredient.preparation else '',
|
||||
product_id=-1
|
||||
preparation=ingredient.preparation.text if ingredient.preparation else "",
|
||||
product_id=-1,
|
||||
)
|
||||
|
||||
async def _find_existing_product(conn, ingredient: str) -> Product:
|
||||
async for item in find_product_by_tag(conn, ingredient):
|
||||
return item
|
||||
return None
|
||||
|
||||
async def _find_existing_product(conn, ingredient: str) -> Optional[Product]:
|
||||
items = [item async for item in find_product_by_tag(conn, ingredient)]
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[Ingredient]:
|
||||
for ingredient in ingredients:
|
||||
|
|
@ -77,4 +90,4 @@ async def match_existing_products(conn, ingredients: List[Ingredient]) -> List[I
|
|||
ingredient.product_id = existing.id
|
||||
ingredient.product = existing
|
||||
|
||||
return ingredients
|
||||
return ingredients
|
||||
|
|
|
|||
|
|
@ -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
48
ingredients/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from common import ApiModel
|
||||
from products import Product
|
||||
from units import ALL_UNITS
|
||||
|
||||
|
||||
class Ingredient(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"name",
|
||||
"line",
|
||||
"preparation",
|
||||
"unit",
|
||||
"quantity",
|
||||
"product_id",
|
||||
"recipe_id",
|
||||
"meal_id",
|
||||
]
|
||||
id: int = -1
|
||||
name: str
|
||||
line: str
|
||||
unit: str = Field(
|
||||
title="Unit",
|
||||
description="Measurement unit (enum values are advisory; runtime accepts any string)",
|
||||
json_schema_extra={"enum": [u.name for u in ALL_UNITS]},
|
||||
)
|
||||
quantity: float
|
||||
preparation: str
|
||||
product_id: Optional[int] = None
|
||||
recipe_id: Optional[int] = None
|
||||
meal_id: Optional[int] = None
|
||||
product: Optional[Product] = None
|
||||
|
||||
# Ensure quantity is stored as a float even if provided as a string in tests
|
||||
@field_validator("quantity", mode="before")
|
||||
@classmethod
|
||||
def _coerce_quantity(cls, v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
return v
|
||||
return v
|
||||
159
ingredients/repository.py
Normal file
159
ingredients/repository.py
Normal 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,),
|
||||
)
|
||||
534
main.py
534
main.py
|
|
@ -1,387 +1,203 @@
|
|||
import sqlite3
|
||||
import products, recipes, db, meals, persons, ingredients, shopping
|
||||
import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Annotated, Optional, Union
|
||||
from fastapi import FastAPI, Depends, Query, Cookie
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import ValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
app = FastAPI()
|
||||
from api import (
|
||||
auth as auth_router,
|
||||
meals as meals_router,
|
||||
persons as persons_router,
|
||||
products as products_router,
|
||||
recipes as recipes_router,
|
||||
shopping as shopping_router,
|
||||
)
|
||||
from api.deps import (
|
||||
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||
error_response as error_response, # noqa: F401 - re-exported for completeness
|
||||
get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
|
||||
)
|
||||
from api.openapi import extend_with_problem_and_cookie_auth
|
||||
from common import ApiModel, ProblemDetails
|
||||
from settings import settings
|
||||
|
||||
import os
|
||||
DATABASE_PATH = os.environ.get('DOOF_DB', './data/doof.sqlite')
|
||||
|
||||
# Dependency to create SQLite connection
|
||||
async def get_db():
|
||||
sql_db = await db.connect(DATABASE_PATH)
|
||||
class CamelCaseRoute(APIRoute):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("response_model_by_alias", True)
|
||||
kwargs.setdefault("response_model_exclude_none", True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(app: FastAPI):
|
||||
client = None
|
||||
if not settings.prod:
|
||||
import httpx
|
||||
|
||||
client = httpx.AsyncClient(base_url=settings.frontend_dev_url)
|
||||
app.state.proxy_client = client
|
||||
try:
|
||||
yield sql_db
|
||||
yield
|
||||
finally:
|
||||
await sql_db.close()
|
||||
if client is not None:
|
||||
await client.aclose()
|
||||
|
||||
async def cookie_person(user_id: Annotated[int, Cookie(alias='user_id')], conn: sqlite3.Connection = Depends(get_db)) -> persons.Person:
|
||||
return await persons.get_by_id(conn, user_id)
|
||||
|
||||
@app.get("/api/recipes/parse")
|
||||
async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db), person = Depends(cookie_person)) -> recipes.Recipe:
|
||||
parsed = await recipes.parse_recipe(conn, person, url)
|
||||
if not parsed:
|
||||
return JSONResponse(status_code=400, content={'message': 'Recipe not found'})
|
||||
return parsed
|
||||
# RFC7807 Problem Details handlers (standalone functions, registered in factory)
|
||||
async def http_exc_handler(request: Request, exc: Exception):
|
||||
# Narrow to StarletteHTTPException at runtime
|
||||
assert isinstance(exc, StarletteHTTPException)
|
||||
body = ProblemDetails(
|
||||
title=str(exc.detail) if exc.detail else "HTTP Error",
|
||||
status=exc.status_code,
|
||||
type=f"https://httpstatuses.com/{exc.status_code}",
|
||||
instance=str(request.url),
|
||||
)
|
||||
return JSONResponse(
|
||||
content=body.model_dump(by_alias=True),
|
||||
status_code=exc.status_code,
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
@app.get("/api/recipes/ingredients/parse")
|
||||
async def parse_ingredients(lines: Annotated[
|
||||
List[str],
|
||||
Query(alias="ingredients",
|
||||
title="Array of ingredients to parse")],
|
||||
conn: sqlite3.Connection = Depends(get_db)) -> List[ingredients.Ingredient]:
|
||||
|
||||
had_links = False
|
||||
result = []
|
||||
for line in lines:
|
||||
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
had_links = True
|
||||
continue
|
||||
|
||||
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
||||
if ingredient:
|
||||
result.append(ingredient)
|
||||
continue
|
||||
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",
|
||||
)
|
||||
|
||||
if had_links:
|
||||
await conn.commit()
|
||||
|
||||
await ingredients.match_existing_products(conn, result)
|
||||
return result
|
||||
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",
|
||||
)
|
||||
|
||||
class ProductUrl(BaseModel):
|
||||
url: str
|
||||
tags: List[str] = []
|
||||
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",
|
||||
)
|
||||
|
||||
@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
|
||||
class HealthStatus(ApiModel):
|
||||
status: str = "ok"
|
||||
|
||||
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
|
||||
async def healthz() -> HealthStatus:
|
||||
return HealthStatus()
|
||||
|
||||
@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)
|
||||
|
||||
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:
|
||||
async for recipe in recipes.get_all(conn):
|
||||
result.append(recipe)
|
||||
# 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
|
||||
|
||||
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
|
||||
async def _reverse_proxy(request: StarletteRequest):
|
||||
import httpx
|
||||
|
||||
@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
|
||||
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
||||
client = app.state.proxy_client
|
||||
rp_req = client.build_request(
|
||||
request.method, url, headers=request.headers.raw, content=request.stream()
|
||||
)
|
||||
rp_resp = await client.send(rp_req, stream=True)
|
||||
return StreamingResponse(
|
||||
rp_resp.aiter_raw(),
|
||||
status_code=rp_resp.status_code,
|
||||
headers=rp_resp.headers,
|
||||
background=BackgroundTask(rp_resp.aclose),
|
||||
)
|
||||
|
||||
@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)
|
||||
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return recipe
|
||||
return app
|
||||
|
||||
@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)
|
||||
# Module-level app for uvicorn
|
||||
app = create_app()
|
||||
|
||||
return result
|
||||
DATABASE_PATH = settings.database_path
|
||||
|
||||
@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
|
||||
|
||||
client = httpx.AsyncClient(base_url="http://localhost:8080/")
|
||||
|
||||
async def _reverse_proxy(request: Request):
|
||||
url = httpx.URL(path=request.url.path,
|
||||
query=request.url.query.encode("utf-8"))
|
||||
rp_req = client.build_request(request.method, url,
|
||||
headers=request.headers.raw,
|
||||
content=request.stream())
|
||||
rp_resp = await client.send(rp_req, stream=True)
|
||||
return StreamingResponse(
|
||||
rp_resp.aiter_raw(),
|
||||
status_code=rp_resp.status_code,
|
||||
headers=rp_resp.headers,
|
||||
background=BackgroundTask(rp_resp.aclose),
|
||||
)
|
||||
|
||||
app.add_route("/{path:path}",_reverse_proxy, ["GET", "POST"])
|
||||
# get_db, cookie_person, and error_response are imported from api.deps
|
||||
|
|
|
|||
|
|
@ -1 +1,27 @@
|
|||
from meals.db import *
|
||||
from meals.models import Meal as Meal, MealRecipe as MealRecipe
|
||||
from meals.repository import (
|
||||
bulk_load_participants as bulk_load_participants,
|
||||
create as create,
|
||||
delete_meal as delete_meal,
|
||||
find_meal_by_id as find_meal_by_id,
|
||||
find_upcoming_meals_by_date_range as find_upcoming_meals_by_date_range,
|
||||
insert_meal as insert_meal,
|
||||
insert_meal_participant as insert_meal_participant,
|
||||
insert_meal_recipe as insert_meal_recipe,
|
||||
load_extra_ingredients as load_extra_ingredients,
|
||||
load_participants as load_participants,
|
||||
load_recipes as load_recipes,
|
||||
mark_consumed as mark_consumed,
|
||||
mark_purchased as mark_purchased,
|
||||
sync_extra_ingredients as sync_extra_ingredients,
|
||||
sync_meal_participants as sync_meal_participants,
|
||||
sync_meal_recipes as sync_meal_recipes,
|
||||
update_meal as update_meal,
|
||||
)
|
||||
from meals.roles import (
|
||||
ROLE_CHEF as ROLE_CHEF,
|
||||
ROLE_CLEANUP as ROLE_CLEANUP,
|
||||
ROLE_CONSUMER as ROLE_CONSUMER,
|
||||
)
|
||||
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
||||
from persons import Person as Person
|
||||
|
|
|
|||
226
meals/db.py
226
meals/db.py
|
|
@ -1,226 +0,0 @@
|
|||
from typing import AsyncIterator, List, ClassVar, Optional
|
||||
from pydantic import BaseModel
|
||||
from ingredients import Ingredient, insert_ingredient, find_ingredients_by_meal_id, delete_ingredients_by_meal_id
|
||||
|
||||
from recipes import Recipe, row_to_recipe, load_recipe_ingredients
|
||||
|
||||
import persons
|
||||
from persons import Person
|
||||
|
||||
import datetime
|
||||
|
||||
class MealRecipe(BaseModel):
|
||||
meal_id: int
|
||||
recipe_id: int
|
||||
servings: float
|
||||
|
||||
recipe: Optional[Recipe] = None
|
||||
|
||||
class Meal(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'suggested_date', 'consumed_date', 'purchase_date']
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[Person] = []
|
||||
cleanup: List[Person] = []
|
||||
consumers: List[Person] = []
|
||||
recipes: List[MealRecipe] = []
|
||||
extra_ingredients: List[Ingredient] = []
|
||||
|
||||
# Set from shopping list
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Meal (
|
||||
id INTEGER PRIMARY KEY,
|
||||
suggested_date DATETIME,
|
||||
consumed_date DATETIME DEFAULT NULL,
|
||||
deleted_date DATETIME DEFAULT NULL,
|
||||
purchase_date DATETIME DEFAULT NULL
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS MealParticipant (
|
||||
meal_id INTEGER,
|
||||
person_id INTEGER,
|
||||
role TEXT,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(person_id) REFERENCES Person(id)
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS MealRecipe (
|
||||
meal_id INTEGER,
|
||||
recipe_id INTEGER,
|
||||
servings REAL,
|
||||
FOREIGN KEY(meal_id) REFERENCES Meal(id),
|
||||
FOREIGN KEY(recipe_id) REFERENCES Recipe(id)
|
||||
);''')
|
||||
|
||||
async def insert_meal_participant(conn, meal_id: int, person_id: int, role: str):
|
||||
await conn.execute('''
|
||||
INSERT INTO MealParticipant (meal_id, person_id, role)
|
||||
VALUES (?, ?, ?)
|
||||
''', (meal_id, person_id, role))
|
||||
|
||||
async def sync_meal_participants(conn, meal_id: int, participants: List[Person], role: str):
|
||||
await conn.execute('''
|
||||
DELETE FROM MealParticipant
|
||||
WHERE meal_id = ? AND role = ?
|
||||
''', (meal_id, role))
|
||||
|
||||
for person in participants:
|
||||
await insert_meal_participant(conn, meal_id, person.id, role)
|
||||
|
||||
async def insert_meal_recipe(conn, r: MealRecipe):
|
||||
if r.meal_id < 0:
|
||||
raise ValueError('Meal must be inserted before meal recipe')
|
||||
|
||||
if r.recipe_id < 0 and r.recipe:
|
||||
r.recipe_id = r.recipe.id
|
||||
|
||||
if r.recipe_id < 0:
|
||||
raise ValueError('Recipe must be inserted before meal')
|
||||
|
||||
await conn.execute('''
|
||||
INSERT INTO MealRecipe (meal_id, recipe_id, servings)
|
||||
VALUES (?, ?, ?)
|
||||
''', (r.meal_id, r.recipe_id, r.servings))
|
||||
|
||||
async def insert_meal(conn, meal: Meal):
|
||||
async with conn.execute('''
|
||||
INSERT INTO Meal (suggested_date)
|
||||
VALUES (?)
|
||||
''', (meal.suggested_date.isoformat(),)) as cursor:
|
||||
meal.id = cursor.lastrowid
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, 'chef')
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup')
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer')
|
||||
|
||||
for meal_recipe in meal.recipes:
|
||||
meal_recipe.meal_id = meal.id
|
||||
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
|
||||
async def find_meal_by_id(conn, meal_id: int) -> Meal:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (meal_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
meal = Meal(**{k:v for k,v in zip(Meal.KEYS, row)})
|
||||
|
||||
await load_participants(conn, meal)
|
||||
await load_recipes(conn, meal)
|
||||
await load_extra_ingredients(conn, meal)
|
||||
return meal
|
||||
|
||||
async def find_upcoming_meals_by_date_range(conn, start: datetime, end: datetime) -> AsyncIterator[Meal]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Meal.KEYS)} FROM Meal
|
||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL
|
||||
''', (start, end)) as cursor:
|
||||
async for row in cursor:
|
||||
yield Meal(**{k:v for k,v in zip(Meal.KEYS, row)})
|
||||
|
||||
async def load_participants(conn, meal: Meal) -> None:
|
||||
async with conn.execute(f'''
|
||||
SELECT person_id, role FROM MealParticipant
|
||||
WHERE meal_id = ?
|
||||
''', (meal.id,)) as cursor:
|
||||
async for row in cursor:
|
||||
person = await persons.get_by_id(conn, row[0])
|
||||
if row[1] == 'chef':
|
||||
meal.chefs.append(person)
|
||||
elif row[1] == 'cleanup':
|
||||
meal.cleanup.append(person)
|
||||
elif row[1] == 'consumer':
|
||||
meal.consumers.append(person)
|
||||
else:
|
||||
raise Exception(f'Unknown role: {row[1]}')
|
||||
|
||||
async def load_recipes(conn, meal: Meal) -> None:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
|
||||
FROM Recipe
|
||||
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
|
||||
WHERE MealRecipe.meal_id = ?
|
||||
''', (meal.id,)) as cursor:
|
||||
async for row in cursor:
|
||||
recipe = row_to_recipe(zip(Recipe.KEYS, row[:-1]))
|
||||
await load_recipe_ingredients(conn, recipe)
|
||||
|
||||
meal.recipes.append(MealRecipe(meal_id=meal.id, recipe_id=recipe.id, servings=row[-1], recipe=recipe))
|
||||
|
||||
async def load_extra_ingredients(conn, meal: Meal) -> None:
|
||||
async for ingredient in find_ingredients_by_meal_id(conn, meal.id):
|
||||
meal.extra_ingredients.append(ingredient)
|
||||
|
||||
async def delete_meal(conn, meal_id: int) -> None:
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET deleted_date = ?
|
||||
WHERE id = ?
|
||||
''', (datetime.datetime.now().astimezone().isoformat(), meal_id))
|
||||
|
||||
async def sync_extra_ingredients(conn, meal_id: int, ingredients: List[Ingredient]) -> None:
|
||||
await delete_ingredients_by_meal_id(conn, meal_id)
|
||||
|
||||
for ingredient in ingredients:
|
||||
ingredient.meal_id = meal_id
|
||||
ingredient.recipe_id = None
|
||||
|
||||
await insert_ingredient(conn, ingredient)
|
||||
|
||||
async def sync_meal_recipes(conn, meal_id: int, recipes: List[Recipe]) -> None:
|
||||
await conn.execute('''
|
||||
DELETE FROM MealRecipe
|
||||
WHERE meal_id = ?
|
||||
''', (meal_id,))
|
||||
|
||||
for meal_recipe in recipes:
|
||||
if meal_recipe.meal_id >= 0 and meal_recipe.meal_id != meal_id:
|
||||
raise ValueError('Already associated with another meal')
|
||||
|
||||
meal_recipe.meal_id = meal_id
|
||||
await insert_meal_recipe(conn, meal_recipe)
|
||||
|
||||
async def update_meal(conn, meal: Meal) -> None:
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET suggested_date = ?
|
||||
WHERE id = ?
|
||||
''', (meal.suggested_date.isoformat(), meal.id))
|
||||
|
||||
await sync_meal_participants(conn, meal.id, meal.chefs, 'chef')
|
||||
await sync_meal_participants(conn, meal.id, meal.cleanup, 'cleanup')
|
||||
await sync_meal_participants(conn, meal.id, meal.consumers, 'consumer')
|
||||
|
||||
await sync_extra_ingredients(conn, meal.id, meal.extra_ingredients)
|
||||
await sync_meal_recipes(conn, meal.id, meal.recipes)
|
||||
|
||||
async def mark_consumed(conn, meal: Meal, date: datetime.datetime) -> None:
|
||||
meal.consumed_date = date
|
||||
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET consumed_date = ?
|
||||
WHERE id = ?
|
||||
''', (date.isoformat(), meal.id))
|
||||
|
||||
async def mark_purchased(conn, meal: Meal) -> Meal:
|
||||
meal.purchase_date = datetime.datetime.now().astimezone()
|
||||
|
||||
await conn.execute('''
|
||||
UPDATE Meal
|
||||
SET purchase_date = ?
|
||||
WHERE id = ?
|
||||
''', (meal.purchase_date.isoformat(), meal.id))
|
||||
|
||||
return meal
|
||||
35
meals/models.py
Normal file
35
meals/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
from recipes import Recipe
|
||||
|
||||
|
||||
class MealRecipe(ApiModel):
|
||||
meal_id: int
|
||||
recipe_id: int
|
||||
servings: float
|
||||
|
||||
recipe: Optional[Recipe] = None
|
||||
|
||||
|
||||
class Meal(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
||||
id: int = -1
|
||||
suggested_date: datetime.datetime
|
||||
consumed_date: Optional[datetime.datetime] = None
|
||||
|
||||
chefs: List[Person] = Field(default_factory=list)
|
||||
cleanup: List[Person] = Field(default_factory=list)
|
||||
consumers: List[Person] = Field(default_factory=list)
|
||||
recipes: List[MealRecipe] = Field(default_factory=list)
|
||||
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
|
||||
# Set from shopping list
|
||||
purchase_date: Optional[datetime.datetime] = None
|
||||
358
meals/repository.py
Normal file
358
meals/repository.py
Normal 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
6
meals/roles.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Centralized participant role constants to avoid string duplication/typos
|
||||
ROLE_CHEF = "chef"
|
||||
ROLE_CLEANUP = "cleanup"
|
||||
ROLE_CONSUMER = "consumer"
|
||||
54
meals/service.py
Normal file
54
meals/service.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import List, Set
|
||||
|
||||
from meals.models import Meal
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
def get_duplicates(items: List[Person]) -> Set[str]:
|
||||
"""Return the set of duplicate person names based on repeated ids."""
|
||||
seen: set[int] = set()
|
||||
duplicates: set[str] = set()
|
||||
for item in items:
|
||||
if item.id in seen:
|
||||
duplicates.add(item.name)
|
||||
seen.add(item.id)
|
||||
return duplicates
|
||||
|
||||
|
||||
def validate_meal(meal: Meal) -> str | None:
|
||||
"""Validate a Meal domain model.
|
||||
|
||||
Returns:
|
||||
None if valid, otherwise a human-readable error message.
|
||||
"""
|
||||
if not meal.chefs:
|
||||
return "Meal must have at least one chef"
|
||||
|
||||
if not meal.cleanup:
|
||||
return "Meal must have at least one cleanup person"
|
||||
|
||||
if not meal.consumers:
|
||||
return "Meal must have at least one consumer"
|
||||
|
||||
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
||||
return "Meal must have at least one recipe or ingredient"
|
||||
|
||||
duplicates = get_duplicates(meal.chefs)
|
||||
if duplicates:
|
||||
return f"Duplicate chef: {', '.join(duplicates)}"
|
||||
|
||||
duplicates = get_duplicates(meal.cleanup)
|
||||
if duplicates:
|
||||
return f"Duplicate cleanup person: {', '.join(duplicates)}"
|
||||
|
||||
duplicates = get_duplicates(meal.consumers)
|
||||
if duplicates:
|
||||
return f"Duplicate consumer: {', '.join(duplicates)}"
|
||||
|
||||
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
||||
if zero_servings:
|
||||
return "Recipe servings must be greater than 0"
|
||||
|
||||
return None
|
||||
2863
openapi.json
Normal file
2863
openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,15 @@
|
|||
from persons.db import *
|
||||
from persons.models import Person as Person
|
||||
from persons.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
create as create,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
get_by_id as get_by_id,
|
||||
get_by_ids as get_by_ids,
|
||||
get_by_name as get_by_name,
|
||||
insert_person as insert_person,
|
||||
search_by_name as search_by_name,
|
||||
search_by_name_paged as search_by_name_paged,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, ClassVar, List
|
||||
|
||||
class Person(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'name']
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT UNIQUE
|
||||
);''')
|
||||
|
||||
async def search_by_name(conn, name: str) -> AsyncIterator[Person]:
|
||||
async with conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name LIKE ?
|
||||
''', (f'%{name}%',)) as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
|
||||
async def get_by_name(conn, name: str) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE name = ?
|
||||
''', (name,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
async def get_by_id(conn, id: int) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
WHERE id = ?
|
||||
''', (id,))
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return Person(id=row[0], name=row[1])
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Person]:
|
||||
async with conn.execute('''
|
||||
SELECT id, name
|
||||
FROM Person
|
||||
''') as cursor:
|
||||
async for row in cursor:
|
||||
yield Person(id=row[0], name=row[1])
|
||||
|
||||
async def insert_person(conn, person: Person) -> Person:
|
||||
cursor = await conn.execute('''
|
||||
INSERT INTO Person (name)
|
||||
VALUES (?)
|
||||
''', (person.name,))
|
||||
person.id = cursor.lastrowid
|
||||
return person
|
||||
10
persons/models.py
Normal file
10
persons/models.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from typing import ClassVar, List
|
||||
|
||||
from common import ApiModel
|
||||
|
||||
|
||||
class Person(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
196
persons/repository.py
Normal file
196
persons/repository.py
Normal 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
|
||||
|
|
@ -1,68 +1,78 @@
|
|||
import json
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from products.db import Product, find_product_by_tag, find_product_by_key, insert_product, get_tags, add_tag, find_product_by_id
|
||||
from products import coles, woolworths
|
||||
from products.models import Product
|
||||
from products.repository import (
|
||||
add_tag,
|
||||
find_product_by_id as find_product_by_id,
|
||||
find_product_by_key,
|
||||
find_product_by_tag as find_product_by_tag,
|
||||
get_tags,
|
||||
insert_product,
|
||||
)
|
||||
|
||||
from products import woolworths, coles
|
||||
SCRAPERS = { 'woolworths': woolworths, 'coles': coles }
|
||||
SCRAPERS = {"woolworths": woolworths, "coles": coles}
|
||||
|
||||
from typing import List, Union
|
||||
import re
|
||||
|
||||
def _get_shop_key(link: str) -> Union[str, str]: # (shop_code, product_id)
|
||||
def _get_shop_key(link: str) -> Tuple[Optional[str], Optional[str]]: # (shop_code, product_id)
|
||||
for shop_code, shop_scraper in SCRAPERS.items():
|
||||
product_id = shop_scraper.get_product_id(link)
|
||||
if product_id:
|
||||
return shop_code, product_id
|
||||
return None, None
|
||||
|
||||
|
||||
async def add_missing_tags(conn, product: Product, tags: List[str]):
|
||||
existing_tags = set()
|
||||
async for tag in get_tags(conn, product):
|
||||
existing_tags.add(tag)
|
||||
|
||||
existing_tags = {tag async for tag in get_tags(conn, product)}
|
||||
remaining_tags = set(tags) - existing_tags
|
||||
if not remaining_tags:
|
||||
return False
|
||||
|
||||
|
||||
for tag in remaining_tags:
|
||||
await add_tag(conn, product, tag)
|
||||
|
||||
|
||||
return product
|
||||
|
||||
async def get_or_create(conn, url: str, tags: List[str]) -> Product:
|
||||
|
||||
async def get_or_create(conn, url: str, tags: List[str]) -> Optional[Product]:
|
||||
shop_code, product_id = _get_shop_key(url)
|
||||
if not product_id:
|
||||
if not shop_code or not product_id:
|
||||
return None
|
||||
|
||||
|
||||
existing = await find_product_by_key(conn, shop_code, product_id)
|
||||
if existing:
|
||||
await add_missing_tags(conn, existing, tags)
|
||||
return existing
|
||||
|
||||
product_data, raw_response = await SCRAPERS[shop_code].scrape(product_id)
|
||||
product = Product(
|
||||
id=-1,
|
||||
shop_code=shop_code,
|
||||
product_id=product_id,
|
||||
link=url,
|
||||
**product_data
|
||||
)
|
||||
|
||||
scraper = SCRAPERS[shop_code]
|
||||
product_data, raw_response = await scraper.scrape(product_id)
|
||||
product = Product(id=-1, shop_code=shop_code, product_id=product_id, link=url, **product_data)
|
||||
|
||||
await insert_product(conn, product, raw_response)
|
||||
await add_missing_tags(conn, product, tags)
|
||||
|
||||
return product
|
||||
|
||||
|
||||
def _dump_json_data_to_log(data: dict, product_id: str) -> str:
|
||||
import os, re
|
||||
dir = './data/dump'
|
||||
import os
|
||||
import re
|
||||
|
||||
dir = "./data/dump"
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
prefix = f'product_{product_id}'
|
||||
suffix = '.json'
|
||||
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)]
|
||||
prefix = f"product_{product_id}"
|
||||
suffix = ".json"
|
||||
file_ids = [
|
||||
int(re.findall(r"\d+", f)[0])
|
||||
for f in os.listdir(dir)
|
||||
if re.match(prefix + r"\d+" + suffix, f)
|
||||
]
|
||||
id = max(file_ids) + 1 if file_ids else 0
|
||||
filename = f'{prefix}{id}{suffix}'
|
||||
with open(os.path.join(dir, filename), 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
filename = f"{prefix}{id}{suffix}"
|
||||
full_path = os.path.join(dir, filename)
|
||||
with open(full_path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return full_path
|
||||
|
|
|
|||
|
|
@ -1,46 +1,58 @@
|
|||
import re, httpx
|
||||
from typing import Union
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
|
||||
async def _get_cookies(client):
|
||||
# Make a request to https://www.coles.com.au/ as if we were a normal browser, then return the cookies
|
||||
response = await client.get('https://www.coles.com.au/product/coles-strawberries-250g-5191256', headers=HEADERS, follow_redirects=True)
|
||||
version = re.findall(r'202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d', response.text)
|
||||
response = await client.get(
|
||||
"https://www.coles.com.au/product/coles-strawberries-250g-5191256",
|
||||
headers=HEADERS,
|
||||
follow_redirects=True,
|
||||
)
|
||||
version = re.findall(r"202[4-9][01]\d[0-2]\d.02_v\d.\d\d.\d", response.text)
|
||||
|
||||
if len(version) == 0:
|
||||
raise Exception('Could not find the Coles API')
|
||||
|
||||
raise Exception("Could not find the Coles API")
|
||||
|
||||
return dict(response.cookies), version[0]
|
||||
|
||||
def _get_package_size(size: str) -> Union[int, str]:
|
||||
|
||||
def _get_package_size(size: str) -> Tuple[int, str]:
|
||||
if size:
|
||||
match = re.match(r'(\d+)(.*)', size)
|
||||
match = re.match(r"(\d+)(.*)", size)
|
||||
if match:
|
||||
return int(match.group(1)), match.group(2)
|
||||
|
||||
return 1, 'items'
|
||||
return 1, "items"
|
||||
|
||||
|
||||
def _get_client():
|
||||
return httpx.AsyncClient()
|
||||
|
||||
|
||||
api_details = None
|
||||
async def _request_details(product_id: str) -> dict:
|
||||
|
||||
|
||||
async def _request_details(product_id: str) -> Optional[dict]:
|
||||
global api_details
|
||||
|
||||
async with _get_client() as client:
|
||||
|
|
@ -51,7 +63,9 @@ async def _request_details(product_id: str) -> dict:
|
|||
|
||||
url = _get_product_details_url(api_version, product_id)
|
||||
try:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies)
|
||||
response = await client.get(
|
||||
url, headers=HEADERS, follow_redirects=True, cookies=cookies
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as ne:
|
||||
|
|
@ -60,35 +74,40 @@ async def _request_details(product_id: str) -> dict:
|
|||
|
||||
return None
|
||||
|
||||
|
||||
def _get_product_details_url(version: str, product_id: str) -> str:
|
||||
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/cadbury-favourites-boxed-chocolate-340g-3571992.json?slug=cadbury-favourites-boxed-chocolate-340g-3571992
|
||||
# https://www.coles.com.au/_next/data/20240926.02_v4.18.0/en/product/coles-blueberries-170g-3571948.json?slug=coles-blueberries-170g-3571948
|
||||
#
|
||||
return f'https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json'
|
||||
#
|
||||
return f"https://www.coles.com.au/_next/data/{version}/en/product/{product_id}.json"
|
||||
|
||||
def get_product_id(url: str) -> str:
|
||||
|
||||
def get_product_id(url: str) -> Optional[str]:
|
||||
# https://www.coles.com.au/product/cadbury-favourites-boxed-chocolate-340g-3571992
|
||||
regex = r'https://www.coles.com.au/product/([^/]+)/?.*'
|
||||
regex = r"https://www.coles.com.au/product/([^/]+)/?.*"
|
||||
match = re.match(regex, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def scrape(product_id: str) -> Union[dict, dict]:
|
||||
|
||||
async def scrape(product_id: str) -> Tuple[dict, dict]:
|
||||
raw_data = await _request_details(product_id)
|
||||
|
||||
product = raw_data['pageProps']['product']
|
||||
if raw_data is None:
|
||||
raw_data = {"pageProps": {"product": {"size": "", "images": []}}}
|
||||
|
||||
quantity, unit = _get_package_size(product['size'])
|
||||
product = raw_data["pageProps"]["product"]
|
||||
|
||||
img_prefix = 'https://shop.coles.com.au'
|
||||
images = product['images'][0]
|
||||
quantity, unit = _get_package_size(product["size"])
|
||||
|
||||
img_prefix = "https://shop.coles.com.au"
|
||||
images = product["images"][0]
|
||||
product_data = {
|
||||
'name': product['name'],
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
'img_small': (img_prefix + images['thumb']['path']) if images else None,
|
||||
'img_large': (img_prefix + images['full']['path']) if images else None,
|
||||
"name": product["name"],
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"img_small": (img_prefix + images["thumb"]["path"]) if images else None,
|
||||
"img_large": (img_prefix + images["full"]["path"]) if images else None,
|
||||
}
|
||||
|
||||
return product_data, raw_data
|
||||
return product_data, raw_data
|
||||
|
|
|
|||
42
products/models.py
Normal file
42
products/models.py
Normal 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
|
||||
|
|
@ -1,24 +1,12 @@
|
|||
from typing import AsyncIterator, List, ClassVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
class Product(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'product_id', 'shop_code', 'link', 'name', 'quantity', 'unit', 'img_small', 'img_large']
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ['id']
|
||||
from products.models import Product
|
||||
|
||||
id: int = -1
|
||||
product_id: str
|
||||
shop_code: str
|
||||
link: str
|
||||
name: str
|
||||
quantity: int
|
||||
unit: str
|
||||
img_small: str
|
||||
img_large: str
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Product (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_id TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -30,70 +18,101 @@ async def create(conn):
|
|||
img_small TEXT,
|
||||
img_large TEXT,
|
||||
raw_data TEXT
|
||||
);''')
|
||||
|
||||
await conn.execute('''
|
||||
);"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ProductTag (
|
||||
food_item_id INTEGER,
|
||||
tag TEXT COLLATE NOCASE,
|
||||
PRIMARY KEY (food_item_id, tag),
|
||||
FOREIGN KEY (food_item_id) REFERENCES Product(id)
|
||||
);''')
|
||||
);"""
|
||||
)
|
||||
|
||||
|
||||
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Product.KEYS)} FROM Product
|
||||
WHERE id IN (
|
||||
SELECT food_item_id FROM ProductTag
|
||||
WHERE tag = ?
|
||||
)
|
||||
''', (tag,)) as cursor:
|
||||
""",
|
||||
(tag,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
yield Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
|
||||
async def find_product_by_id(conn, product_id: str) -> Product:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
|
||||
async def find_product_by_id(conn, product_id: int) -> Optional[Product]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Product.KEYS)} FROM Product
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (product_id,)) as cursor:
|
||||
""",
|
||||
(product_id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
return Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
return None
|
||||
|
||||
async def find_product_by_key(conn, shop_code: str, product_id: str) -> Product:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Product.KEYS)} FROM Product
|
||||
|
||||
async def find_product_by_key(conn, shop_code: str, product_id: str) -> Optional[Product]:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
SELECT {",".join(Product.KEYS)} FROM Product
|
||||
WHERE shop_code = ? AND product_id = ?
|
||||
LIMIT 1
|
||||
''', (shop_code, product_id,)) as cursor:
|
||||
""",
|
||||
(
|
||||
shop_code,
|
||||
product_id,
|
||||
),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
return Product(**{k:v for k,v in zip(Product.KEYS, row)})
|
||||
return Product(**{k: v for k, v in zip(Product.KEYS, row)})
|
||||
return None
|
||||
|
||||
|
||||
async def insert_product(conn, product: Product, data: dict):
|
||||
insert_keys = [k for k in Product.KEYS if k not in Product.NON_INSERT_KEYS]
|
||||
insert_values = [getattr(product, k) for k in insert_keys]
|
||||
|
||||
async with conn.execute(f'''
|
||||
INSERT INTO Product ({','.join(insert_keys)}, raw_data)
|
||||
VALUES ({','.join(['?'] * len(insert_keys))}, ?)
|
||||
''', (*insert_values, json.dumps(data))) as cursor:
|
||||
async with conn.execute(
|
||||
f"""
|
||||
INSERT INTO Product ({",".join(insert_keys)}, raw_data)
|
||||
VALUES ({",".join(["?"] * len(insert_keys))}, ?)
|
||||
""",
|
||||
(*insert_values, json.dumps(data)),
|
||||
) as cursor:
|
||||
product.id = cursor.lastrowid
|
||||
|
||||
await conn.commit()
|
||||
|
||||
# Commit handled by outer transaction
|
||||
|
||||
|
||||
async def add_tag(conn, product: Product, tag: str):
|
||||
await conn.execute('''
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ProductTag (food_item_id, tag)
|
||||
VALUES (?, ?)
|
||||
''', (product.id, tag))
|
||||
|
||||
await conn.commit()
|
||||
""",
|
||||
(product.id, tag),
|
||||
)
|
||||
|
||||
# Commit handled by outer transaction
|
||||
|
||||
|
||||
async def get_tags(conn, product: Product) -> AsyncIterator[str]:
|
||||
async with conn.execute('''
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT tag FROM ProductTag
|
||||
WHERE food_item_id = ?
|
||||
''', (product.id,)) as cursor:
|
||||
""",
|
||||
(product.id,),
|
||||
) as cursor:
|
||||
async for row in cursor:
|
||||
yield row[0]
|
||||
|
||||
|
|
@ -1,43 +1,52 @@
|
|||
import re, httpx
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from typing import Union
|
||||
import httpx
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
|
||||
async def _get_cookies(client):
|
||||
# Make a request to https://www.woolworths.com.au/ as if we were a normal browser, then return the cookies
|
||||
response = await client.get('https://www.woolworths.com.au/', headers=HEADERS, follow_redirects=True)
|
||||
response = await client.get(
|
||||
"https://www.woolworths.com.au/", headers=HEADERS, follow_redirects=True
|
||||
)
|
||||
return dict(response.cookies)
|
||||
|
||||
def _get_package_size(data: dict) -> str:
|
||||
size = data['Product']['PackageSize']
|
||||
|
||||
def _get_package_size(data: dict) -> Tuple[int, str]:
|
||||
size = data["Product"]["PackageSize"]
|
||||
if size:
|
||||
match = re.match(r'(\d+)(.*)', size)
|
||||
match = re.match(r"(\d+)(.*)", size)
|
||||
if match:
|
||||
return int(match.group(1)), match.group(2)
|
||||
|
||||
return 1, 'items'
|
||||
return 1, "items"
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient()
|
||||
|
||||
|
||||
cached_cookies = None
|
||||
async def _request_url(url: str) -> dict:
|
||||
|
||||
|
||||
async def _request_url(url: str) -> Optional[dict]:
|
||||
global cached_cookies
|
||||
|
||||
async with _get_client() as client:
|
||||
|
|
@ -46,7 +55,9 @@ async def _request_url(url: str) -> dict:
|
|||
|
||||
cookies = cached_cookies
|
||||
try:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True, cookies=cookies)
|
||||
response = await client.get(
|
||||
url, headers=HEADERS, follow_redirects=True, cookies=cookies
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as ne:
|
||||
|
|
@ -54,27 +65,33 @@ async def _request_url(url: str) -> dict:
|
|||
cached_cookies = None
|
||||
return None
|
||||
|
||||
def _get_product_details_url(product_id) -> str:
|
||||
return f'https://www.woolworths.com.au/apis/ui/product/detail/{product_id}'
|
||||
|
||||
def get_product_id(url: str) -> str:
|
||||
woolies_regex = r'https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*'
|
||||
def _get_product_details_url(product_id: str) -> str:
|
||||
return f"https://www.woolworths.com.au/apis/ui/product/detail/{product_id}"
|
||||
|
||||
|
||||
def get_product_id(url: str) -> Optional[str]:
|
||||
woolies_regex = r"https://www.woolworths.com.au/shop/productdetails/(\d+)/?.*"
|
||||
match = re.match(woolies_regex, url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def scrape(product_id: str) -> Union[dict, dict]:
|
||||
|
||||
async def scrape(product_id: str) -> Tuple[dict, dict]:
|
||||
details_url = _get_product_details_url(product_id)
|
||||
raw_data = await _request_url(details_url)
|
||||
if raw_data is None:
|
||||
# Return a minimal structure; callers treat this as raw payload for logging
|
||||
raw_data = {}
|
||||
quantity, unit = _get_package_size(raw_data)
|
||||
|
||||
product_data = {
|
||||
'name': raw_data['Product']['Name'],
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
'img_small': raw_data['Product']['SmallImageFile'],
|
||||
'img_large': raw_data['Product']['LargeImageFile'],
|
||||
"name": raw_data["Product"]["Name"],
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"img_small": raw_data["Product"]["SmallImageFile"],
|
||||
"img_large": raw_data["Product"]["LargeImageFile"],
|
||||
}
|
||||
|
||||
return product_data, raw_data
|
||||
return product_data, raw_data
|
||||
|
|
|
|||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal 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 = ["."]
|
||||
|
|
@ -1,20 +1,36 @@
|
|||
from persons import Person
|
||||
|
||||
from recipes.db import Recipe, insert_recipe, find_recipe_by_id, get_all, find_recipes_by_name, row_to_recipe, load_recipe_ingredients, hide_recipe
|
||||
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
|
||||
from ingredients import parse_ingredient_from_nlp, match_existing_products
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
async def parse_recipe(conn, created_by: Person, url: str) -> Recipe:
|
||||
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
||||
from persons.models import Person
|
||||
from recipes.models import Recipe as Recipe
|
||||
from recipes.repository import (
|
||||
compute_prev_cursor as compute_prev_cursor,
|
||||
count_all as count_all,
|
||||
count_by_name as count_by_name,
|
||||
find_recipe_by_id as find_recipe_by_id,
|
||||
find_recipes_by_name as find_recipes_by_name,
|
||||
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
||||
get_all as get_all,
|
||||
get_all_paged as get_all_paged,
|
||||
hide_recipe as hide_recipe,
|
||||
insert_recipe as insert_recipe,
|
||||
load_recipe_ingredients as load_recipe_ingredients,
|
||||
row_to_recipe as row_to_recipe,
|
||||
)
|
||||
from recipes.scraping import scrape_recipe_ldata as _scrape_recipe_ldata
|
||||
|
||||
|
||||
async def parse_recipe(conn, created_by: Person, url: str) -> Optional[Recipe]:
|
||||
ldata = await _scrape_recipe_ldata(url)
|
||||
if ldata:
|
||||
return await _get_recipe_from_ldata(conn, url, ldata, created_by)
|
||||
return None
|
||||
|
||||
|
||||
def find_yield(recipe_ldata: dict) -> int:
|
||||
if 'recipeYield' in recipe_ldata:
|
||||
yield_vals = recipe_ldata['recipeYield']
|
||||
if "recipeYield" in recipe_ldata:
|
||||
yield_vals = recipe_ldata["recipeYield"]
|
||||
if not isinstance(yield_vals, list):
|
||||
yield_vals = [yield_vals]
|
||||
|
||||
|
|
@ -25,30 +41,33 @@ def find_yield(recipe_ldata: dict) -> int:
|
|||
pass
|
||||
|
||||
for val in yield_vals:
|
||||
match = re.match(r'(\d+)', val)
|
||||
match = re.match(r"(\d+)", val)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
return 4
|
||||
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> dict:
|
||||
ingredients = [parse_ingredient_from_nlp(ingredient) for ingredient in ldata['recipeIngredient']]
|
||||
|
||||
|
||||
async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person) -> Recipe:
|
||||
ingredients = [
|
||||
parse_ingredient_from_nlp(ingredient) for ingredient in ldata["recipeIngredient"]
|
||||
]
|
||||
ingredients = await match_existing_products(conn, ingredients)
|
||||
name = ldata['name'] if 'name' in ldata else url
|
||||
images = ldata['image'] if 'image' in ldata else []
|
||||
name = ldata["name"] if "name" in ldata else url
|
||||
images = ldata["image"] if "image" in ldata else []
|
||||
serves = find_yield(ldata)
|
||||
|
||||
|
||||
if isinstance(images, list) and len(images) > 0 and isinstance(images[0], dict):
|
||||
images = [image['url'] for image in images]
|
||||
images = [image["url"] for image in images]
|
||||
|
||||
if isinstance(images, dict):
|
||||
images = [images['url']]
|
||||
images = [images["url"]]
|
||||
|
||||
if isinstance(images, str):
|
||||
images = [images]
|
||||
|
||||
return Recipe(
|
||||
id=0,
|
||||
id=-1,
|
||||
name=name,
|
||||
link=url,
|
||||
serves=serves,
|
||||
|
|
@ -56,4 +75,4 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person
|
|||
ingredients=ingredients,
|
||||
created_by=created_by,
|
||||
created_by_id=created_by.id,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
109
recipes/db.py
109
recipes/db.py
|
|
@ -1,109 +0,0 @@
|
|||
import json, datetime
|
||||
|
||||
from persons import Person
|
||||
from ingredients import Ingredient, find_ingredients_by_recipe_id
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import AsyncIterator, List, ClassVar, Tuple, Optional
|
||||
|
||||
class Recipe(BaseModel):
|
||||
KEYS: ClassVar[List[str]] = ['id', 'name', 'link', 'serves', 'image_urls', 'based_on_recipe', 'created_by_id', 'date_created', 'hidden_by_id', 'date_hidden']
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ['id', 'created_date', 'hidden_by_id', 'date_hidden']
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
link: str
|
||||
serves: int
|
||||
image_urls: List[str] = []
|
||||
ingredients: List[Ingredient] = []
|
||||
based_on_recipe: Optional[int] = None
|
||||
|
||||
date_created: datetime.datetime = datetime.datetime.now().astimezone()
|
||||
created_by_id: Optional[int]
|
||||
created_by: Optional[Person] = None
|
||||
|
||||
date_hidden: Optional[datetime.datetime] = None
|
||||
hidden_by_id: Optional[int] = None
|
||||
hidden_by: Optional[Person] = None
|
||||
|
||||
async def create(conn):
|
||||
await conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS Recipe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
serves INTEGER NOT NULL,
|
||||
image_urls TEXT NOT NULL,
|
||||
based_on_recipe INTEGER NULL,
|
||||
|
||||
date_created DATETIME NOT NULL,
|
||||
created_by_id INTEGER NOT NULL,
|
||||
|
||||
date_hidden DATETIME DEFAULT NULL,
|
||||
hidden_by_id INTEGER DEFAULT NULL,
|
||||
|
||||
FOREIGN KEY (based_on_recipe) REFERENCES Recipe(id)
|
||||
FOREIGN KEY (created_by_id) REFERENCES Person(id)
|
||||
FOREIGN KEY (hidden_by_id) REFERENCES Person(id)
|
||||
);''')
|
||||
|
||||
def _as_insert_field(recipe: Recipe, name: str):
|
||||
value = getattr(recipe, name)
|
||||
if name == 'image_urls':
|
||||
return json.dumps(value)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat()
|
||||
|
||||
return value
|
||||
|
||||
async def insert_recipe(conn, recipe: Recipe):
|
||||
fields_to_insert = [k for k in Recipe.KEYS if k not in Recipe.NON_INSERT_KEYS]
|
||||
actual_values = [_as_insert_field(recipe, k) for k in fields_to_insert]
|
||||
|
||||
insert_stmt = f'''
|
||||
INSERT INTO Recipe ({','.join(fields_to_insert)})
|
||||
VALUES ({','.join(['?'] * len(fields_to_insert))})
|
||||
'''
|
||||
|
||||
async with conn.execute(insert_stmt, actual_values) as cursor:
|
||||
recipe.id = cursor.lastrowid
|
||||
|
||||
async def hide_recipe(conn, recipe_id: int, person: Person):
|
||||
await conn.execute('''
|
||||
UPDATE Recipe
|
||||
SET date_hidden = ?, hidden_by_id = ?
|
||||
WHERE id = ?
|
||||
''', (datetime.datetime.now().astimezone().isoformat(), person.id, recipe_id))
|
||||
|
||||
def row_to_recipe(col_tuples: List[Tuple[str, ...]]) -> Recipe:
|
||||
d = {k:v for k,v in col_tuples}
|
||||
d['image_urls'] = json.loads(d['image_urls'])
|
||||
return Recipe(**d)
|
||||
|
||||
async def find_recipe_by_id(conn, recipe_id: int) -> Recipe:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
''', (recipe_id,)) as cursor:
|
||||
async for row in cursor:
|
||||
return row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe
|
||||
WHERE name LIKE ? AND date_hidden IS NULL
|
||||
''', (f'%{name}%',)) as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def get_all(conn) -> AsyncIterator[Recipe]:
|
||||
async with conn.execute(f'''
|
||||
SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
|
||||
''') as cursor:
|
||||
async for row in cursor:
|
||||
yield row_to_recipe(zip(Recipe.KEYS, row))
|
||||
|
||||
async def load_recipe_ingredients(conn, recipe: Recipe) -> None:
|
||||
async for ingredient in find_ingredients_by_recipe_id(conn, recipe.id):
|
||||
recipe.ingredients.append(ingredient)
|
||||
44
recipes/models.py
Normal file
44
recipes/models.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import ApiModel
|
||||
from ingredients import Ingredient
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class Recipe(ApiModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"name",
|
||||
"link",
|
||||
"serves",
|
||||
"image_urls",
|
||||
"based_on_recipe",
|
||||
"created_by_id",
|
||||
"date_created",
|
||||
"hidden_by_id",
|
||||
"date_hidden",
|
||||
]
|
||||
NON_INSERT_KEYS: ClassVar[List[str]] = ["id", "created_date", "hidden_by_id", "date_hidden"]
|
||||
|
||||
id: int = -1
|
||||
name: str
|
||||
link: str
|
||||
serves: int
|
||||
image_urls: List[str] = Field(default_factory=list)
|
||||
ingredients: List[Ingredient] = Field(default_factory=list)
|
||||
based_on_recipe: Optional[int] = None
|
||||
|
||||
date_created: datetime.datetime = Field(
|
||||
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
created_by_id: 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
222
recipes/repository.py
Normal 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
|
||||
|
|
@ -1,35 +1,39 @@
|
|||
from bs4 import BeautifulSoup
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
HEADERS = {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'DNT': '1',
|
||||
'Sec-GPC': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Priority': 'u=1',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"DNT": "1",
|
||||
"Sec-GPC": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Priority": "u=1",
|
||||
"Pragma": "no-cache",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
|
||||
def _is_recipe_ldata(ldata_node):
|
||||
if '@type' in ldata_node:
|
||||
typ = ldata_node['@type']
|
||||
|
||||
def _is_recipe_ldata(ldata_node) -> bool:
|
||||
if "@type" in ldata_node:
|
||||
typ = ldata_node["@type"]
|
||||
if isinstance(typ, list):
|
||||
typ = typ[0]
|
||||
|
||||
if isinstance(typ, str) and typ.lower() == 'recipe':
|
||||
if isinstance(typ, str) and typ.lower() == "recipe":
|
||||
return True
|
||||
|
||||
return None
|
||||
|
||||
async def scrape_recipe_ldata(url: str) -> dict:
|
||||
return False
|
||||
|
||||
|
||||
async def scrape_recipe_ldata(url: str) -> Optional[dict]:
|
||||
# Load the requested URL with headers
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=HEADERS, follow_redirects=True)
|
||||
|
|
@ -37,19 +41,19 @@ async def scrape_recipe_ldata(url: str) -> dict:
|
|||
return None
|
||||
|
||||
# Extract the recipe ld+json data
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
for ld in soup.find_all('script', type='application/ld+json'):
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
for ld in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(ld.text)
|
||||
#_dump_json_data_to_log(data)
|
||||
# _dump_json_data_to_log(data)
|
||||
if _is_recipe_ldata(data):
|
||||
return data
|
||||
|
||||
if '@graph' in data:
|
||||
for item in data['@graph']:
|
||||
|
||||
if "@graph" in data:
|
||||
for item in data["@graph"]:
|
||||
if _is_recipe_ldata(item):
|
||||
return item
|
||||
|
||||
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if _is_recipe_ldata(item):
|
||||
|
|
@ -57,19 +61,31 @@ async def scrape_recipe_ldata(url: str) -> dict:
|
|||
|
||||
except (json.decoder.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
|
||||
return None
|
||||
|
||||
# Fallback return to satisfy static analysis
|
||||
return None
|
||||
|
||||
|
||||
def _dump_json_data_to_log(data: dict) -> str:
|
||||
import os, re
|
||||
dir = './data/dump'
|
||||
import os
|
||||
import re
|
||||
|
||||
dir = "./data/dump"
|
||||
if not os.path.exists(dir):
|
||||
os.makedirs(dir)
|
||||
|
||||
prefix = 'ldata_'
|
||||
suffix = '.json'
|
||||
file_ids = [int(re.findall(r'\d+', f)[0]) for f in os.listdir(dir) if re.match(prefix + r'\d+' + suffix, f)]
|
||||
prefix = "ldata_"
|
||||
suffix = ".json"
|
||||
file_ids = [
|
||||
int(re.findall(r"\d+", f)[0])
|
||||
for f in os.listdir(dir)
|
||||
if re.match(prefix + r"\d+" + suffix, f)
|
||||
]
|
||||
id = max(file_ids) + 1 if file_ids else 0
|
||||
filename = f'{prefix}{id}{suffix}'
|
||||
with open(os.path.join(dir, filename), 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
filename = f"{prefix}{id}{suffix}"
|
||||
full_path = os.path.join(dir, filename)
|
||||
with open(full_path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
return full_path
|
||||
|
|
|
|||
15
scripts/export_openapi.py
Normal file
15
scripts/export_openapi.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure project root is on sys.path
|
||||
ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from main import app # noqa: E402
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open("openapi.json", "w") as f:
|
||||
json.dump(app.openapi(), f, indent=2)
|
||||
print("Wrote openapi.json")
|
||||
27
settings.py
Normal file
27
settings.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
296
shopping/db.py
296
shopping/db.py
|
|
@ -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
48
shopping/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from common import BaseLinkedModel
|
||||
from persons.models import Person
|
||||
|
||||
|
||||
class ShoppingListItem(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = [
|
||||
"id",
|
||||
"ingredient_id",
|
||||
"list_id",
|
||||
"person_id",
|
||||
"meal_id",
|
||||
"recipe_id",
|
||||
"created_date",
|
||||
]
|
||||
id: int = -1
|
||||
list_id: Optional[int] = None
|
||||
|
||||
person_id: int = -1
|
||||
|
||||
ingredient_id: Optional[int] = None
|
||||
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
meal_id: Optional[int] = None
|
||||
|
||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
|
||||
|
||||
class StoreEnum(str, Enum):
|
||||
woolworths = "woolworths"
|
||||
coles = "coles"
|
||||
home = ""
|
||||
|
||||
|
||||
class ShoppingList(BaseLinkedModel):
|
||||
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
|
||||
id: int = -1
|
||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||
store_name: StoreEnum = StoreEnum.home
|
||||
purchased_by_id: int = -1
|
||||
purchased_by: Optional[Person] = None
|
||||
items: List[ShoppingListItem] = Field(default_factory=list)
|
||||
307
shopping/repository.py
Normal file
307
shopping/repository.py
Normal 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)})
|
||||
|
|
@ -2,53 +2,54 @@ import httpx
|
|||
import json
|
||||
import os
|
||||
|
||||
|
||||
class RecordingAsyncClient:
|
||||
def __init__(self, save_dir: str):
|
||||
self.save_dir = save_dir
|
||||
os.makedirs(self.save_dir, exist_ok=True)
|
||||
self.client = None # Will be initialized in __aenter__
|
||||
|
||||
|
||||
async def __aenter__(self):
|
||||
# Initialize the actual AsyncClient when entering the context manager
|
||||
self.client = httpx.AsyncClient()
|
||||
return self
|
||||
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
# Ensure the client is closed when exiting the context manager
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
async def request(self, method: str, url: str, **kwargs):
|
||||
# Send the actual request
|
||||
response = await self.client.request(method, url, **kwargs)
|
||||
|
||||
|
||||
# Record the request and response
|
||||
record = {
|
||||
"request": {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": dict(response.request.headers),
|
||||
"content": response.request.content.decode('utf-8', errors='ignore'),
|
||||
"content": response.request.content.decode("utf-8", errors="ignore"),
|
||||
},
|
||||
"response": {
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"content": response.text,
|
||||
"cookies": dict(response.cookies),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Generate a filename based on the URL and method
|
||||
record_file = os.path.join(self.save_dir, f"{method}_{url.replace('/', '_')}.json")
|
||||
|
||||
|
||||
# Save the record to a file
|
||||
with open(record_file, 'w') as f:
|
||||
with open(record_file, "w") as f:
|
||||
json.dump(record, f, indent=4)
|
||||
|
||||
return response
|
||||
|
||||
async def get(self, url: str, **kwargs):
|
||||
return await self.request("GET", url, **kwargs)
|
||||
|
||||
|
||||
async def post(self, url: str, **kwargs):
|
||||
return await self.request("POST", url, **kwargs)
|
||||
|
||||
|
|
@ -58,18 +59,20 @@ class RecordingAsyncClient:
|
|||
async def delete(self, url: str, **kwargs):
|
||||
return await self.request("DELETE", url, **kwargs)
|
||||
|
||||
|
||||
from unittest.mock import Mock
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
class MockAsyncClient:
|
||||
def __init__(self, load_dir: str):
|
||||
self.load_dir = load_dir
|
||||
|
||||
|
||||
async def __aenter__(self):
|
||||
# No actual client to initialize, just return the instance
|
||||
return self
|
||||
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
# No actual client to close
|
||||
pass
|
||||
|
|
@ -77,43 +80,43 @@ class MockAsyncClient:
|
|||
async def request(self, method: str, url: str, **kwargs):
|
||||
# Generate the filename based on the URL and method
|
||||
record_file = os.path.join(self.load_dir, f"{method}_{url.replace('/', '_')}.json")
|
||||
|
||||
|
||||
if not os.path.exists(record_file):
|
||||
raise FileNotFoundError(f"Recorded response not found for {method} {url}")
|
||||
|
||||
|
||||
# Load the recorded response from the file
|
||||
with open(record_file, 'r') as f:
|
||||
with open(record_file, "r") as f:
|
||||
record = json.load(f)
|
||||
|
||||
|
||||
# Create a mock response object
|
||||
mock_response = Mock()
|
||||
|
||||
|
||||
# Mock the status code
|
||||
mock_response.status_code = record['response']['status_code']
|
||||
|
||||
mock_response.status_code = record["response"]["status_code"]
|
||||
|
||||
# Mock the json method to return the content as a parsed JSON
|
||||
def mock_json():
|
||||
try:
|
||||
return json.loads(record['response']['content'])
|
||||
return json.loads(record["response"]["content"])
|
||||
except json.JSONDecodeError:
|
||||
return record['response']['content']
|
||||
|
||||
return record["response"]["content"]
|
||||
|
||||
mock_response.json = mock_json
|
||||
|
||||
|
||||
# Mock the cookies as a dictionary
|
||||
mock_response.cookies = record['response']['cookies']
|
||||
mock_response.cookies = record["response"]["cookies"]
|
||||
|
||||
# Mock the headers as a dictionary
|
||||
mock_response.headers = record['response']['headers']
|
||||
|
||||
mock_response.headers = record["response"]["headers"]
|
||||
|
||||
# Mock the text attribute
|
||||
mock_response.text = record['response']['content']
|
||||
|
||||
mock_response.text = record["response"]["content"]
|
||||
|
||||
return mock_response
|
||||
|
||||
async def get(self, url: str, **kwargs):
|
||||
return await self.request("GET", url, **kwargs)
|
||||
|
||||
|
||||
async def post(self, url: str, **kwargs):
|
||||
return await self.request("POST", url, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,23 @@
|
|||
import persons
|
||||
|
||||
|
||||
class Persons:
|
||||
jacob = persons.Person(
|
||||
id=1,
|
||||
name='Jacob')
|
||||
|
||||
ryan = persons.Person(
|
||||
id=2,
|
||||
name='Ryan')
|
||||
|
||||
ellie = persons.Person(
|
||||
id=3,
|
||||
name='Ellie')
|
||||
|
||||
chris = persons.Person(
|
||||
id=4,
|
||||
name='Chris')
|
||||
jacob = persons.Person(id=1, name="Jacob")
|
||||
|
||||
ryan = persons.Person(id=2, name="Ryan")
|
||||
|
||||
ellie = persons.Person(id=3, name="Ellie")
|
||||
|
||||
chris = persons.Person(id=4, name="Chris")
|
||||
|
||||
|
||||
import products
|
||||
|
||||
|
||||
class Products:
|
||||
broccoli = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Fresh Broccoli",
|
||||
product_id="134681",
|
||||
quantity=1,
|
||||
|
|
@ -35,7 +30,7 @@ class Products:
|
|||
|
||||
garlic_bread = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="La Famiglia Garlic Bread",
|
||||
product_id="294517",
|
||||
quantity=1,
|
||||
|
|
@ -48,7 +43,7 @@ class Products:
|
|||
|
||||
beans_round = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Beans Round",
|
||||
product_id="134072",
|
||||
quantity=1,
|
||||
|
|
@ -61,7 +56,7 @@ class Products:
|
|||
|
||||
western_star_unsalted_butter_chefs_choice = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Western Star Unsalted Butter Chef's Choice",
|
||||
product_id="712251",
|
||||
quantity=500,
|
||||
|
|
@ -74,7 +69,7 @@ class Products:
|
|||
|
||||
saxa_iodised_table_salt_shaker = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Saxa Iodised Table Salt Shaker",
|
||||
quantity=750,
|
||||
unit="g",
|
||||
|
|
@ -87,7 +82,7 @@ class Products:
|
|||
|
||||
mckenzies_pepper_black_ground = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Mckenzie's Pepper Black Ground",
|
||||
quantity=100,
|
||||
unit="g",
|
||||
|
|
@ -100,7 +95,7 @@ class Products:
|
|||
|
||||
apple = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Apple",
|
||||
product_id="3542",
|
||||
quantity=1,
|
||||
|
|
@ -113,7 +108,7 @@ class Products:
|
|||
|
||||
banana = products.Product(
|
||||
id=0,
|
||||
shop_code='woolworths',
|
||||
shop_code="woolworths",
|
||||
name="Banana",
|
||||
product_id="214",
|
||||
quantity=1,
|
||||
|
|
@ -125,46 +120,57 @@ class Products:
|
|||
)
|
||||
|
||||
_tags = {
|
||||
apple.product_id: ['apple', 'fruit', 'fresh fruit'],
|
||||
banana.product_id: ['banana', 'fruit', 'fresh fruit'],
|
||||
broccoli.product_id: ['broccoli', 'fresh broccoli'],
|
||||
garlic_bread.product_id: ['garlic bread', 'bread', 'garlic', 'frozen garlic bread'],
|
||||
beans_round.product_id: ['beans', 'green beans', 'fresh green beans', 'fresh beans'],
|
||||
western_star_unsalted_butter_chefs_choice.product_id: ['butter', 'unsalted butter', 'salted butter'],
|
||||
saxa_iodised_table_salt_shaker.product_id: ['salt', 'iodised salt', 'kosher salt'],
|
||||
mckenzies_pepper_black_ground.product_id: ['pepper', 'black pepper', 'ground pepper', 'fresh ground pepper'],
|
||||
apple.product_id: ["apple", "fruit", "fresh fruit"],
|
||||
banana.product_id: ["banana", "fruit", "fresh fruit"],
|
||||
broccoli.product_id: ["broccoli", "fresh broccoli"],
|
||||
garlic_bread.product_id: ["garlic bread", "bread", "garlic", "frozen garlic bread"],
|
||||
beans_round.product_id: ["beans", "green beans", "fresh green beans", "fresh beans"],
|
||||
western_star_unsalted_butter_chefs_choice.product_id: [
|
||||
"butter",
|
||||
"unsalted butter",
|
||||
"salted butter",
|
||||
],
|
||||
saxa_iodised_table_salt_shaker.product_id: ["salt", "iodised salt", "kosher salt"],
|
||||
mckenzies_pepper_black_ground.product_id: [
|
||||
"pepper",
|
||||
"black pepper",
|
||||
"ground pepper",
|
||||
"fresh ground pepper",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
import ingredients
|
||||
|
||||
|
||||
class Ingredients:
|
||||
one_apple = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1 Apple',
|
||||
name='Apple',
|
||||
unit='Items',
|
||||
quantity='1',
|
||||
preparation='',
|
||||
line="1 Apple",
|
||||
name="Apple",
|
||||
unit="Items",
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.apple,
|
||||
)
|
||||
|
||||
broccoli_chopped_1kg = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1kg Broccoli, Chopped',
|
||||
name='Broccoli',
|
||||
unit='kg',
|
||||
quantity='1',
|
||||
preparation='Chopped',
|
||||
line="1kg Broccoli, Chopped",
|
||||
name="Broccoli",
|
||||
unit="kg",
|
||||
quantity="1",
|
||||
preparation="Chopped",
|
||||
product=Products.broccoli,
|
||||
)
|
||||
|
||||
garlic_bread_1_loaf = ingredients.Ingredient(
|
||||
id=0,
|
||||
line='1 Loaf Garlic Bread',
|
||||
name='Garlic Bread',
|
||||
unit='Loaf',
|
||||
quantity='1',
|
||||
preparation='',
|
||||
line="1 Loaf Garlic Bread",
|
||||
name="Garlic Bread",
|
||||
unit="Loaf",
|
||||
quantity="1",
|
||||
preparation="",
|
||||
product=Products.garlic_bread,
|
||||
)
|
||||
|
||||
|
|
@ -208,15 +214,19 @@ class Ingredients:
|
|||
product=Products.mckenzies_pepper_black_ground,
|
||||
)
|
||||
|
||||
|
||||
import recipes
|
||||
|
||||
|
||||
class Recipes:
|
||||
broccoli_soup = recipes.Recipe(
|
||||
id=0,
|
||||
name='Broccoli Soup',
|
||||
link='https://www.bbcgoodfood.com/recipes/broccoli-soup',
|
||||
name="Broccoli Soup",
|
||||
link="https://www.bbcgoodfood.com/recipes/broccoli-soup",
|
||||
serves=4,
|
||||
image_urls=['https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg'],
|
||||
image_urls=[
|
||||
"https://www.bbcgoodfood.com/sites/default/files/styles/recipe/public/recipe/recipe-image/2018/10/broccoli-soup.jpg"
|
||||
],
|
||||
ingredients=[Ingredients.broccoli_chopped_1kg],
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
|
@ -226,14 +236,23 @@ class Recipes:
|
|||
name="How to Steam Green Beans",
|
||||
link="https://www.thespruceeats.com/steamed-green-beans-3057051",
|
||||
serves=4,
|
||||
image_urls=["https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"],
|
||||
ingredients=[Ingredients.green_beans,Ingredients.butter,Ingredients.salt,Ingredients.freshly_ground_black_pepper],
|
||||
image_urls=[
|
||||
"https://www.thespruceeats.com/thmb/CLROdq9dlYbjKjlOlA_kmFdunTY=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/steamed-green-beans-3057051-hero-01-b1c4f894da5b4bc0a01cd43886df0100.jpg"
|
||||
],
|
||||
ingredients=[
|
||||
Ingredients.green_beans,
|
||||
Ingredients.butter,
|
||||
Ingredients.salt,
|
||||
Ingredients.freshly_ground_black_pepper,
|
||||
],
|
||||
created_by_id=Persons.jacob.id,
|
||||
)
|
||||
|
||||
from meals import db as meals_db
|
||||
|
||||
from meals import repository as meals_db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Meals:
|
||||
broccoli_soup_for_jacob = meals_db.Meal(
|
||||
id=0,
|
||||
|
|
@ -243,17 +262,22 @@ class Meals:
|
|||
chefs=[Persons.jacob],
|
||||
cleanup=[Persons.ryan],
|
||||
consumers=[Persons.ellie, Persons.chris],
|
||||
recipes=[meals_db.MealRecipe(meal_id = -1, recipe_id = -1, servings = 2, recipe = Recipes.broccoli_soup)],
|
||||
recipes=[
|
||||
meals_db.MealRecipe(meal_id=-1, recipe_id=-1, servings=2, recipe=Recipes.broccoli_soup)
|
||||
],
|
||||
extra_ingredients=[Ingredients.garlic_bread_1_loaf],
|
||||
)
|
||||
|
||||
|
||||
def class_fields(obj):
|
||||
return {k:v for k,v in obj.__dict__.items() if not k.startswith('_')}
|
||||
return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
async def create_persons(conn):
|
||||
for person in class_fields(Persons).values():
|
||||
await persons.insert_person(conn, person)
|
||||
|
||||
|
||||
async def create_test_data(conn):
|
||||
await create_persons(conn)
|
||||
|
||||
|
|
@ -271,6 +295,7 @@ async def create_test_data(conn):
|
|||
for meal in class_fields(Meals).values():
|
||||
await meals_db.insert_meal(conn, meal)
|
||||
|
||||
|
||||
"""
|
||||
import re
|
||||
def to_name(thing):
|
||||
|
|
@ -319,4 +344,4 @@ def to_create_statements(items, type_name, order):
|
|||
s.append(')')
|
||||
s.append('')
|
||||
return '\n'.join(s)
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
76
tests/test_health_and_location_headers.py
Normal file
76
tests/test_health_and_location_headers.py
Normal 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
593
tests/test_ingredients.py
Normal 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()
|
||||
1331
tests/test_main.py
1331
tests/test_main.py
File diff suppressed because it is too large
Load diff
505
tests/test_meals.py
Normal file
505
tests/test_meals.py
Normal 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()
|
||||
|
|
@ -2,17 +2,20 @@ import unittest
|
|||
|
||||
import tests.test_data as test_data
|
||||
|
||||
import products.db as products_db
|
||||
import products.repository as products_db
|
||||
from db import connect, create
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
class TestProductsDb(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
reload_test_data()
|
||||
return await super().asyncSetUp()
|
||||
|
|
@ -20,13 +23,13 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase):
|
|||
async def asyncTearDown(self) -> None:
|
||||
await self.conn.close()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
|
||||
async def testCreateAndFind(self) -> None:
|
||||
product = test_data.Products.broccoli
|
||||
await products_db.insert_product(self.conn, product, {})
|
||||
self.assertIsNotNone(product)
|
||||
self.assertGreater(product.id, 0)
|
||||
|
||||
|
||||
product_by_id = await products_db.find_product_by_id(self.conn, product.id)
|
||||
self.assertIsNotNone(product_by_id)
|
||||
self.assertEqual(product_by_id.id, product.id)
|
||||
|
|
@ -39,31 +42,35 @@ class TestProductsDb(unittest.IsolatedAsyncioTestCase):
|
|||
from . import httpx_mocks
|
||||
from products import woolworths
|
||||
|
||||
|
||||
class TestWoolworths(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
local_path = './tests/sample_files/woolworths'
|
||||
local_path = "./tests/sample_files/woolworths"
|
||||
woolworths._get_client = lambda: httpx_mocks.MockAsyncClient(local_path)
|
||||
# woolworths._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
|
||||
async def test_get_product_id(self) -> None:
|
||||
params = [
|
||||
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', '144607'),
|
||||
('https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas', '133211'),
|
||||
('https://www.coles.com.au/product/coles-strawberries-250g-5191256', None),
|
||||
("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", "144607"),
|
||||
(
|
||||
"https://www.woolworths.com.au/shop/productdetails/133211/cavendish-bananas",
|
||||
"133211",
|
||||
),
|
||||
("https://www.coles.com.au/product/coles-strawberries-250g-5191256", None),
|
||||
]
|
||||
|
||||
for url, id in params:
|
||||
self.assertEqual(woolworths.get_product_id(url), id)
|
||||
|
||||
async def test_get_strawberries(self) -> None:
|
||||
details, raw_data = await woolworths.scrape('144607')
|
||||
details, raw_data = await woolworths.scrape("144607")
|
||||
expected = {
|
||||
'name': 'Strawberries',
|
||||
'quantity': 250,
|
||||
'unit': 'g Punnet',
|
||||
'img_small': 'https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg',
|
||||
'img_large': 'https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg'
|
||||
"name": "Strawberries",
|
||||
"quantity": 250,
|
||||
"unit": "g Punnet",
|
||||
"img_small": "https://cdn0.woolworths.media/content/wowproductimages/small/144607.jpg",
|
||||
"img_large": "https://cdn0.woolworths.media/content/wowproductimages/large/144607.jpg",
|
||||
}
|
||||
|
||||
for key, value in expected.items():
|
||||
|
|
@ -72,33 +79,39 @@ class TestWoolworths(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
from products import coles
|
||||
|
||||
|
||||
class TestColes(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
local_path = './tests/sample_files/coles'
|
||||
local_path = "./tests/sample_files/coles"
|
||||
coles._get_client = lambda: httpx_mocks.MockAsyncClient(local_path)
|
||||
# coles._get_client = lambda: httpx_mocks.RecordingAsyncClient(local_path)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
|
||||
async def test_get_product_id(self) -> None:
|
||||
params = [
|
||||
('https://www.coles.com.au/product/coles-strawberries-250g-5191256', 'coles-strawberries-250g-5191256'),
|
||||
('https://www.coles.com.au/product/coles-blueberries-170g-3571948', 'coles-blueberries-170g-3571948'),
|
||||
('https://www.woolworths.com.au/shop/productdetails/144607/strawberries', None),
|
||||
(
|
||||
"https://www.coles.com.au/product/coles-strawberries-250g-5191256",
|
||||
"coles-strawberries-250g-5191256",
|
||||
),
|
||||
(
|
||||
"https://www.coles.com.au/product/coles-blueberries-170g-3571948",
|
||||
"coles-blueberries-170g-3571948",
|
||||
),
|
||||
("https://www.woolworths.com.au/shop/productdetails/144607/strawberries", None),
|
||||
]
|
||||
|
||||
for url, id in params:
|
||||
self.assertEqual(coles.get_product_id(url), id)
|
||||
|
||||
async def test_get_strawberries(self) -> None:
|
||||
details, raw_data = await coles.scrape('coles-strawberries-250g-5191256')
|
||||
details, raw_data = await coles.scrape("coles-strawberries-250g-5191256")
|
||||
expected = {
|
||||
'name': 'Strawberries',
|
||||
'quantity': 250,
|
||||
'unit': 'g',
|
||||
'img_small': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg',
|
||||
'img_large': 'https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg'
|
||||
"name": "Strawberries",
|
||||
"quantity": 250,
|
||||
"unit": "g",
|
||||
"img_small": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256-th.jpg",
|
||||
"img_large": "https://shop.coles.com.au/wcsstore/Coles-CAS/images/5/1/9/5191256.jpg",
|
||||
}
|
||||
|
||||
for key, value in expected.items():
|
||||
self.assertEqual(details[key], value, msg=key)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,156 +1,453 @@
|
|||
from datetime import datetime, timedelta
|
||||
import importlib
|
||||
import unittest
|
||||
import tests.test_data as test_data
|
||||
import shopping
|
||||
from datetime import datetime
|
||||
|
||||
import ingredients, products
|
||||
import tests.test_data as test_data
|
||||
|
||||
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():
|
||||
global 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):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(':memory:')
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_persons(self.conn)
|
||||
reload_test_data()
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.conn.close()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
async def test_current_shopping_list(self):
|
||||
shopping_list = await shopping.current_shopping_list(self.conn)
|
||||
self.assertEqual(len(shopping_list.requests), 0)
|
||||
self.assertEqual(len(shopping_list.results), 0)
|
||||
def test_validate_request(self):
|
||||
ing = ingredients_repo.Ingredient(
|
||||
id=1,
|
||||
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):
|
||||
import meals, recipes
|
||||
bad_person = ShoppingListItem(ingredient_id=ing.id, person_id=-1)
|
||||
with self.assertRaises(ValueError):
|
||||
shopping.validate_request(bad_person)
|
||||
|
||||
meal = test_data.Meals.broccoli_soup_for_jacob
|
||||
for product in [i.product for i in meal.extra_ingredients] + [i.product for r in meal.recipes for i in r.recipe.ingredients]:
|
||||
if product.id < 0:
|
||||
await products.insert_product(self.conn, product, {})
|
||||
missing_both = ShoppingListItem(person_id=1)
|
||||
with self.assertRaises(ValueError):
|
||||
shopping.validate_request(missing_both)
|
||||
|
||||
for mr in meal.recipes:
|
||||
await recipes.insert_recipe(self.conn, mr.recipe)
|
||||
mr.recipe_id = mr.recipe.id
|
||||
async def test_request_and_purchase_ingredient(self):
|
||||
ing = ingredients_repo.Ingredient(
|
||||
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)
|
||||
|
||||
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.meal_id, meal.id)
|
||||
|
||||
|
||||
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
|
||||
items = [ShoppingListItem(ingredient_id=ing.id, meal_id=meal.id, recipe_id=recipe.id)]
|
||||
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
|
||||
self.conn, items
|
||||
)
|
||||
self.assertIn(meal.id, meals_lookup)
|
||||
self.assertIn(recipe.id, recipes_lookup)
|
||||
self.assertIn(ing.id, ingredients_lookup)
|
||||
|
||||
person = test_data.Persons.jacob
|
||||
|
||||
await products.insert_product(self.conn, first.product, {})
|
||||
await products.insert_product(self.conn, second.product, {})
|
||||
|
||||
shopping_list = await shopping.current_shopping_list(self.conn)
|
||||
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first]):
|
||||
pass
|
||||
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))
|
||||
|
||||
async for _ in shopping.sync_persons_requested_ingredients(self.conn, shopping_list, person, [first, second]):
|
||||
pass
|
||||
async def test_complete_meal_purchase_unrequests_and_marks_purchased(self):
|
||||
# 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)
|
||||
self.assertEqual(len(shopping_list.requests), 2)
|
||||
self.assertEqual(len(shopping_list.results), 0)
|
||||
# Create ingredients for the recipe
|
||||
ingredient1 = ingredients.Ingredient(
|
||||
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,
|
||||
)
|
||||
|
||||
request_by_line = {r.ingredient.line: r for r in shopping_list.requests}
|
||||
self.assertEqual(len(request_by_line), 2)
|
||||
|
||||
for requested_ingredient in [first, second]:
|
||||
request = request_by_line[requested_ingredient.line]
|
||||
self.assertEqual(request.person_id, person.id)
|
||||
self.assertEqual(request.ingredient.line, requested_ingredient.line)
|
||||
await ingredients.insert_ingredient(self.conn, ingredient1)
|
||||
await ingredients.insert_ingredient(self.conn, ingredient2)
|
||||
|
||||
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())
|
||||
# Load the recipe with its ingredients
|
||||
await recipes.load_recipe_ingredients(self.conn, recipe)
|
||||
|
||||
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)
|
||||
# Create and insert a meal
|
||||
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)
|
||||
|
||||
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
|
||||
# Request the meal
|
||||
person = test_data.Persons.jacob
|
||||
|
||||
await products.insert_product(self.conn, ingredient.product, {})
|
||||
|
||||
shopping_list = await shopping.current_shopping_list(self.conn)
|
||||
self.assertIsNone(shopping_list.purchased_date)
|
||||
await shopping.request(self.conn, person, meal=meal)
|
||||
self.assertTrue(await shopping.is_requested(self.conn, meal))
|
||||
|
||||
shopping_list = await shopping.mark_purchased(self.conn)
|
||||
self.assertIsNotNone(shopping_list.purchased_date)
|
||||
self.assertLessEqual(shopping_list.purchased_date - datetime.now().astimezone(), timedelta(seconds=1))
|
||||
# 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)
|
||||
|
||||
new_shopping_list = await shopping.current_shopping_list(self.conn)
|
||||
self.assertNotEqual(shopping_list.id, new_shopping_list.id)
|
||||
self.assertIsNone(new_shopping_list.purchased_date)
|
||||
# Purchase all ingredients from the meal
|
||||
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)
|
||||
|
||||
# Verify meal is no longer requested and is marked as purchased
|
||||
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)
|
||||
|
||||
# After complete purchase, there should be no outstanding/purchased items for that meal
|
||||
(
|
||||
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)
|
||||
|
|
|
|||
61
tests/test_shopping_api.py
Normal file
61
tests/test_shopping_api.py
Normal 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
115
tests/test_v1.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import unittest
|
||||
import importlib
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import tests.test_data as test_data
|
||||
|
||||
from db import connect, create
|
||||
import main
|
||||
|
||||
|
||||
def reload_test_data():
|
||||
global test_data
|
||||
test_data = importlib.reload(test_data)
|
||||
|
||||
|
||||
class TestV1API(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.conn = await connect(":memory:")
|
||||
await create(self.conn)
|
||||
await test_data.create_test_data(self.conn)
|
||||
reload_test_data()
|
||||
|
||||
async def override_get_db():
|
||||
try:
|
||||
yield self.conn
|
||||
finally:
|
||||
pass
|
||||
|
||||
main.app.dependency_overrides[main.get_db] = override_get_db
|
||||
self.client = TestClient(main.app)
|
||||
return await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.conn.close()
|
||||
main.app.dependency_overrides.clear()
|
||||
return await super().asyncTearDown()
|
||||
|
||||
def test_v1_recipes_page_envelope(self):
|
||||
resp = self.client.get("/api/v1/recipes")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, dict)
|
||||
assert "items" in body
|
||||
assert isinstance(body["items"], list)
|
||||
assert len(body["items"]) >= 0
|
||||
|
||||
def test_v1_persons_page_envelope(self):
|
||||
resp = self.client.get("/api/v1/persons")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, dict)
|
||||
assert "items" in body
|
||||
assert isinstance(body["items"], list)
|
||||
|
||||
def test_v1_recipe_not_found_problem(self):
|
||||
resp = self.client.get("/api/v1/recipes/999999")
|
||||
assert resp.status_code == 404
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 404
|
||||
assert "title" in prob
|
||||
assert "type" in prob
|
||||
|
||||
def test_v1_meal_create_no_chefs_problem(self):
|
||||
meal_data = {
|
||||
"id": -1,
|
||||
"suggestedDate": "2024-06-01T18:00:00+00:00",
|
||||
"chefs": [],
|
||||
"cleanup": [{"id": 1, "name": "Ryan"}],
|
||||
"consumers": [{"id": 1, "name": "Ellie"}],
|
||||
"recipes": [],
|
||||
"extraIngredients": [],
|
||||
}
|
||||
resp = self.client.post("/api/v1/meals", json=meal_data)
|
||||
assert resp.status_code == 400
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 400
|
||||
assert "title" in prob
|
||||
|
||||
def test_v1_login_not_found_problem(self):
|
||||
resp = self.client.post("/api/v1/auth/login", json={"username": "nope"})
|
||||
assert resp.status_code == 404
|
||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||
prob = resp.json()
|
||||
assert prob.get("status") == 404
|
||||
assert prob.get("title")
|
||||
|
||||
def test_v1_camel_case_keys(self):
|
||||
# persons endpoint should return camelCase in v1
|
||||
resp = self.client.get("/api/v1/persons")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "items" in body # Page envelope
|
||||
if body["items"]:
|
||||
# pick first person
|
||||
person = body["items"][0]
|
||||
assert "id" in person
|
||||
assert "name" in person
|
||||
|
||||
def test_v1_cursor_edge_cases(self):
|
||||
# invalid cursor should be treated as start
|
||||
resp = self.client.get("/api/v1/recipes?cursor=notanint&limit=1")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "items" in body
|
||||
# end-of-list cursor
|
||||
# get all to compute a large cursor
|
||||
all_resp = self.client.get("/api/v1/recipes?limit=200")
|
||||
items = all_resp.json()["items"]
|
||||
if items:
|
||||
last_id = items[-1]["id"]
|
||||
after_last = self.client.get(f"/api/v1/recipes?cursor={last_id}&limit=200")
|
||||
after_body = after_last.json()
|
||||
assert after_body["items"] == [] or after_body.get("nextCursor") is None
|
||||
113
tighten-api-spec.md
Normal file
113
tighten-api-spec.md
Normal 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.
|
||||
26
units.py
26
units.py
|
|
@ -1,5 +1,3 @@
|
|||
from typing import Union
|
||||
|
||||
class Unit:
|
||||
def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0):
|
||||
self.name = name
|
||||
|
|
@ -15,6 +13,7 @@ class Unit:
|
|||
"""Converts a quantity from the base unit to this unit."""
|
||||
return quantity / self.conversion_to_base
|
||||
|
||||
|
||||
# Define common base units in SI units
|
||||
ITEMS = Unit("Items", ["item", "items"], "count", 1)
|
||||
LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1)
|
||||
|
|
@ -35,12 +34,29 @@ MILLIGRAM = Unit("Milligram", ["milligram", "milligrams", "mg"], "weight", 1)
|
|||
KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000)
|
||||
|
||||
# Big list of units
|
||||
ALL_UNITS = [ITEMS, LITRE, GRAM, CUP, TABLESPOON, TEASPOON, OUNCE, POUND, FLUID_OUNCE, PINT, QUART, GALLON, MILLILITRE, MILLIGRAM, KILOGRAM]
|
||||
ALL_UNITS = [
|
||||
ITEMS,
|
||||
LITRE,
|
||||
GRAM,
|
||||
CUP,
|
||||
TABLESPOON,
|
||||
TEASPOON,
|
||||
OUNCE,
|
||||
POUND,
|
||||
FLUID_OUNCE,
|
||||
PINT,
|
||||
QUART,
|
||||
GALLON,
|
||||
MILLILITRE,
|
||||
MILLIGRAM,
|
||||
KILOGRAM,
|
||||
]
|
||||
|
||||
def get_unit(alias: str) -> Union[Unit, None]:
|
||||
|
||||
def get_unit(alias: str) -> Unit | None:
|
||||
"""Returns the corresponding unit based on alias or abbreviation."""
|
||||
alias_lower = alias.lower()
|
||||
for unit in ALL_UNITS:
|
||||
if alias_lower in unit.symbols or alias_lower == unit.name.lower():
|
||||
return unit
|
||||
return None
|
||||
return None
|
||||
|
|
|
|||
Loading…
Reference in a new issue