Squashed commit of the following:
commit fcd005b8624023547f28b7b28e59e6099bcfc7d4
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 20:24:07 2025 +1100
Openapi tightening
commit f93bd8f641d561052c7bd075bae321b4ff3b676d
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 19:03:52 2025 +1100
Removed refactor strategy doc
commit 0c5a61092f522be0c47cbbe86917c8a7e4e2d339
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 17:48:33 2025 +1100
mypy & ruff checks
commit 23d66d6b18984127e17c73c3063f6120385935e9
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 16:49:35 2025 +1100
Final removal of db.py files
commit f454aed1ca9783cc558cc203f29a7fe31b62a975
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:42:31 2025 +1100
Finalise restructure, remove db.py files
commit 7187f6dd89489521538791c6bdebb426514beb99
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:34:54 2025 +1100
commit 6fea227ae20d32b8eb1e7a4885006a620fcc7bb1
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:32:53 2025 +1100
commit 27415e7e02d89195ad514cb017a9dbbf84d7a5e4
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:31:10 2025 +1100
commit b773428033d855f9ad82005602e049c1a2e3c585
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:28:58 2025 +1100
commit 116592c95278d995f4c516e87f2cea43cf5b7735
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:25:21 2025 +1100
commit 03ec565faea088971968ee2f9bb83e2de16b21f3
Author: jableader <jacobdunk@gmail.com>
Date: Sun Oct 19 15:21:29 2025 +1100
Plan
This commit is contained in:
parent
50a1fcaeee
commit
5be4e89c4e
44 changed files with 797 additions and 2015 deletions
24
.gitignore
vendored
24
.gitignore
vendored
|
|
@ -1,3 +1,27 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Caches
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# SQLite databases & dumps
|
||||||
|
*.sqlite
|
||||||
|
/data/dump/
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage*
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -3,14 +3,14 @@ Meal planner backend
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `main.py`: FastAPI app with all HTTP endpoints.
|
- `main.py`: FastAPI app with all HTTP endpoints.
|
||||||
- `db.py`: aiosqlite connection + schema bootstrap across subpackages.
|
- `db.py`: aiosqlite connection + schema bootstrap across subpackages (calls each feature's `repository.create`).
|
||||||
- Domain packages with models and persistence:
|
- Domain packages with models and persistence:
|
||||||
- `products/` (db, scrapers for Woolworths/Coles)
|
- `products/` (models.py, repository.py, scrapers for Woolworths/Coles)
|
||||||
- `ingredients/`
|
- `ingredients/` (models.py, repository.py)
|
||||||
- `recipes/` (db, scraping)
|
- `recipes/` (models.py, repository.py, scraping.py)
|
||||||
- `meals/`
|
- `meals/` (models.py, repository.py, service.py)
|
||||||
- `persons/`
|
- `persons/` (models.py, repository.py)
|
||||||
- `shopping/`
|
- `shopping/` (models.py, repository.py)
|
||||||
- `tests/`: unit and API tests with sample HTTP fixtures.
|
- `tests/`: unit and API tests with sample HTTP fixtures.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
@ -27,7 +27,7 @@ uvicorn main:app --reload
|
||||||
|
|
||||||
Run tests
|
Run tests
|
||||||
```
|
```
|
||||||
python -m unittest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
|
|
@ -40,7 +40,7 @@ This repo includes baseline configs in `pyproject.toml`:
|
||||||
Optional commands (install these locally first):
|
Optional commands (install these locally first):
|
||||||
```
|
```
|
||||||
ruff check .
|
ruff check .
|
||||||
black .
|
ruff format .
|
||||||
mypy .
|
mypy .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
23
api/auth.py
23
api/auth.py
|
|
@ -1,13 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
|
|
||||||
import persons
|
import persons
|
||||||
from common import ProblemDetails, ApiModel
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from api.deps import get_db, cookie_person, error_response
|
from common import ApiModel, ProblemDetails
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -18,25 +16,32 @@ class LoginBody(ApiModel):
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/login",
|
"/login",
|
||||||
response_model=None,
|
response_model=persons.Person,
|
||||||
operation_id="login",
|
operation_id="login",
|
||||||
summary="Login and set user_id cookie",
|
summary="Login and set user_id cookie",
|
||||||
responses={
|
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(request: Request, data: LoginBody, conn: aiosqlite.Connection = Depends(get_db)) -> persons.Person | JSONResponse:
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
data: LoginBody,
|
||||||
|
response: Response,
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
) -> persons.Person:
|
||||||
person = await persons.get_by_name(conn, data.username)
|
person = await persons.get_by_name(conn, data.username)
|
||||||
if not person:
|
if not person:
|
||||||
return error_response(request, 404, "Person not found")
|
return error_response(request, 404, "Person not found")
|
||||||
|
|
||||||
response = JSONResponse(content=jsonable_encoder(person))
|
# When using response_model, return the Pydantic model and set the cookie on the Response
|
||||||
response.set_cookie(key="user_id", value=str(person.id))
|
response.set_cookie(key="user_id", value=str(person.id))
|
||||||
return response
|
return person
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/refresh",
|
"/refresh",
|
||||||
|
response_model=persons.Person,
|
||||||
operation_id="refresh",
|
operation_id="refresh",
|
||||||
summary="Refresh current user from cookie",
|
summary="Refresh current user from cookie",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Optional, AsyncGenerator
|
from typing import Annotated, AsyncGenerator, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import Cookie, Depends, Request
|
from fastapi import Cookie, Depends, Request
|
||||||
|
|
|
||||||
19
api/meals.py
19
api/meals.py
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
import datetime
|
import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
@ -9,11 +9,8 @@ from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
import meals
|
import meals
|
||||||
import persons
|
import persons
|
||||||
import shopping
|
import shopping
|
||||||
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from common import ProblemDetails
|
from common import ProblemDetails
|
||||||
from api.deps import get_db, cookie_person, error_response
|
|
||||||
from common import ApiModel, Field
|
|
||||||
import datetime
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/meals", tags=["meals"])
|
router = APIRouter(prefix="/meals", tags=["meals"])
|
||||||
|
|
||||||
|
|
@ -47,7 +44,7 @@ async def get_upcoming_meals(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||||
async def get_meal(
|
async def get_meal(
|
||||||
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal:
|
) -> meals.Meal | Response:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
@ -59,7 +56,7 @@ async def get_meal(
|
||||||
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
||||||
async def create_meal(
|
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:
|
) -> meals.Meal | Response:
|
||||||
validation_response = validate_meal(meal, request)
|
validation_response = validate_meal(meal, request)
|
||||||
if validation_response:
|
if validation_response:
|
||||||
return validation_response
|
return validation_response
|
||||||
|
|
@ -76,7 +73,7 @@ async def create_meal(
|
||||||
})
|
})
|
||||||
async def update_meal(
|
async def update_meal(
|
||||||
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal:
|
) -> meals.Meal | Response:
|
||||||
if meal.id != meal_id:
|
if meal.id != meal_id:
|
||||||
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
||||||
|
|
||||||
|
|
@ -105,7 +102,7 @@ async def mark_consumed(
|
||||||
consumed_date: Optional[datetime.datetime] = None,
|
consumed_date: Optional[datetime.datetime] = None,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
) -> meals.Meal:
|
) -> meals.Meal | Response:
|
||||||
if consumed_date and not consumed_date.tzinfo:
|
if consumed_date and not consumed_date.tzinfo:
|
||||||
return error_response(request, 400, "Consumed date must include timezone")
|
return error_response(request, 400, "Consumed date must include timezone")
|
||||||
|
|
||||||
|
|
@ -126,7 +123,7 @@ async def delete_meal(
|
||||||
request: Request,
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
) -> meals.Meal:
|
) -> meals.Meal | Response:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
@ -136,7 +133,7 @@ async def delete_meal(
|
||||||
return meal
|
return meal
|
||||||
|
|
||||||
|
|
||||||
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Request]:
|
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Response]:
|
||||||
"""HTTP-friendly wrapper that maps service validation to ProblemDetails."""
|
"""HTTP-friendly wrapper that maps service validation to ProblemDetails."""
|
||||||
msg = meals.validate_meal(meal)
|
msg = meals.validate_meal(meal)
|
||||||
if msg:
|
if msg:
|
||||||
|
|
|
||||||
109
api/openapi.py
Normal file
109
api/openapi.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
|
||||||
|
def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||||
|
"""Augment FastAPI's OpenAPI spec with RFC7807 responses and cookie auth.
|
||||||
|
|
||||||
|
This mutates the app's OpenAPI generation in-place while delegating to the
|
||||||
|
original generator for the base schema.
|
||||||
|
"""
|
||||||
|
original_openapi = app.openapi
|
||||||
|
|
||||||
|
def custom_openapi() -> dict[str, Any]:
|
||||||
|
spec = original_openapi()
|
||||||
|
components = spec.setdefault("components", {})
|
||||||
|
responses = components.setdefault("responses", {})
|
||||||
|
security_schemes = components.setdefault("securitySchemes", {})
|
||||||
|
|
||||||
|
# Standard ProblemDetails responses
|
||||||
|
responses.setdefault(
|
||||||
|
"Problem400",
|
||||||
|
{
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {},
|
||||||
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
responses.setdefault(
|
||||||
|
"Problem404",
|
||||||
|
{
|
||||||
|
"description": "Not Found",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {},
|
||||||
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
responses.setdefault(
|
||||||
|
"Problem422",
|
||||||
|
{
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||||
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cookie-based auth for documentation (does not enforce at runtime)
|
||||||
|
security_schemes.setdefault(
|
||||||
|
"cookieAuth",
|
||||||
|
{
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "cookie",
|
||||||
|
"name": "user_id",
|
||||||
|
"description": "Authentication via user_id cookie (session-style).",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize v1 responses and mark cookie security for known endpoints
|
||||||
|
paths = spec.get("paths", {})
|
||||||
|
protected_ops: set[str] = {
|
||||||
|
"parseRecipe",
|
||||||
|
"createRecipe",
|
||||||
|
"deleteRecipe",
|
||||||
|
"markMealConsumed",
|
||||||
|
"deleteMeal",
|
||||||
|
"purchaseIngredients",
|
||||||
|
"getMyShoppingList",
|
||||||
|
"syncMyShoppingList",
|
||||||
|
"requestMeal",
|
||||||
|
"unrequestMeal",
|
||||||
|
"refresh",
|
||||||
|
}
|
||||||
|
for path, ops in paths.items():
|
||||||
|
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
||||||
|
continue
|
||||||
|
if not isinstance(ops, dict):
|
||||||
|
continue
|
||||||
|
for _method, op in ops.items():
|
||||||
|
if not isinstance(op, dict):
|
||||||
|
continue
|
||||||
|
resp = op.get("responses")
|
||||||
|
if not isinstance(resp, dict):
|
||||||
|
continue
|
||||||
|
if "400" in resp:
|
||||||
|
resp["400"] = {"$ref": "#/components/responses/Problem400"}
|
||||||
|
if "404" in resp:
|
||||||
|
resp["404"] = {"$ref": "#/components/responses/Problem404"}
|
||||||
|
if "422" not in resp:
|
||||||
|
resp["422"] = {"$ref": "#/components/responses/Problem422"}
|
||||||
|
|
||||||
|
op_id = op.get("operationId")
|
||||||
|
if isinstance(op_id, str) and op_id in protected_ops:
|
||||||
|
security = op.setdefault("security", [])
|
||||||
|
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
||||||
|
security.append({"cookieAuth": []})
|
||||||
|
|
||||||
|
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
|
||||||
|
|
||||||
|
return spec
|
||||||
|
|
||||||
|
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
||||||
|
app.openapi = custom_openapi # type: ignore[method-assign]
|
||||||
|
|
@ -6,8 +6,8 @@ import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Response
|
from fastapi import APIRouter, Depends, Query, Response
|
||||||
|
|
||||||
import persons
|
import persons
|
||||||
|
from api.deps import get_db
|
||||||
from common import Page
|
from common import Page
|
||||||
from main import get_db
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
router = APIRouter(prefix="/persons", tags=["persons"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ from fastapi import APIRouter, Depends, Response
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
import products
|
import products
|
||||||
from common import ApiModel
|
|
||||||
from api.deps import get_db
|
from api.deps import get_db
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
import ingredients
|
import ingredients
|
||||||
import recipes
|
|
||||||
import persons
|
import persons
|
||||||
|
import recipes
|
||||||
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from common import Page, ProblemDetails
|
from common import Page, ProblemDetails
|
||||||
from api.deps import get_db, cookie_person, error_response
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||||
)
|
)
|
||||||
async def parse_recipe_handler(
|
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 | ProblemDetails:
|
) -> recipes.Recipe | Response:
|
||||||
parsed = await recipes.parse_recipe(conn, person, url)
|
parsed = await recipes.parse_recipe(conn, person, url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return error_response(request, 400, "Recipe not found")
|
return error_response(request, 400, "Recipe not found")
|
||||||
|
|
@ -179,7 +179,7 @@ async def list_recipes(
|
||||||
)
|
)
|
||||||
async def get_recipe(
|
async def get_recipe(
|
||||||
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | Response:
|
||||||
r = await load_full_recipe(conn, recipe_id)
|
r = await load_full_recipe(conn, recipe_id)
|
||||||
if not r:
|
if not r:
|
||||||
return error_response(request, 404, "Recipe not found")
|
return error_response(request, 404, "Recipe not found")
|
||||||
|
|
@ -206,7 +206,7 @@ async def create_recipe(
|
||||||
response: Response,
|
response: Response,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | Response:
|
||||||
if not recipe.ingredients:
|
if not recipe.ingredients:
|
||||||
return error_response(request, 400, "Recipe must have at least one ingredient")
|
return error_response(request, 400, "Recipe must have at least one ingredient")
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ async def delete_recipe(
|
||||||
request: Request,
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | Response:
|
||||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
return error_response(request, 404, "Recipe not found")
|
return error_response(request, 404, "Recipe not found")
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,18 @@ from __future__ import annotations
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
import ingredients
|
import ingredients
|
||||||
import meals
|
import meals
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
import shopping
|
import shopping
|
||||||
from common import ProblemDetails
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from api.deps import get_db, cookie_person, error_response
|
from common import ApiModel, Field, ProblemDetails
|
||||||
|
|
||||||
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||||
|
|
||||||
from common import ApiModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
outstanding_items: List[shopping.ShoppingListItem]
|
outstanding_items: List[shopping.ShoppingListItem]
|
||||||
|
|
@ -80,7 +78,7 @@ class PurchasedShoppingList(ApiModel):
|
||||||
|
|
||||||
@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id",
|
@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": {}}}})
|
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 | ProblemDetails:
|
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)
|
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||||
if not shopping_list:
|
if not shopping_list:
|
||||||
return error_response(request, 404, "Shopping list not found")
|
return error_response(request, 404, "Shopping list not found")
|
||||||
|
|
@ -143,9 +141,25 @@ class MealIdWrapper(ApiModel):
|
||||||
meal_id: int
|
meal_id: int
|
||||||
|
|
||||||
|
|
||||||
@router.post("/current/meals/me", response_model=None, operation_id="requestMeal", summary="Request a meal for shopping",
|
@router.post(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
"/current/meals/me",
|
||||||
async def request_meal(r: MealIdWrapper, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem | ProblemDetails:
|
response_model=shopping.ShoppingListItem,
|
||||||
|
operation_id="requestMeal",
|
||||||
|
summary="Request a meal for shopping",
|
||||||
|
responses={
|
||||||
|
404: {
|
||||||
|
"model": ProblemDetails,
|
||||||
|
"description": "Meal not found",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def request_meal(
|
||||||
|
r: MealIdWrapper,
|
||||||
|
request: Request,
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
) -> shopping.ShoppingListItem | Response:
|
||||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
@ -153,16 +167,35 @@ async def request_meal(r: MealIdWrapper, request: Request, conn: aiosqlite.Conne
|
||||||
response = await shopping.request(conn, person, meal=meal)
|
response = await shopping.request(conn, person, meal=meal)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
class Ok(ApiModel):
|
||||||
|
ok: bool = True
|
||||||
|
|
||||||
@router.delete("/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", summary="Remove a meal request",
|
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
@router.delete(
|
||||||
async def unrequest_meal(meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict | ProblemDetails:
|
"/current/meals/{meal_id}",
|
||||||
|
response_model=Ok,
|
||||||
|
operation_id="unrequestMeal",
|
||||||
|
summary="Remove a meal request",
|
||||||
|
responses={
|
||||||
|
404: {
|
||||||
|
"model": ProblemDetails,
|
||||||
|
"description": "Meal not found",
|
||||||
|
"content": {"application/problem+json": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def unrequest_meal(
|
||||||
|
meal_id: int,
|
||||||
|
request: Request,
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
) -> Ok | Response:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
||||||
await shopping.remove_request(conn, person, meal=meal)
|
await shopping.remove_request(conn, person, meal=meal)
|
||||||
return {}
|
return Ok()
|
||||||
|
|
||||||
|
|
||||||
# Removed duplicate placeholder endpoints left over from earlier scaffolding
|
# Removed duplicate placeholder endpoints left over from earlier scaffolding
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
def to_camel(s: str) -> str:
|
def to_camel(s: str) -> str:
|
||||||
|
|
|
||||||
12
db.py
12
db.py
|
|
@ -8,27 +8,27 @@ async def connect(path="./data/doof.sqlite") -> aiosqlite.Connection:
|
||||||
|
|
||||||
|
|
||||||
async def create(conn: aiosqlite.Connection):
|
async def create(conn: aiosqlite.Connection):
|
||||||
import products.db as product_db
|
import products.repository as product_db
|
||||||
|
|
||||||
await product_db.create(conn)
|
await product_db.create(conn)
|
||||||
|
|
||||||
import ingredients.db as ingredient_db
|
import ingredients.repository as ingredient_db
|
||||||
|
|
||||||
await ingredient_db.create(conn)
|
await ingredient_db.create(conn)
|
||||||
|
|
||||||
import recipes.db as recipe_db
|
import recipes.repository as recipe_db
|
||||||
|
|
||||||
await recipe_db.create(conn)
|
await recipe_db.create(conn)
|
||||||
|
|
||||||
import persons.db as person_db
|
import persons.repository as person_db
|
||||||
|
|
||||||
await person_db.create(conn)
|
await person_db.create(conn)
|
||||||
|
|
||||||
import meals.db as meals_db
|
import meals.repository as meals_db
|
||||||
|
|
||||||
await meals_db.create(conn)
|
await meals_db.create(conn)
|
||||||
|
|
||||||
import shopping.db as shopping_db
|
import shopping.repository as shopping_db
|
||||||
|
|
||||||
await shopping_db.create(conn)
|
await shopping_db.create(conn)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ from typing import List, Optional
|
||||||
from ingredient_parser import parse_ingredient
|
from ingredient_parser import parse_ingredient
|
||||||
|
|
||||||
import units
|
import units
|
||||||
from ingredients.db import (
|
from ingredients.models import Ingredient
|
||||||
Ingredient,
|
from ingredients.repository import (
|
||||||
delete_ingredients_by_meal_id as delete_ingredients_by_meal_id,
|
delete_ingredients_by_meal_id as delete_ingredients_by_meal_id,
|
||||||
find_ingredient_by_id as find_ingredient_by_id,
|
find_ingredient_by_id as find_ingredient_by_id,
|
||||||
find_ingredients_by_meal_id as find_ingredients_by_meal_id,
|
find_ingredients_by_meal_id as find_ingredients_by_meal_id,
|
||||||
|
|
|
||||||
48
ingredients/models.py
Normal file
48
ingredients/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, ClassVar, List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
from products import Product
|
||||||
|
from units import ALL_UNITS
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(ApiModel):
|
||||||
|
KEYS: ClassVar[List[str]] = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"line",
|
||||||
|
"preparation",
|
||||||
|
"unit",
|
||||||
|
"quantity",
|
||||||
|
"product_id",
|
||||||
|
"recipe_id",
|
||||||
|
"meal_id",
|
||||||
|
]
|
||||||
|
id: int = -1
|
||||||
|
name: str
|
||||||
|
line: str
|
||||||
|
unit: str = Field(
|
||||||
|
title="Unit",
|
||||||
|
description="Measurement unit (enum values are advisory; runtime accepts any string)",
|
||||||
|
json_schema_extra={"enum": [u.name for u in ALL_UNITS]},
|
||||||
|
)
|
||||||
|
quantity: float
|
||||||
|
preparation: str
|
||||||
|
product_id: Optional[int] = None
|
||||||
|
recipe_id: Optional[int] = None
|
||||||
|
meal_id: Optional[int] = None
|
||||||
|
product: Optional[Product] = None
|
||||||
|
|
||||||
|
# Ensure quantity is stored as a float even if provided as a string in tests
|
||||||
|
@field_validator("quantity", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_quantity(cls, v: Any) -> Any:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
return v
|
||||||
|
return v
|
||||||
|
|
@ -1,49 +1,7 @@
|
||||||
from typing import Any, AsyncIterator, ClassVar, List, Optional, Dict
|
from typing import AsyncIterator, List, Optional
|
||||||
|
|
||||||
from pydantic import field_validator, Field
|
from ingredients.models import Ingredient
|
||||||
from common import ApiModel
|
from products.models import Product
|
||||||
|
|
||||||
from products import Product
|
|
||||||
from units import ALL_UNITS
|
|
||||||
|
|
||||||
|
|
||||||
class Ingredient(ApiModel):
|
|
||||||
KEYS: ClassVar[List[str]] = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"line",
|
|
||||||
"preparation",
|
|
||||||
"unit",
|
|
||||||
"quantity",
|
|
||||||
"product_id",
|
|
||||||
"recipe_id",
|
|
||||||
"meal_id",
|
|
||||||
]
|
|
||||||
id: int = -1
|
|
||||||
name: str
|
|
||||||
line: str
|
|
||||||
unit: str = Field(
|
|
||||||
title="Unit",
|
|
||||||
description="Measurement unit (enum values are advisory; runtime accepts any string)",
|
|
||||||
json_schema_extra={"enum": [u.name for u in ALL_UNITS]},
|
|
||||||
)
|
|
||||||
quantity: float | str
|
|
||||||
preparation: str
|
|
||||||
product_id: Optional[int] = None
|
|
||||||
recipe_id: Optional[int] = None
|
|
||||||
meal_id: Optional[int] = None
|
|
||||||
product: Optional[Product] = None
|
|
||||||
|
|
||||||
# Ensure quantity is stored as a float even if provided as a string in tests
|
|
||||||
@field_validator("quantity", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _coerce_quantity(cls, v: Any) -> Any:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
return float(v)
|
|
||||||
except ValueError:
|
|
||||||
return v
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
285
main.py
285
main.py
|
|
@ -1,21 +1,29 @@
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
from typing import Annotated, Dict, List, Optional, Any
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
import aiosqlite
|
from fastapi import FastAPI, Request
|
||||||
from fastapi import Depends, FastAPI, APIRouter, Request, Response
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
import db
|
|
||||||
import products
|
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
from common import ProblemDetails, Page, ApiModel
|
from pydantic import ValidationError
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from api import (
|
||||||
|
auth as auth_router,
|
||||||
|
meals as meals_router,
|
||||||
|
persons as persons_router,
|
||||||
|
products as products_router,
|
||||||
|
recipes as recipes_router,
|
||||||
|
shopping as shopping_router,
|
||||||
|
)
|
||||||
|
from api.deps import (
|
||||||
|
cookie_person as cookie_person, # noqa: F401 - re-exported for tests
|
||||||
|
error_response as error_response, # noqa: F401 - re-exported for completeness
|
||||||
|
get_db as get_db, # noqa: F401 - re-exported for tests dependency overrides
|
||||||
|
)
|
||||||
|
from api.openapi import extend_with_problem_and_cookie_auth
|
||||||
|
from common import ApiModel, ProblemDetails
|
||||||
from settings import settings
|
from settings import settings
|
||||||
from api.deps import get_db, cookie_person, error_response
|
|
||||||
|
|
||||||
|
|
||||||
class CamelCaseRoute(APIRoute):
|
class CamelCaseRoute(APIRoute):
|
||||||
|
|
@ -40,148 +48,10 @@ async def app_lifespan(app: FastAPI):
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
|
# RFC7807 Problem Details handlers (standalone functions, registered in factory)
|
||||||
api_v1 = APIRouter(route_class=CamelCaseRoute)
|
async def http_exc_handler(request: Request, exc: Exception):
|
||||||
DATABASE_PATH = settings.database_path
|
# Narrow to StarletteHTTPException at runtime
|
||||||
|
assert isinstance(exc, StarletteHTTPException)
|
||||||
# get_db, cookie_person, and error_response are imported from api.deps
|
|
||||||
|
|
||||||
|
|
||||||
# OpenAPI reusable responses for ProblemDetails
|
|
||||||
def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
|
|
||||||
# Attach a custom openapi generation that injects reusable responses
|
|
||||||
original_openapi = app.openapi
|
|
||||||
|
|
||||||
def custom_openapi():
|
|
||||||
spec = original_openapi()
|
|
||||||
components = spec.setdefault("components", {})
|
|
||||||
responses = components.setdefault("responses", {})
|
|
||||||
security_schemes = components.setdefault("securitySchemes", {})
|
|
||||||
# Standard ProblemDetails responses
|
|
||||||
responses.setdefault(
|
|
||||||
"Problem400",
|
|
||||||
{
|
|
||||||
"description": "Bad Request",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {},
|
|
||||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
responses.setdefault(
|
|
||||||
"Problem404",
|
|
||||||
{
|
|
||||||
"description": "Not Found",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {},
|
|
||||||
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
responses.setdefault(
|
|
||||||
"Problem422",
|
|
||||||
{
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {
|
|
||||||
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
|
|
||||||
},
|
|
||||||
# Some clients may still expect FastAPI's default error; keep schema available
|
|
||||||
"application/json": {
|
|
||||||
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Define cookie-based auth for documentation (does not enforce at runtime)
|
|
||||||
security_schemes.setdefault(
|
|
||||||
"cookieAuth",
|
|
||||||
{
|
|
||||||
"type": "apiKey",
|
|
||||||
"in": "cookie",
|
|
||||||
"name": "user_id",
|
|
||||||
"description": "Authentication via user_id cookie (session-style).",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize v1 responses to reference reusable ProblemDetails where appropriate
|
|
||||||
paths = spec.get("paths", {})
|
|
||||||
# Known operationIds that require cookie_person dependency
|
|
||||||
protected_ops: set[str] = {
|
|
||||||
# recipes
|
|
||||||
"parseRecipe", # GET /recipes/parse
|
|
||||||
"createRecipe", # POST /recipes
|
|
||||||
"deleteRecipe", # DELETE /recipes/{recipe_id}
|
|
||||||
# meals
|
|
||||||
"markMealConsumed", # POST /meals/{meal_id}/consumed
|
|
||||||
"deleteMeal", # DELETE /meals/{meal_id}
|
|
||||||
# shopping
|
|
||||||
"purchaseIngredients", # POST /shopping
|
|
||||||
"getMyShoppingList", # GET /shopping/current/me/ingredients
|
|
||||||
"syncMyShoppingList", # POST /shopping/current/me/ingredients
|
|
||||||
"requestMeal", # POST /shopping/current/meals/me
|
|
||||||
"unrequestMeal", # DELETE /shopping/current/meals/{meal_id}
|
|
||||||
# auth
|
|
||||||
"refresh",
|
|
||||||
}
|
|
||||||
for path, ops in paths.items():
|
|
||||||
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
|
||||||
continue
|
|
||||||
if not isinstance(ops, dict):
|
|
||||||
continue
|
|
||||||
for method, op in ops.items():
|
|
||||||
if not isinstance(op, dict):
|
|
||||||
continue
|
|
||||||
# Add ProblemDetails response references and cookie security if required
|
|
||||||
resp = op.get("responses")
|
|
||||||
if not isinstance(resp, dict):
|
|
||||||
continue
|
|
||||||
# Map 400/404 to reusable references; ensure 422 exists
|
|
||||||
if "400" in resp:
|
|
||||||
resp["400"] = {"$ref": "#/components/responses/Problem400"}
|
|
||||||
if "404" in resp:
|
|
||||||
resp["404"] = {"$ref": "#/components/responses/Problem404"}
|
|
||||||
# Only add 422 if not already present
|
|
||||||
if "422" not in resp:
|
|
||||||
resp["422"] = {"$ref": "#/components/responses/Problem422"}
|
|
||||||
|
|
||||||
op_id = op.get("operationId")
|
|
||||||
if isinstance(op_id, str) and op_id in protected_ops:
|
|
||||||
# Merge/append cookieAuth security requirement
|
|
||||||
security = op.setdefault("security", [])
|
|
||||||
# Avoid duplicating if already present
|
|
||||||
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
|
||||||
security.append({"cookieAuth": []})
|
|
||||||
return spec
|
|
||||||
|
|
||||||
app.openapi = custom_openapi # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
_extend_openapi_with_problem_responses(app)
|
|
||||||
|
|
||||||
|
|
||||||
from api import products as products_router # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from api import recipes as recipes_router # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
from api import meals as meals_router # type: ignore
|
|
||||||
from api import shopping as shopping_router # type: ignore
|
|
||||||
from api import persons as persons_router # type: ignore
|
|
||||||
from api import auth as auth_router # type: ignore
|
|
||||||
|
|
||||||
# RFC7807 Problem Details handlers
|
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
||||||
from pydantic import ValidationError
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
|
||||||
async def http_exc_handler(request: Request, exc: StarletteHTTPException):
|
|
||||||
body = ProblemDetails(
|
body = ProblemDetails(
|
||||||
title=str(exc.detail) if exc.detail else "HTTP Error",
|
title=str(exc.detail) if exc.detail else "HTTP Error",
|
||||||
status=exc.status_code,
|
status=exc.status_code,
|
||||||
|
|
@ -195,8 +65,8 @@ async def http_exc_handler(request: Request, exc: StarletteHTTPException):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValidationError)
|
async def validation_exc_handler(request: Request, exc: Exception):
|
||||||
async def validation_exc_handler(request: Request, exc: ValidationError):
|
assert isinstance(exc, ValidationError)
|
||||||
errors: Dict[str, Any] = {}
|
errors: Dict[str, Any] = {}
|
||||||
for e in exc.errors():
|
for e in exc.errors():
|
||||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||||
|
|
@ -213,8 +83,8 @@ async def validation_exc_handler(request: Request, exc: ValidationError):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
async def request_validation_exc_handler(request: Request, exc: Exception):
|
||||||
async def request_validation_exc_handler(request: Request, exc: RequestValidationError):
|
assert isinstance(exc, RequestValidationError)
|
||||||
errors: Dict[str, Any] = {}
|
errors: Dict[str, Any] = {}
|
||||||
for e in exc.errors():
|
for e in exc.errors():
|
||||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||||
|
|
@ -231,46 +101,71 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Mount versioned API router
|
class HealthStatus(ApiModel):
|
||||||
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
|
status: str = "ok"
|
||||||
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
async def healthz() -> HealthStatus:
|
||||||
async def healthz():
|
return HealthStatus()
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Back-compat shims removed; tests should import helpers from feature modules
|
|
||||||
|
|
||||||
|
|
||||||
if settings.prod:
|
def create_app() -> FastAPI:
|
||||||
from fastapi.staticfiles import StaticFiles
|
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
# OpenAPI augmentation
|
||||||
else:
|
extend_with_problem_and_cookie_auth(app)
|
||||||
# Proxy the request to the frontend development server
|
|
||||||
from starlette.background import BackgroundTask
|
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import StreamingResponse
|
|
||||||
|
|
||||||
async def _reverse_proxy(request: Request):
|
# Exception handlers
|
||||||
import httpx
|
app.add_exception_handler(StarletteHTTPException, http_exc_handler)
|
||||||
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
app.add_exception_handler(ValidationError, validation_exc_handler)
|
||||||
client = app.state.proxy_client
|
app.add_exception_handler(RequestValidationError, request_validation_exc_handler)
|
||||||
rp_req = client.build_request(
|
|
||||||
request.method, url, headers=request.headers.raw, content=request.stream()
|
|
||||||
)
|
|
||||||
rp_resp = await client.send(rp_req, stream=True)
|
|
||||||
return StreamingResponse(
|
|
||||||
rp_resp.aiter_raw(),
|
|
||||||
status_code=rp_resp.status_code,
|
|
||||||
headers=rp_resp.headers,
|
|
||||||
background=BackgroundTask(rp_resp.aclose),
|
|
||||||
)
|
|
||||||
|
|
||||||
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|
# Routers
|
||||||
|
app.include_router(products_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)
|
||||||
|
|
||||||
|
# Static/proxy
|
||||||
|
if settings.prod:
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
||||||
|
else:
|
||||||
|
# Proxy the request to the frontend development server
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
from starlette.requests import Request as StarletteRequest
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
|
async def _reverse_proxy(request: StarletteRequest):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
||||||
|
client = app.state.proxy_client
|
||||||
|
rp_req = client.build_request(
|
||||||
|
request.method, url, headers=request.headers.raw, content=request.stream()
|
||||||
|
)
|
||||||
|
rp_resp = await client.send(rp_req, stream=True)
|
||||||
|
return StreamingResponse(
|
||||||
|
rp_resp.aiter_raw(),
|
||||||
|
status_code=rp_resp.status_code,
|
||||||
|
headers=rp_resp.headers,
|
||||||
|
background=BackgroundTask(rp_resp.aclose),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level app for uvicorn
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
DATABASE_PATH = settings.database_path
|
||||||
|
|
||||||
|
# get_db, cookie_person, and error_response are imported from api.deps
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from meals.db import (
|
from meals.models import Meal as Meal, MealRecipe as MealRecipe
|
||||||
Meal as Meal,
|
from meals.repository import (
|
||||||
MealRecipe as MealRecipe,
|
|
||||||
bulk_load_participants as bulk_load_participants,
|
bulk_load_participants as bulk_load_participants,
|
||||||
create as create,
|
create as create,
|
||||||
delete_meal as delete_meal,
|
delete_meal as delete_meal,
|
||||||
|
|
@ -19,6 +18,10 @@ from meals.db import (
|
||||||
sync_meal_recipes as sync_meal_recipes,
|
sync_meal_recipes as sync_meal_recipes,
|
||||||
update_meal as update_meal,
|
update_meal as update_meal,
|
||||||
)
|
)
|
||||||
from persons import Person as Person
|
from meals.roles import (
|
||||||
|
ROLE_CHEF as ROLE_CHEF,
|
||||||
|
ROLE_CLEANUP as ROLE_CLEANUP,
|
||||||
|
ROLE_CONSUMER as ROLE_CONSUMER,
|
||||||
|
)
|
||||||
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
from meals.service import get_duplicates as get_duplicates, validate_meal as validate_meal
|
||||||
from meals.roles import ROLE_CHEF as ROLE_CHEF, ROLE_CLEANUP as ROLE_CLEANUP, ROLE_CONSUMER as ROLE_CONSUMER
|
from persons import Person as Person
|
||||||
|
|
|
||||||
35
meals/models.py
Normal file
35
meals/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from typing import ClassVar, List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
from ingredients import Ingredient
|
||||||
|
from persons.models import Person
|
||||||
|
from recipes import Recipe
|
||||||
|
|
||||||
|
|
||||||
|
class MealRecipe(ApiModel):
|
||||||
|
meal_id: int
|
||||||
|
recipe_id: int
|
||||||
|
servings: float
|
||||||
|
|
||||||
|
recipe: Optional[Recipe] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Meal(ApiModel):
|
||||||
|
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
||||||
|
id: int = -1
|
||||||
|
suggested_date: datetime.datetime
|
||||||
|
consumed_date: Optional[datetime.datetime] = None
|
||||||
|
|
||||||
|
chefs: List[Person] = Field(default_factory=list)
|
||||||
|
cleanup: List[Person] = Field(default_factory=list)
|
||||||
|
consumers: List[Person] = Field(default_factory=list)
|
||||||
|
recipes: List[MealRecipe] = Field(default_factory=list)
|
||||||
|
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Set from shopping list
|
||||||
|
purchase_date: Optional[datetime.datetime] = None
|
||||||
|
|
@ -1,44 +1,19 @@
|
||||||
import datetime
|
import datetime
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
from typing import AsyncIterator, List, Optional
|
||||||
|
|
||||||
from pydantic import Field
|
|
||||||
from common import ApiModel
|
|
||||||
|
|
||||||
import persons
|
|
||||||
from persons import get_by_ids as persons_get_by_ids
|
|
||||||
from .roles import ROLE_CHEF, ROLE_CLEANUP, ROLE_CONSUMER
|
|
||||||
from ingredients import (
|
from ingredients import (
|
||||||
Ingredient,
|
Ingredient,
|
||||||
delete_ingredients_by_meal_id,
|
delete_ingredients_by_meal_id,
|
||||||
find_ingredients_by_meal_id,
|
find_ingredients_by_meal_id,
|
||||||
insert_ingredient,
|
insert_ingredient,
|
||||||
)
|
)
|
||||||
from persons import Person
|
from meals.models import Meal, MealRecipe
|
||||||
from recipes import Recipe, load_recipe_ingredients, row_to_recipe
|
from persons.models import Person
|
||||||
|
from persons.repository import get_by_ids as persons_get_by_ids
|
||||||
|
from recipes.models import Recipe
|
||||||
|
from recipes.repository import load_recipe_ingredients, row_to_recipe
|
||||||
|
|
||||||
|
from .roles import ROLE_CHEF, ROLE_CLEANUP, ROLE_CONSUMER
|
||||||
class MealRecipe(ApiModel):
|
|
||||||
meal_id: int
|
|
||||||
recipe_id: int
|
|
||||||
servings: float
|
|
||||||
|
|
||||||
recipe: Optional[Recipe] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Meal(ApiModel):
|
|
||||||
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
|
|
||||||
id: int = -1
|
|
||||||
suggested_date: datetime.datetime
|
|
||||||
consumed_date: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
chefs: List[Person] = Field(default_factory=list)
|
|
||||||
cleanup: List[Person] = Field(default_factory=list)
|
|
||||||
consumers: List[Person] = Field(default_factory=list)
|
|
||||||
recipes: List[MealRecipe] = Field(default_factory=list)
|
|
||||||
extra_ingredients: List[Ingredient] = Field(default_factory=list)
|
|
||||||
|
|
||||||
# Set from shopping list
|
|
||||||
purchase_date: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
|
|
@ -2,8 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Set
|
from typing import List, Set
|
||||||
|
|
||||||
from persons import Person
|
from meals.models import Meal
|
||||||
from meals.db import Meal
|
from persons.models import Person
|
||||||
|
|
||||||
|
|
||||||
def get_duplicates(items: List[Person]) -> Set[str]:
|
def get_duplicates(items: List[Person]) -> Set[str]:
|
||||||
|
|
|
||||||
|
|
@ -1,538 +0,0 @@
|
||||||
{
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Doof API",
|
|
||||||
"description": "Doof Backend API",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/api/v1/recipes/parse": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"recipes"
|
|
||||||
],
|
|
||||||
"summary": "Parse a recipe from a URL",
|
|
||||||
"operationId": "parseRecipe",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "url",
|
|
||||||
"in": "query",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Url"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "user_id",
|
|
||||||
"in": "cookie",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "User Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"$ref": "#/components/responses/Problem400"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/recipes/ingredients/parse": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"ingredients"
|
|
||||||
],
|
|
||||||
"summary": "Parse raw ingredient lines",
|
|
||||||
"operationId": "parseIngredients",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "ingredients",
|
|
||||||
"in": "query",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"title": "Array of ingredients to parse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Ingredient"
|
|
||||||
},
|
|
||||||
"title": "Response Parseingredients"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/products": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"products"
|
|
||||||
],
|
|
||||||
"summary": "Create or fetch a product from a URL",
|
|
||||||
"operationId": "createProduct",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProductUrl"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Product"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Response Createproduct"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/recipes": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"recipes"
|
|
||||||
],
|
|
||||||
"summary": "List recipes (paginated)",
|
|
||||||
"operationId": "listRecipes",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "q",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
|
||||||
"title": "Q"
|
|
||||||
},
|
|
||||||
"description": "Optional case-insensitive name filter (matches recipe name with SQL LIKE)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cursor",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
|
||||||
"title": "Cursor"
|
|
||||||
},
|
|
||||||
"description": "Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "limit",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"maximum": 200,
|
|
||||||
"minimum": 1,
|
|
||||||
"description": "Maximum number of items to return (1-200).",
|
|
||||||
"default": 50,
|
|
||||||
"title": "Limit"
|
|
||||||
},
|
|
||||||
"description": "Maximum number of items to return (1-200)."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "A page of recipes",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Page_Recipe_"
|
|
||||||
},
|
|
||||||
"example": {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Example Recipe",
|
|
||||||
"link": "https://example.com/recipes/1",
|
|
||||||
"serves": 4,
|
|
||||||
"imageUrls": [],
|
|
||||||
"ingredients": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"nextCursor": "2",
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"recipes"
|
|
||||||
],
|
|
||||||
"summary": "Create a new recipe (versioning semantics applied)",
|
|
||||||
"operationId": "createRecipe",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "user_id",
|
|
||||||
"in": "cookie",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "User Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Recipe-Input"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Validation error",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/recipes/{recipe_id}": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"recipes"
|
|
||||||
],
|
|
||||||
"summary": "Get a single recipe",
|
|
||||||
"operationId": "getRecipe",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "recipe_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Recipe Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Recipe not found",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"tags": [
|
|
||||||
"v1",
|
|
||||||
"recipes"
|
|
||||||
],
|
|
||||||
"summary": "Soft-delete (hide) a recipe",
|
|
||||||
"operationId": "deleteRecipe",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "recipe_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Recipe Id"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "user_id",
|
|
||||||
"in": "cookie",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "User Id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Successful Response",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Recipe not found",
|
|
||||||
"content": {
|
|
||||||
"application/problem+json": {},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation Error",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/HTTPValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": {
|
|
||||||
"schemas": {
|
|
||||||
"HTTPValidationError": {
|
|
||||||
"properties": {
|
|
||||||
"detail": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/ValidationError"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Detail"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"title": "HTTPValidationError"
|
|
||||||
},
|
|
||||||
"ProblemDetails": {
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Type",
|
|
||||||
"default": "about:blank"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Title"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Status"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Detail"
|
|
||||||
},
|
|
||||||
"instance": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Instance"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Errors"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"title",
|
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"title": "ProblemDetails"
|
|
||||||
},
|
|
||||||
"ValidationError": {
|
|
||||||
"properties": {
|
|
||||||
"loc": {
|
|
||||||
"items": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Location"
|
|
||||||
},
|
|
||||||
"msg": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Message"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Error Type"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"loc",
|
|
||||||
"msg",
|
|
||||||
"type"
|
|
||||||
],
|
|
||||||
"title": "ValidationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
47
openapi.json
47
openapi.json
|
|
@ -1043,7 +1043,9 @@
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {}
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1101,7 +1103,9 @@
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {}
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Ok"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1284,7 +1288,9 @@
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {}
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Person"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1361,7 +1367,9 @@
|
||||||
"description": "Successful Response",
|
"description": "Successful Response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {}
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HealthStatus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1443,6 +1451,17 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HTTPValidationError"
|
"title": "HTTPValidationError"
|
||||||
},
|
},
|
||||||
|
"HealthStatus": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Status",
|
||||||
|
"default": "ok"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "HealthStatus"
|
||||||
|
},
|
||||||
"Ingredient": {
|
"Ingredient": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
|
@ -1481,14 +1500,7 @@
|
||||||
"description": "Measurement unit (enum values are advisory; runtime accepts any string)"
|
"description": "Measurement unit (enum values are advisory; runtime accepts any string)"
|
||||||
},
|
},
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"anyOf": [
|
"type": "number",
|
||||||
{
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Quantity"
|
"title": "Quantity"
|
||||||
},
|
},
|
||||||
"preparation": {
|
"preparation": {
|
||||||
|
|
@ -1797,6 +1809,17 @@
|
||||||
],
|
],
|
||||||
"title": "MealRecipe"
|
"title": "MealRecipe"
|
||||||
},
|
},
|
||||||
|
"Ok": {
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Ok",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Ok"
|
||||||
|
},
|
||||||
"Page_Person_": {
|
"Page_Person_": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": {
|
"items": {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
from persons.db import (
|
from persons.models import Person as Person
|
||||||
Person as Person,
|
from persons.repository import (
|
||||||
|
compute_prev_cursor as compute_prev_cursor,
|
||||||
|
count_all as count_all,
|
||||||
|
count_by_name as count_by_name,
|
||||||
create as create,
|
create as create,
|
||||||
get_all as get_all,
|
get_all as get_all,
|
||||||
get_all_paged as get_all_paged,
|
get_all_paged as get_all_paged,
|
||||||
search_by_name_paged as search_by_name_paged,
|
|
||||||
count_all as count_all,
|
|
||||||
count_by_name as count_by_name,
|
|
||||||
compute_prev_cursor as compute_prev_cursor,
|
|
||||||
get_by_id as get_by_id,
|
get_by_id as get_by_id,
|
||||||
get_by_name as get_by_name,
|
|
||||||
get_by_ids as get_by_ids,
|
get_by_ids as get_by_ids,
|
||||||
|
get_by_name as get_by_name,
|
||||||
insert_person as insert_person,
|
insert_person as insert_person,
|
||||||
search_by_name as search_by_name,
|
search_by_name as search_by_name,
|
||||||
|
search_by_name_paged as search_by_name_paged,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
10
persons/models.py
Normal file
10
persons/models.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from typing import ClassVar, List
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
class Person(ApiModel):
|
||||||
|
KEYS: ClassVar[List[str]] = ["id", "name"]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
name: str
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
from typing import AsyncIterator, List, Optional
|
||||||
|
|
||||||
from common import ApiModel
|
from persons.models import Person
|
||||||
|
|
||||||
|
|
||||||
class Person(ApiModel):
|
|
||||||
KEYS: ClassVar[List[str]] = ["id", "name"]
|
|
||||||
|
|
||||||
id: int = -1
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
|
|
@ -167,7 +160,8 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
params = (f"%{name}%", first_id, limit)
|
from typing import Any
|
||||||
|
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||||
else:
|
else:
|
||||||
query = """
|
query = """
|
||||||
SELECT id
|
SELECT id
|
||||||
|
|
@ -176,6 +170,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
|
from typing import Any
|
||||||
params = (first_id, limit)
|
params = (first_id, limit)
|
||||||
|
|
||||||
async with conn.execute(query, params) as c:
|
async with conn.execute(query, params) as c:
|
||||||
|
|
@ -2,8 +2,8 @@ import json
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from products import coles, woolworths
|
from products import coles, woolworths
|
||||||
from products.db import (
|
from products.models import Product
|
||||||
Product,
|
from products.repository import (
|
||||||
add_tag,
|
add_tag,
|
||||||
find_product_by_id as find_product_by_id,
|
find_product_by_id as find_product_by_id,
|
||||||
find_product_by_key,
|
find_product_by_key,
|
||||||
|
|
|
||||||
32
products/models.py
Normal file
32
products/models.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import ClassVar, List, Optional
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
class Product(ApiModel):
|
||||||
|
KEYS: ClassVar[List[str]] = [
|
||||||
|
"id",
|
||||||
|
"product_id",
|
||||||
|
"shop_code",
|
||||||
|
"link",
|
||||||
|
"name",
|
||||||
|
"quantity",
|
||||||
|
"unit",
|
||||||
|
"img_small",
|
||||||
|
"img_large",
|
||||||
|
]
|
||||||
|
NON_INSERT_KEYS: ClassVar[List[str]] = ["id"]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
product_id: str
|
||||||
|
shop_code: str
|
||||||
|
link: str
|
||||||
|
name: str
|
||||||
|
quantity: int
|
||||||
|
unit: str
|
||||||
|
img_small: str
|
||||||
|
img_large: str
|
||||||
|
# Non-persisted field used in tests and insert helper
|
||||||
|
raw_data: Optional[dict] = None
|
||||||
|
|
@ -1,34 +1,7 @@
|
||||||
import json
|
import json
|
||||||
from typing import AsyncIterator, ClassVar, List, Optional
|
from typing import AsyncIterator, Optional
|
||||||
|
|
||||||
from common import ApiModel
|
from products.models import Product
|
||||||
|
|
||||||
|
|
||||||
class Product(ApiModel):
|
|
||||||
KEYS: ClassVar[List[str]] = [
|
|
||||||
"id",
|
|
||||||
"product_id",
|
|
||||||
"shop_code",
|
|
||||||
"link",
|
|
||||||
"name",
|
|
||||||
"quantity",
|
|
||||||
"unit",
|
|
||||||
"img_small",
|
|
||||||
"img_large",
|
|
||||||
]
|
|
||||||
NON_INSERT_KEYS: ClassVar[List[str]] = ["id"]
|
|
||||||
|
|
||||||
id: int = -1
|
|
||||||
product_id: str
|
|
||||||
shop_code: str
|
|
||||||
link: str
|
|
||||||
name: str
|
|
||||||
quantity: int
|
|
||||||
unit: str
|
|
||||||
img_small: str
|
|
||||||
img_large: str
|
|
||||||
# Non-persisted field used in tests and insert helper
|
|
||||||
raw_data: Optional[dict] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = ["py310"]
|
target-version = ["py311"]
|
||||||
include = "\\.pyi?$"
|
include = "\\.pyi?$"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|
@ -25,7 +25,7 @@ combine-as-imports = true
|
||||||
known-first-party = ["ingredients", "meals", "persons", "products", "recipes", "shopping"]
|
known-first-party = ["ingredients", "meals", "persons", "products", "recipes", "shopping"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.11"
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
@ -35,8 +35,6 @@ no_implicit_optional = true
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
exclude = "^(\\.*/)?tests($|/)"
|
exclude = "^(\\.*/)?tests($|/)"
|
||||||
|
|
||||||
disable_error_code = ["import-untyped"]
|
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["tests.*"]
|
module = ["tests.*"]
|
||||||
ignore_errors = true
|
ignore_errors = true
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@ import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
from ingredients import match_existing_products, parse_ingredient_from_nlp
|
||||||
from persons import Person
|
from persons.models import Person
|
||||||
from recipes.db import (
|
from recipes.models import Recipe as Recipe
|
||||||
Recipe as Recipe,
|
from recipes.repository import (
|
||||||
|
compute_prev_cursor as compute_prev_cursor,
|
||||||
|
count_all as count_all,
|
||||||
|
count_by_name as count_by_name,
|
||||||
find_recipe_by_id as find_recipe_by_id,
|
find_recipe_by_id as find_recipe_by_id,
|
||||||
find_recipes_by_name as find_recipes_by_name,
|
find_recipes_by_name as find_recipes_by_name,
|
||||||
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
find_recipes_by_name_paged as find_recipes_by_name_paged,
|
||||||
get_all as get_all,
|
get_all as get_all,
|
||||||
get_all_paged as get_all_paged,
|
get_all_paged as get_all_paged,
|
||||||
compute_prev_cursor as compute_prev_cursor,
|
|
||||||
count_all as count_all,
|
|
||||||
count_by_name as count_by_name,
|
|
||||||
hide_recipe as hide_recipe,
|
hide_recipe as hide_recipe,
|
||||||
insert_recipe as insert_recipe,
|
insert_recipe as insert_recipe,
|
||||||
load_recipe_ingredients as load_recipe_ingredients,
|
load_recipe_ingredients as load_recipe_ingredients,
|
||||||
|
|
@ -67,7 +67,7 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict, created_by: Person
|
||||||
images = [images]
|
images = [images]
|
||||||
|
|
||||||
return Recipe(
|
return Recipe(
|
||||||
id=0,
|
id=-1,
|
||||||
name=name,
|
name=name,
|
||||||
link=url,
|
link=url,
|
||||||
serves=serves,
|
serves=serves,
|
||||||
|
|
|
||||||
44
recipes/models.py
Normal file
44
recipes/models.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from typing import ClassVar, List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from common import ApiModel
|
||||||
|
from ingredients import Ingredient
|
||||||
|
from persons.models import Person
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(ApiModel):
|
||||||
|
KEYS: ClassVar[List[str]] = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"link",
|
||||||
|
"serves",
|
||||||
|
"image_urls",
|
||||||
|
"based_on_recipe",
|
||||||
|
"created_by_id",
|
||||||
|
"date_created",
|
||||||
|
"hidden_by_id",
|
||||||
|
"date_hidden",
|
||||||
|
]
|
||||||
|
NON_INSERT_KEYS: ClassVar[List[str]] = ["id", "created_date", "hidden_by_id", "date_hidden"]
|
||||||
|
|
||||||
|
id: int = -1
|
||||||
|
name: str
|
||||||
|
link: str
|
||||||
|
serves: int
|
||||||
|
image_urls: List[str] = Field(default_factory=list)
|
||||||
|
ingredients: List[Ingredient] = Field(default_factory=list)
|
||||||
|
based_on_recipe: Optional[int] = None
|
||||||
|
|
||||||
|
date_created: datetime.datetime = Field(
|
||||||
|
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||||
|
)
|
||||||
|
created_by_id: Optional[int]
|
||||||
|
created_by: Optional[Person] = None
|
||||||
|
|
||||||
|
date_hidden: Optional[datetime.datetime] = None
|
||||||
|
hidden_by_id: Optional[int] = None
|
||||||
|
hidden_by: Optional[Person] = None
|
||||||
|
|
@ -1,46 +1,10 @@
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
from typing import Any, AsyncIterator, ClassVar, Iterable, List, Optional, Tuple, cast
|
from typing import Any, AsyncIterator, Iterable, List, Optional, Tuple, cast
|
||||||
|
|
||||||
from pydantic import Field
|
from ingredients import find_ingredients_by_recipe_id
|
||||||
from common import ApiModel
|
from persons.models import Person
|
||||||
|
from recipes.models import Recipe
|
||||||
from ingredients import Ingredient, find_ingredients_by_recipe_id
|
|
||||||
from persons import Person
|
|
||||||
|
|
||||||
|
|
||||||
class Recipe(ApiModel):
|
|
||||||
KEYS: ClassVar[List[str]] = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"link",
|
|
||||||
"serves",
|
|
||||||
"image_urls",
|
|
||||||
"based_on_recipe",
|
|
||||||
"created_by_id",
|
|
||||||
"date_created",
|
|
||||||
"hidden_by_id",
|
|
||||||
"date_hidden",
|
|
||||||
]
|
|
||||||
NON_INSERT_KEYS: ClassVar[List[str]] = ["id", "created_date", "hidden_by_id", "date_hidden"]
|
|
||||||
|
|
||||||
id: int = -1
|
|
||||||
name: str
|
|
||||||
link: str
|
|
||||||
serves: int
|
|
||||||
image_urls: List[str] = Field(default_factory=list)
|
|
||||||
ingredients: List[Ingredient] = Field(default_factory=list)
|
|
||||||
based_on_recipe: Optional[int] = None
|
|
||||||
|
|
||||||
date_created: datetime.datetime = Field(
|
|
||||||
default_factory=lambda: datetime.datetime.now().astimezone()
|
|
||||||
)
|
|
||||||
created_by_id: Optional[int]
|
|
||||||
created_by: Optional[Person] = None
|
|
||||||
|
|
||||||
date_hidden: Optional[datetime.datetime] = None
|
|
||||||
hidden_by_id: Optional[int] = None
|
|
||||||
hidden_by: Optional[Person] = None
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
|
|
@ -200,22 +164,24 @@ async def compute_prev_cursor(
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return None
|
return None
|
||||||
if name:
|
if name:
|
||||||
query = f"""
|
query = """
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE name LIKE ? AND date_hidden IS NULL AND id < ?
|
WHERE name LIKE ? AND date_hidden IS NULL AND id < ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
params = (f"%{name}%", first_id, limit)
|
from typing import Any
|
||||||
|
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
|
||||||
else:
|
else:
|
||||||
query = f"""
|
query = """
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE date_hidden IS NULL AND id < ?
|
WHERE date_hidden IS NULL AND id < ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
"""
|
"""
|
||||||
|
from typing import Any
|
||||||
params = (first_id, limit)
|
params = (first_id, limit)
|
||||||
|
|
||||||
async with conn.execute(query, params) as c:
|
async with conn.execute(query, params) as c:
|
||||||
|
|
@ -227,7 +193,7 @@ async def compute_prev_cursor(
|
||||||
|
|
||||||
async def count_all(conn) -> int:
|
async def count_all(conn) -> int:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
f"""
|
"""
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE date_hidden IS NULL
|
WHERE date_hidden IS NULL
|
||||||
|
|
@ -239,7 +205,7 @@ async def count_all(conn) -> int:
|
||||||
|
|
||||||
async def count_by_name(conn, name: str) -> int:
|
async def count_by_name(conn, name: str) -> int:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
f"""
|
"""
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM Recipe
|
FROM Recipe
|
||||||
WHERE name LIKE ? AND date_hidden IS NULL
|
WHERE name LIKE ? AND date_hidden IS NULL
|
||||||
|
|
@ -7,7 +7,7 @@ ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||||
if ROOT not in sys.path:
|
if ROOT not in sys.path:
|
||||||
sys.path.insert(0, ROOT)
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
from main import app
|
from main import app # noqa: E402
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
with open("openapi.json", "w") as f:
|
with open("openapi.json", "w") as f:
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
# Ensure project root is on sys.path
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(__file__))
|
|
||||||
if ROOT not in sys.path:
|
|
||||||
sys.path.insert(0, ROOT)
|
|
||||||
|
|
||||||
from main import app
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Ensure /api/v1 is part of the generated spec
|
|
||||||
with open("munch-ease-backend-openapi.json", "w") as f:
|
|
||||||
spec = app.openapi()
|
|
||||||
json.dump(spec, f, indent=2)
|
|
||||||
print("Wrote munch-ease-backend-openapi.json")
|
|
||||||
|
|
@ -6,8 +6,8 @@ anywhere (including tests) without side effects.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ from typing import Any, Dict, Iterable, Iterator, List, Tuple
|
||||||
import ingredients
|
import ingredients
|
||||||
import meals
|
import meals
|
||||||
import recipes
|
import recipes
|
||||||
from shopping.db import (
|
from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem
|
||||||
ShoppingList as ShoppingList,
|
from shopping.repository import (
|
||||||
ShoppingListItem as ShoppingListItem,
|
|
||||||
find_items_by_list_id as _find_items_by_list_id,
|
find_items_by_list_id as _find_items_by_list_id,
|
||||||
get_purchased_ingredients as _get_purchased_ingredients,
|
get_purchased_ingredients as _get_purchased_ingredients,
|
||||||
is_requested as is_requested,
|
is_requested as is_requested,
|
||||||
|
|
@ -14,6 +13,7 @@ from shopping.db import (
|
||||||
remove_request as remove_request,
|
remove_request as remove_request,
|
||||||
request as request,
|
request as request,
|
||||||
update_purchased_meals as update_purchased_meals,
|
update_purchased_meals as update_purchased_meals,
|
||||||
|
validate_request as validate_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
48
shopping/models.py
Normal file
48
shopping/models.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import ClassVar, List, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from common import BaseLinkedModel
|
||||||
|
from persons.models import Person
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListItem(BaseLinkedModel):
|
||||||
|
KEYS: ClassVar[List[str]] = [
|
||||||
|
"id",
|
||||||
|
"ingredient_id",
|
||||||
|
"list_id",
|
||||||
|
"person_id",
|
||||||
|
"meal_id",
|
||||||
|
"recipe_id",
|
||||||
|
"created_date",
|
||||||
|
]
|
||||||
|
id: int = -1
|
||||||
|
list_id: Optional[int] = None
|
||||||
|
|
||||||
|
person_id: int = -1
|
||||||
|
|
||||||
|
ingredient_id: Optional[int] = None
|
||||||
|
|
||||||
|
recipe_id: Optional[int] = None
|
||||||
|
|
||||||
|
meal_id: Optional[int] = None
|
||||||
|
|
||||||
|
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||||
|
|
||||||
|
|
||||||
|
class StoreEnum(str, Enum):
|
||||||
|
woolworths = "woolworths"
|
||||||
|
coles = "coles"
|
||||||
|
home = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingList(BaseLinkedModel):
|
||||||
|
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
|
||||||
|
id: int = -1
|
||||||
|
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
||||||
|
store_name: StoreEnum = StoreEnum.home
|
||||||
|
purchased_by_id: int = -1
|
||||||
|
purchased_by: Optional[Person] = None
|
||||||
|
items: List[ShoppingListItem] = Field(default_factory=list)
|
||||||
|
|
@ -1,53 +1,6 @@
|
||||||
from datetime import datetime
|
from typing import Any, AsyncIterator, List, Optional
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, AsyncIterator, ClassVar, List, Optional
|
|
||||||
|
|
||||||
from pydantic import Field
|
from shopping.models import ShoppingList, ShoppingListItem
|
||||||
|
|
||||||
from common import BaseLinkedModel
|
|
||||||
from ingredients import Ingredient, insert_ingredient
|
|
||||||
from meals import Meal, find_meal_by_id, mark_purchased
|
|
||||||
from persons import Person
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListItem(BaseLinkedModel):
|
|
||||||
KEYS: ClassVar[List[str]] = [
|
|
||||||
"id",
|
|
||||||
"ingredient_id",
|
|
||||||
"list_id",
|
|
||||||
"person_id",
|
|
||||||
"meal_id",
|
|
||||||
"recipe_id",
|
|
||||||
"created_date",
|
|
||||||
]
|
|
||||||
id: int = -1
|
|
||||||
list_id: Optional[int] = None
|
|
||||||
|
|
||||||
person_id: int = -1
|
|
||||||
|
|
||||||
ingredient_id: Optional[int] = None
|
|
||||||
|
|
||||||
recipe_id: Optional[int] = None
|
|
||||||
|
|
||||||
meal_id: Optional[int] = None
|
|
||||||
|
|
||||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
|
||||||
|
|
||||||
|
|
||||||
class StoreEnum(str, Enum):
|
|
||||||
woolworths = "woolworths"
|
|
||||||
coles = "coles"
|
|
||||||
home = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingList(BaseLinkedModel):
|
|
||||||
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
|
|
||||||
id: int = -1
|
|
||||||
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
|
|
||||||
store_name: StoreEnum = StoreEnum.home
|
|
||||||
purchased_by_id: int = -1
|
|
||||||
purchased_by: Optional[Person] = None
|
|
||||||
items: List[ShoppingListItem] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
async def create(conn):
|
async def create(conn):
|
||||||
|
|
@ -82,7 +35,9 @@ async def create(conn):
|
||||||
# Useful indexes for queries
|
# 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_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_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;")
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_shopping_item_person_null_list ON ShoppingListItem(person_id, ingredient_id) WHERE list_id IS NULL;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_request(request: ShoppingListItem) -> None:
|
def validate_request(request: ShoppingListItem) -> None:
|
||||||
|
|
@ -101,6 +56,8 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
||||||
if shopping_list.items is None or len(shopping_list.items) == 0:
|
if shopping_list.items is None or len(shopping_list.items) == 0:
|
||||||
raise ValueError("Shopping list must have items")
|
raise ValueError("Shopping list must have items")
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
shopping_list.created_date = datetime.now().astimezone()
|
shopping_list.created_date = datetime.now().astimezone()
|
||||||
|
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
|
|
@ -186,6 +143,8 @@ async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||||
purchased_ingredient_ids = {
|
purchased_ingredient_ids = {
|
||||||
item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)
|
item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)
|
||||||
}
|
}
|
||||||
|
from meals.repository import find_meal_by_id, mark_purchased
|
||||||
|
|
||||||
for meal_id in meal_ids:
|
for meal_id in meal_ids:
|
||||||
meal = await find_meal_by_id(conn, meal_id)
|
meal = await find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
|
|
@ -202,7 +161,7 @@ async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
|
||||||
await remove_request(conn, person=None, meal=meal)
|
await remove_request(conn, person=None, meal=meal)
|
||||||
|
|
||||||
|
|
||||||
async def is_requested(conn, meal: Meal) -> bool:
|
async def is_requested(conn, meal) -> bool:
|
||||||
if meal.id < 0:
|
if meal.id < 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -218,8 +177,10 @@ async def is_requested(conn, meal: Meal) -> bool:
|
||||||
|
|
||||||
|
|
||||||
async def request(
|
async def request(
|
||||||
conn, person: Person, ingredient: Optional[Ingredient] = None, meal: Optional[Meal] = None
|
conn, person, ingredient: Optional[Any] = None, meal: Optional[Any] = None
|
||||||
) -> ShoppingListItem:
|
) -> ShoppingListItem:
|
||||||
|
from ingredients.repository import insert_ingredient
|
||||||
|
|
||||||
if ingredient is not None and meal is not None:
|
if ingredient is not None and meal is not None:
|
||||||
raise ValueError("Cannot request both an ingredient and a meal")
|
raise ValueError("Cannot request both an ingredient and a meal")
|
||||||
|
|
||||||
|
|
@ -256,9 +217,9 @@ async def request(
|
||||||
|
|
||||||
async def remove_request(
|
async def remove_request(
|
||||||
conn,
|
conn,
|
||||||
person: Optional[Person] = None,
|
person: Optional[Any] = None,
|
||||||
meal: Optional[Meal] = None,
|
meal: Optional[Any] = None,
|
||||||
ingredient: Optional[Ingredient] = None,
|
ingredient: Optional[Any] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if meal is not None:
|
if meal is not None:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
|
|
@ -249,7 +249,7 @@ class Recipes:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from meals import db as meals_db
|
from meals import repository as meals_db
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ def reload_test_data():
|
||||||
|
|
||||||
from db import connect, create
|
from db import connect, create
|
||||||
import ingredients
|
import ingredients
|
||||||
import ingredients.db as ingredients_db
|
import ingredients.repository as ingredients_db
|
||||||
import products.db as products_db
|
import products.repository as products_db
|
||||||
import units
|
import units
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,8 +208,8 @@ def reload_test_data():
|
||||||
|
|
||||||
from db import connect, create
|
from db import connect, create
|
||||||
import ingredients
|
import ingredients
|
||||||
import ingredients.db as ingredients_db
|
import ingredients.repository as ingredients_db
|
||||||
import products.db as products_db
|
import products.repository as products_db
|
||||||
import units
|
import units
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ import main
|
||||||
from meals import get_duplicates
|
from meals import get_duplicates
|
||||||
from api.meals import validate_meal
|
from api.meals import validate_meal
|
||||||
import meals
|
import meals
|
||||||
import meals.db as meals_db
|
import meals.repository as meals_db
|
||||||
from meals.db import Meal, MealRecipe
|
from meals.models import Meal, MealRecipe
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
import ingredients
|
import ingredients
|
||||||
|
|
@ -499,7 +499,7 @@ class TestMainHelperFunctions(unittest.TestCase):
|
||||||
recipes=[],
|
recipes=[],
|
||||||
extra_ingredients=[],
|
extra_ingredients=[],
|
||||||
)
|
)
|
||||||
result = validate_meal(meal)
|
result = validate_meal(meal)
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(result)
|
||||||
self.assertEqual(result.status_code, 400)
|
self.assertEqual(result.status_code, 400)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ def reload_test_data():
|
||||||
|
|
||||||
from db import connect, create
|
from db import connect, create
|
||||||
import meals
|
import meals
|
||||||
import meals.db as meals_db
|
import meals.repository as meals_db
|
||||||
from meals.db import Meal, MealRecipe
|
from meals.models import Meal, MealRecipe
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
import ingredients
|
import ingredients
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import unittest
|
||||||
|
|
||||||
import tests.test_data as test_data
|
import tests.test_data as test_data
|
||||||
|
|
||||||
import products.db as products_db
|
import products.repository as products_db
|
||||||
from db import connect, create
|
from db import connect, create
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue