Compare commits

...

2 commits

Author SHA1 Message Date
7b6f4e2a3b Squashed commit of the following:
commit 21a17b771743b23ee41d11a90ed8fdc3433468ce
Author: jableader <jacobdunk@gmail.com>
Date:   Mon Oct 20 00:12:02 2025 +1100

    Completed tooling improvements, fixed remaining errors

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

    autoformat

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

    tooling changes

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

    Plan
2025-10-20 00:12:16 +11:00
5c33e9c2a4 Remove unused CI 2025-10-19 21:24:23 +11:00
29 changed files with 703 additions and 184 deletions

11
.env.example Normal file
View file

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

View file

@ -1,30 +0,0 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install black ruff mypy
- name: Lint
run: |
ruff check .
- name: Type check
run: |
mypy .
- name: Test
run: |
python -m unittest -q

View file

@ -1,50 +0,0 @@
name: OpenAPI
on:
push:
branches: [ main, openapi, '**/openapi' ]
pull_request:
branches: [ main ]
jobs:
schema:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Export OpenAPI schema
run: |
python scripts/export_openapi.py
- name: Set up Node for schema tools
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Lint schema with Spectral
run: npx -y @stoplight/spectral-cli lint openapi.json
- name: Compare with baseline if present
run: |
if [ -f openapi-baseline.json ]; then \
npx -y openapi-diff --fail-on-changed --fail-on-incompatible openapi-baseline.json openapi.json; \
else \
echo "No baseline file found. Skipping diff."; \
fi
- name: Upload schema artifact
uses: actions/upload-artifact@v4
with:
name: openapi-schema
path: openapi.json

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

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

186
CONTRIBUTING.md Normal file
View file

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

63
Makefile Normal file
View file

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

View file

@ -1,5 +1,27 @@
Meal planner backend
## Quickstart
First time setup:
```bash
make install
```
Run the development server:
```bash
make dev
```
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.
@ -15,38 +37,79 @@ Meal planner backend
## Getting started
Install packages
### 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
```
Run API (dev)
```
uvicorn main:app --reload
Install pre-commit hooks:
```bash
pip install pre-commit
pre-commit install
```
Run tests
### Running the application
Run API (dev):
```bash
make dev
# or: uvicorn main:app --reload
```
pytest -q
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`:
- black (format)
- ruff (lint)
- ruff (format and lint)
- mypy (type check)
Optional commands (install these locally first):
```
ruff check .
ruff format .
mypy .
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

View file

@ -21,7 +21,11 @@ class LoginBody(ApiModel):
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": {}}}
404: {
"model": ProblemDetails,
"description": "Person not found",
"content": {"application/problem+json": {}},
},
},
)
async def login(
@ -29,7 +33,7 @@ async def login(
data: LoginBody,
response: Response,
conn: aiosqlite.Connection = Depends(get_db),
) -> persons.Person:
) -> persons.Person | Response:
person = await persons.get_by_name(conn, data.username)
if not person:
return error_response(request, 404, "Person not found")

View file

@ -38,8 +38,11 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
async def cookie_person(
user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db)
user_id: Optional[int] = Cookie(None, alias="user_id"),
conn: aiosqlite.Connection = Depends(get_db),
) -> Optional[persons.Person]:
if user_id is None:
return None
return await persons.get_by_id(conn, user_id)

View file

@ -15,7 +15,9 @@ from common import ProblemDetails
router = APIRouter(prefix="/meals", tags=["meals"])
@router.get("/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range")
@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(...),
@ -40,8 +42,19 @@ async def get_upcoming_meals(
return result
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
@router.get(
"/{meal_id}",
response_model=meals.Meal,
operation_id="getMeal",
summary="Get a meal by id",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def get_meal(
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal | Response:
@ -52,10 +65,24 @@ async def get_meal(
return meal
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
@router.post(
"",
response_model=meals.Meal,
operation_id="createMeal",
summary="Create a new meal",
responses={
400: {
"model": ProblemDetails,
"description": "Validation error",
"content": {"application/problem+json": {}},
}
},
)
async def create_meal(
meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db)
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:
@ -66,11 +93,24 @@ async def create_meal(
return meal
@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal",
@router.put(
"/{meal_id}",
response_model=meals.Meal,
operation_id="updateMeal",
summary="Update an existing meal",
responses={
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
})
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:
@ -91,11 +131,24 @@ async def update_meal(
return await get_meal(meal_id, request, conn)
@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed",
@router.post(
"/{meal_id}/consumed",
response_model=meals.Meal,
operation_id="markMealConsumed",
summary="Mark a meal as consumed",
responses={
400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}},
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
})
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,
@ -116,8 +169,19 @@ async def mark_consumed(
return meal
@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal",
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
@router.delete(
"/{meal_id}",
response_model=meals.Meal,
operation_id="deleteMeal",
summary="Delete a meal",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def delete_meal(
meal_id: int,
request: Request,

View file

@ -45,7 +45,9 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
{
"description": "Validation Error",
"content": {
"application/problem+json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
"application/problem+json": {
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
},
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
},
},

View file

@ -23,15 +23,10 @@ router = APIRouter(prefix="/persons", tags=["persons"])
"content": {
"application/json": {
"example": {
"items": [
{
"id": 1,
"name": "Ada Lovelace"
}
],
"items": [{"id": 1, "name": "Ada Lovelace"}],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
"total": 1,
}
}
},

View file

@ -28,7 +28,10 @@ router = APIRouter(prefix="/recipes", tags=["recipes"])
},
)
async def parse_recipe_handler(
url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person)
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:
@ -100,12 +103,12 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
"link": "https://example.com/recipes/1",
"serves": 4,
"imageUrls": [],
"ingredients": []
"ingredients": [],
}
],
"nextCursor": "2",
"prevCursor": "0",
"total": 1
"total": 1,
}
}
},

View file

