Compare commits
2 commits
5be4e89c4e
...
7b6f4e2a3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6f4e2a3b | |||
| 5c33e9c2a4 |
29 changed files with 703 additions and 184 deletions
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
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
50
.github/workflows/openapi.yml
vendored
50
.github/workflows/openapi.yml
vendored
|
|
@ -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
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 {} +
|
||||
89
README.md
89
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
96
api/meals.py
96
api/meals.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
|
|
@ -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
12
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = ?
|
||||
|
|
|
|||
14
openapi.json
14
openapi.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py311"]
|
||||
include = "\\.pyi?$"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
],
|
||||
|
|
|
|||
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")
|
||||
Loading…
Reference in a new issue