@ -33,7 +33,9 @@ class CurrentShoppingList(ApiModel):
operation_id="getCurrentShoppingList",
summary="Get the current aggregated shopping list",
)
async def get_current_shopping_list(conn: aiosqlite.Connection = Depends(get_db)) -> CurrentShoppingList:
async def get_current_shopping_list(
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
(
outstanding_requests,
purchased_requests,
@ -76,9 +78,22 @@ class PurchasedShoppingList(ApiModel):
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id",
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | Response:
@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")
@ -94,13 +109,43 @@ async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Conn
)
@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list")
async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> PurchasedShoppingList:
@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: shopping.ShoppingList,
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")
# Attach purchaser to ensure purchased_by_id is set via BaseLinkedModel
shopping_list = shopping.ShoppingList(
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
)
try:
await shopping.purchase(conn, shopping_list)
except ValueError as e:
# Map domain validation errors to a proper Problem Details response
return error_response(request, 400, str(e))
result = PurchasedShoppingList(list=shopping_list)
await shopping.to_lookups(
conn,
@ -112,13 +157,27 @@ async def purchase_ingredients(shopping_list: shopping.ShoppingList, conn: aiosq
return result
@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests")
async def get_my_shopping_list(conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> List[ingredients.Ingredient]:
@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]:
@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
@ -167,6 +226,7 @@ async def request_meal(
response = await shopping.request(conn, person, meal=meal)
return response
class Ok(ApiModel):
ok: bool = True

View file

@ -29,9 +29,7 @@ class BaseLinkedModel(ApiModel):
if id_key in data:
# If the id_key already exists, ensure it matches the value's id
if data[id_key] != value.id:
raise ValueError(
f"ID mismatch for {key}: {data[id_key]} != {value.id}"
)
raise ValueError(f"ID mismatch for {key}: {data[id_key]} != {value.id}")
else:
# If the id_key does not exist, set it to the value's id
data[id_key] = value.id

10
dev-requirements.txt Normal file
View file

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

View file

@ -23,7 +23,9 @@ async def create(conn):
);"""
)
# 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_recipe_id ON Ingredient(recipe_id);"
)
await conn.execute("CREATE INDEX IF NOT EXISTS idx_ingredient_meal_id ON Ingredient(meal_id);")
@ -59,7 +61,7 @@ async def find_ingredient_by_id(conn, ingredient_id: int) -> Optional[Ingredient
async with conn.execute(
f"""
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient
LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE Ingredient.id = ?
""",
@ -81,7 +83,7 @@ async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[I
async with conn.execute(
f"""
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient
LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE recipe_id = ?
""",
@ -96,7 +98,9 @@ async def find_ingredients_by_recipe_id(conn, recipe_id: int) -> AsyncIterator[I
)
async def find_ingredients_by_recipe_ids(conn, recipe_ids: List[int]) -> dict[int, List[Ingredient]]:
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 {}
@ -104,7 +108,7 @@ async def find_ingredients_by_recipe_ids(conn, recipe_ids: List[int]) -> dict[in
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)}
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})
@ -130,7 +134,7 @@ async def find_ingredients_by_meal_id(conn, meal_id: int) -> AsyncIterator[Ingre
async with conn.execute(
f"""
SELECT {','.join(ingredient_cols + product_cols)} FROM Ingredient
SELECT {",".join(ingredient_cols + product_cols)} FROM Ingredient
LEFT JOIN Product ON Ingredient.product_id = Product.id
WHERE meal_id = ?
""",

12
main.py
View file

@ -79,7 +79,9 @@ async def validation_exc_handler(request: Request, exc: Exception):
errors=errors,
)
return JSONResponse(
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
content=body.model_dump(by_alias=True),
status_code=422,
media_type="application/problem+json",
)
@ -97,7 +99,9 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
errors=errors,
)
return JSONResponse(
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
content=body.model_dump(by_alias=True),
status_code=422,
media_type="application/problem+json",
)
@ -110,7 +114,9 @@ async def healthz() -> HealthStatus:
def create_app() -> FastAPI:
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
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)

View file

@ -39,7 +39,9 @@ async def create(conn):
);"""
)
# Useful indexes
await conn.execute("CREATE INDEX IF NOT EXISTS idx_meal_participants_meal_role ON MealParticipant(meal_id, role);")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_meal_participants_meal_role ON MealParticipant(meal_id, role);"
)
await conn.execute(
"""
@ -53,7 +55,9 @@ async def create(conn):
)
# 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);")
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):
@ -123,7 +127,7 @@ async def insert_meal(conn, meal: Meal):
async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
async with conn.execute(
f"""
SELECT {','.join(Meal.KEYS)} FROM Meal
SELECT {",".join(Meal.KEYS)} FROM Meal
WHERE id = ?
LIMIT 1
""",
@ -144,7 +148,7 @@ async def find_upcoming_meals_by_date_range(
) -> AsyncIterator[Meal]:
async with conn.execute(
f"""
SELECT {','.join(Meal.KEYS)} FROM Meal
SELECT {",".join(Meal.KEYS)} FROM Meal
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL
""",
(start, end),
@ -249,7 +253,7 @@ async def bulk_load_participants(conn, meals: List[Meal]) -> None:
async def load_recipes(conn, meal: Meal) -> None:
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
SELECT {",".join(Recipe.KEYS)}, MealRecipe.servings as requested_servings
FROM Recipe
JOIN MealRecipe ON MealRecipe.recipe_id = Recipe.id
WHERE MealRecipe.meal_id = ?

View file

@ -875,6 +875,20 @@
}
}
},
"400": {
"$ref": "#/components/responses/Problem400"
},
"401": {
"description": "Unauthorized (invalid or unknown user)",
"content": {
"application/problem+json": {},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {

View file

@ -148,7 +148,9 @@ async def count_by_name(conn, name: str) -> int:
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]:
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
@ -161,6 +163,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
LIMIT ?
"""
from typing import Any
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
else:
query = """
@ -171,6 +174,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
LIMIT ?
"""
from typing import Any
params = (first_id, limit)
async with conn.execute(query, params) as c:

View file

@ -35,7 +35,7 @@ async def create(conn):
async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
async with conn.execute(
f"""
SELECT {','.join(Product.KEYS)} FROM Product
SELECT {",".join(Product.KEYS)} FROM Product
WHERE id IN (
SELECT food_item_id FROM ProductTag
WHERE tag = ?
@ -50,7 +50,7 @@ async def find_product_by_tag(conn, tag: str) -> AsyncIterator[Product]:
async def find_product_by_id(conn, product_id: int) -> Optional[Product]:
async with conn.execute(
f"""
SELECT {','.join(Product.KEYS)} FROM Product
SELECT {",".join(Product.KEYS)} FROM Product
WHERE id = ?
LIMIT 1
""",
@ -64,7 +64,7 @@ async def find_product_by_id(conn, product_id: int) -> Optional[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
SELECT {",".join(Product.KEYS)} FROM Product
WHERE shop_code = ? AND product_id = ?
LIMIT 1
""",
@ -84,8 +84,8 @@ async def insert_product(conn, product: Product, data: dict):
async with conn.execute(
f"""
INSERT INTO Product ({','.join(insert_keys)}, raw_data)
VALUES ({','.join(['?'] * len(insert_keys))}, ?)
INSERT INTO Product ({",".join(insert_keys)}, raw_data)
VALUES ({",".join(["?"] * len(insert_keys))}, ?)
""",
(*insert_values, json.dumps(data)),
) as cursor:

View file

@ -1,8 +1,3 @@
[tool.black]
line-length = 100
target-version = ["py311"]
include = "\\.pyi?$"
[tool.ruff]
line-length = 100
target-version = "py311"

View file

@ -30,8 +30,12 @@ async def create(conn):
);"""
)
# 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);")
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):
@ -49,8 +53,8 @@ async def insert_recipe(conn, recipe: Recipe):
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))})
INSERT INTO Recipe ({",".join(fields_to_insert)})
VALUES ({",".join(["?"] * len(fields_to_insert))})
"""
async with conn.execute(insert_stmt, actual_values) as cursor:
@ -80,7 +84,7 @@ def row_to_recipe(col_tuples: Iterable[Tuple[str, object]]) -> Recipe:
async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)} FROM Recipe
SELECT {",".join(Recipe.KEYS)} FROM Recipe
WHERE id = ?
LIMIT 1
""",
@ -94,7 +98,7 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)} FROM Recipe
SELECT {",".join(Recipe.KEYS)} FROM Recipe
WHERE name LIKE ? AND date_hidden IS NULL
""",
(f"%{name}%",),
@ -106,7 +110,7 @@ async def find_recipes_by_name(conn, name: str) -> AsyncIterator[Recipe]:
async def get_all(conn) -> AsyncIterator[Recipe]:
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
SELECT {",".join(Recipe.KEYS)} FROM Recipe WHERE date_hidden IS NULL
"""
) as cursor:
async for row in cursor:
@ -123,7 +127,7 @@ async def get_all_paged(conn, after_id: Optional[int], limit: int) -> AsyncItera
after = after_id if after_id is not None else -1
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)}
SELECT {",".join(Recipe.KEYS)}
FROM Recipe
WHERE date_hidden IS NULL AND id > ?
ORDER BY id
@ -141,7 +145,7 @@ async def find_recipes_by_name_paged(
after = after_id if after_id is not None else -1
async with conn.execute(
f"""
SELECT {','.join(Recipe.KEYS)}
SELECT {",".join(Recipe.KEYS)}
FROM Recipe
WHERE name LIKE ? AND date_hidden IS NULL AND id > ?
ORDER BY id
@ -172,6 +176,7 @@ async def compute_prev_cursor(
LIMIT ?
"""
from typing import Any
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
else:
query = """
@ -182,6 +187,7 @@ async def compute_prev_cursor(
LIMIT ?
"""
from typing import Any
params = (first_id, limit)
async with conn.execute(query, params) as c:

View file

@ -4,6 +4,7 @@ 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

View file

@ -33,8 +33,12 @@ async def create(conn):
);"""
)
# 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_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;"
)
@ -248,7 +252,7 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
select = f"""
SELECT {','.join(request_cols)}
SELECT {",".join(request_cols)}
FROM ShoppingListItem
"""
@ -270,7 +274,7 @@ 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
SELECT {",".join(ShoppingList.KEYS)} FROM ShoppingList
WHERE id = ?
LIMIT 1
""",
@ -293,9 +297,9 @@ async def get_purchased_ingredients(conn, meal_ids: List[int]) -> AsyncIterator[
async with conn.execute(
f"""
SELECT {','.join(ShoppingListItem.KEYS)}
SELECT {",".join(ShoppingListItem.KEYS)}
FROM ShoppingListItem
WHERE meal_id IN ({','.join(['?'] * len(meal_ids))}) AND list_id IS NOT NULL
WHERE meal_id IN ({",".join(["?"] * len(meal_ids))}) AND list_id IS NOT NULL
""",
meal_ids,
) as cursor:

View file

@ -27,9 +27,11 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
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()

View file

@ -190,9 +190,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
}
response = self.client.post("/api/v1/meals", json=meal_data)
self.assertEqual(response.status_code, 400)
self.assertIn(
"Meal must have at least one recipe or ingredient", response.json()["title"]
)
self.assertIn("Meal must have at least one recipe or ingredient", response.json()["title"])
def test_create_meal_invalid_duplicate_chefs(self):
"""Test creating a meal with duplicate chefs (should fail validation)"""
@ -275,6 +273,7 @@ class TestMainAPI(unittest.IsolatedAsyncioTestCase):
def test_delete_meal_not_found(self):
"""Test deleting a meal that doesn't exist"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -569,6 +568,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_create_recipe_valid(self):
"""Test creating a valid recipe - currently fails due to auth dependency issues"""
# The authentication dependency injection isn't working properly in tests
# This would require a more complex setup to properly mock FastAPI dependencies
async def override_cookie_person():
@ -605,6 +605,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_create_recipe_no_ingredients(self):
"""Test creating a recipe without ingredients - auth dependency issues prevent proper testing"""
# The authentication dependency injection isn't working properly in tests
async def override_cookie_person():
return test_data.Persons.jacob
@ -631,6 +632,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_mark_consumed_invalid_timezone(self):
"""Test marking meal as consumed with invalid timezone"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -664,6 +666,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_request_meal_not_found(self):
"""Test requesting a meal that doesn't exist"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -682,6 +685,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_unrequest_meal_not_found(self):
"""Test unrequesting a meal that doesn't exist"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -699,6 +703,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_get_my_shopping_list_empty(self):
"""Test getting empty shopping list when no items are requested"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -755,6 +760,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_sync_my_shopping_list_empty_to_empty(self):
"""Test syncing empty list with empty current state"""
# Override the cookie_person dependency to return a test user
async def override_cookie_person():
return test_data.Persons.jacob
@ -1102,6 +1108,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
def test_sync_my_shopping_list_invalid_json(self):
"""Test sync_my_shopping_list with invalid JSON data"""
# Override the cookie_person dependency
async def override_cookie_person():
return test_data.Persons.jacob
@ -1115,7 +1122,7 @@ class TestMainWithAuthentication(unittest.IsolatedAsyncioTestCase):
json=[
{
"id": "not_a_number", # Invalid ID type
"name": "Test Ingredient"
"name": "Test Ingredient",
# Missing required fields
}
],

View file

@ -0,0 +1,61 @@
import importlib
import unittest
from fastapi.testclient import TestClient
from db import connect, create
import main
import tests.test_data as test_data
def reload_test_data():
global test_data
test_data = importlib.reload(test_data)
class TestShoppingAPI(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.conn = await connect(":memory:")
await create(self.conn)
await test_data.create_test_data(self.conn)
reload_test_data()
async def override_get_db():
try:
yield self.conn
finally:
pass
main.app.dependency_overrides[main.get_db] = override_get_db
self.client = TestClient(main.app)
return await super().asyncSetUp()
async def asyncTearDown(self) -> None:
await self.conn.close()
main.app.dependency_overrides.clear()
return await super().asyncTearDown()
def test_purchase_unauthorized_without_cookie(self):
# Do NOT override cookie_person; no cookie provided => 401
body = {"storeName": "woolworths", "items": []}
resp = self.client.post("/api/v1/shopping", json=body)
assert resp.status_code == 401
assert "application/problem+json" in resp.headers.get("content-type", "")
prob = resp.json()
assert prob.get("status") == 401
assert prob.get("title")
def test_purchase_validation_error_returns_problem(self):
# Override cookie_person to simulate authenticated user
async def override_cookie_person():
return test_data.Persons.jacob
main.app.dependency_overrides[main.cookie_person] = override_cookie_person
# Empty items triggers domain validation error => 400 with Problem Details
body = {"storeName": "woolworths", "items": []}
resp = self.client.post("/api/v1/shopping", json=body)
assert resp.status_code == 400
assert "application/problem+json" in resp.headers.get("content-type", "")
prob = resp.json()
assert prob.get("status") == 400
assert "items" in prob.get("title", "").lower() or prob.get("title")