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:
jableader 2025-10-19 20:24:23 +11:00
parent 50a1fcaeee
commit 5be4e89c4e
44 changed files with 797 additions and 2015 deletions

24
.gitignore vendored
View file

@ -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__/
*.pyc
*.pyo

View file

@ -3,14 +3,14 @@ Meal planner backend
## Structure
- `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:
- `products/` (db, scrapers for Woolworths/Coles)
- `ingredients/`
- `recipes/` (db, scraping)
- `meals/`
- `persons/`
- `shopping/`
- `products/` (models.py, repository.py, scrapers for Woolworths/Coles)
- `ingredients/` (models.py, repository.py)
- `recipes/` (models.py, repository.py, scraping.py)
- `meals/` (models.py, repository.py, service.py)
- `persons/` (models.py, repository.py)
- `shopping/` (models.py, repository.py)
- `tests/`: unit and API tests with sample HTTP fixtures.
## Getting started
@ -27,7 +27,7 @@ uvicorn main:app --reload
Run tests
```
python -m unittest -q
pytest -q
```
## Tooling
@ -40,7 +40,7 @@ This repo includes baseline configs in `pyproject.toml`:
Optional commands (install these locally first):
```
ruff check .
black .
ruff format .
mypy .
```

View file

@ -1,13 +1,11 @@
from __future__ import annotations
import aiosqlite
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from fastapi import APIRouter, Depends, Request, Response
import persons
from common import ProblemDetails, ApiModel
from api.deps import get_db, cookie_person, error_response
from api.deps import cookie_person, error_response, get_db
from common import ApiModel, ProblemDetails
router = APIRouter(prefix="/auth", tags=["auth"])
@ -18,25 +16,32 @@ class LoginBody(ApiModel):
@router.post(
"/login",
response_model=None,
response_model=persons.Person,
operation_id="login",
summary="Login and set user_id cookie",
responses={
200: {"model": persons.Person, "description": "Successful Response"},
404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}
},
)
async def login(request: Request, data: LoginBody, 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)
if not person:
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))
return response
return person
@router.post(
"/refresh",
response_model=persons.Person,
operation_id="refresh",
summary="Refresh current user from cookie",
)

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Annotated, Optional, AsyncGenerator
from typing import Annotated, AsyncGenerator, Optional
import aiosqlite
from fastapi import Cookie, Depends, Request

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from typing import List, Optional
import datetime
from typing import List, Optional
import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
@ -9,11 +9,8 @@ from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import persons
import shopping
from api.deps import cookie_person, error_response, get_db
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"])
@ -47,7 +44,7 @@ async def get_upcoming_meals(
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def get_meal(
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
) -> meals.Meal | Response:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
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": {}}}})
async def create_meal(
meal: meals.Meal, request: Request, response: Response, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
) -> meals.Meal | Response:
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
@ -76,7 +73,7 @@ async def create_meal(
})
async def update_meal(
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
) -> meals.Meal | Response:
if meal.id != meal_id:
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,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal:
) -> meals.Meal | Response:
if consumed_date and not consumed_date.tzinfo:
return error_response(request, 400, "Consumed date must include timezone")
@ -126,7 +123,7 @@ async def delete_meal(
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> meals.Meal:
) -> meals.Meal | Response:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
@ -136,7 +133,7 @@ async def delete_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."""
msg = meals.validate_meal(meal)
if msg:

109
api/openapi.py Normal file
View 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]

View file

@ -6,8 +6,8 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Response
import persons
from api.deps import get_db
from common import Page
from main import get_db
router = APIRouter(prefix="/persons", tags=["persons"])

View file

@ -7,9 +7,8 @@ from fastapi import APIRouter, Depends, Response
from pydantic import Field
import products
from common import ApiModel
from api.deps import get_db
from common import ApiModel
router = APIRouter(prefix="/products", tags=["products"])

View file

@ -6,10 +6,10 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import ingredients
import recipes
import persons
import recipes
from api.deps import cookie_person, error_response, get_db
from common import Page, ProblemDetails
from api.deps import get_db, cookie_person, error_response
router = APIRouter(prefix="/recipes", tags=["recipes"])
@ -29,7 +29,7 @@ router = APIRouter(prefix="/recipes", tags=["recipes"])
)
async def parse_recipe_handler(
url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person)
) -> recipes.Recipe | ProblemDetails:
) -> recipes.Recipe | Response:
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
return error_response(request, 400, "Recipe not found")
@ -179,7 +179,7 @@ async def list_recipes(
)
async def get_recipe(
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)
if not r:
return error_response(request, 404, "Recipe not found")
@ -206,7 +206,7 @@ async def create_recipe(
response: Response,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
) -> recipes.Recipe | ProblemDetails:
) -> recipes.Recipe | Response:
if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient")
@ -248,7 +248,7 @@ async def delete_recipe(
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
) -> recipes.Recipe | ProblemDetails:
) -> recipes.Recipe | Response:
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe:
return error_response(request, 404, "Recipe not found")

View file

@ -3,20 +3,18 @@ from __future__ import annotations
from typing import Dict, List
import aiosqlite
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Response
import ingredients
import meals
import persons
import recipes
import shopping
from common import ProblemDetails
from api.deps import get_db, cookie_person, error_response
from api.deps import cookie_person, error_response, get_db
from common import ApiModel, Field, ProblemDetails
router = APIRouter(prefix="/shopping", tags=["shopping"])
from common import ApiModel, Field
class CurrentShoppingList(ApiModel):
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",
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)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
@ -143,9 +141,25 @@ class MealIdWrapper(ApiModel):
meal_id: int
@router.post("/current/meals/me", response_model=None, 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 | ProblemDetails:
@router.post(
"/current/meals/me",
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)
if not meal:
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)
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": {}}}})
async def unrequest_meal(meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict | ProblemDetails:
@router.delete(
"/current/meals/{meal_id}",
response_model=Ok,
operation_id="unrequestMeal",
summary="Remove a meal request",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def unrequest_meal(
meal_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
) -> Ok | Response:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
await shopping.remove_request(conn, person, meal=meal)
return {}
return Ok()
# Removed duplicate placeholder endpoints left over from earlier scaffolding

View file

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

12
db.py
View file

@ -8,27 +8,27 @@ async def connect(path="./data/doof.sqlite") -> 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)
import ingredients.db as ingredient_db
import ingredients.repository as ingredient_db
await ingredient_db.create(conn)
import recipes.db as recipe_db
import recipes.repository as recipe_db
await recipe_db.create(conn)
import persons.db as person_db
import persons.repository as person_db
await person_db.create(conn)
import meals.db as meals_db
import meals.repository as meals_db
await meals_db.create(conn)
import shopping.db as shopping_db
import shopping.repository as shopping_db
await shopping_db.create(conn)

View file

@ -4,8 +4,8 @@ from typing import List, Optional
from ingredient_parser import parse_ingredient
import units
from ingredients.db import (
Ingredient,
from ingredients.models import Ingredient
from ingredients.repository import (
delete_ingredients_by_meal_id as delete_ingredients_by_meal_id,
find_ingredient_by_id as find_ingredient_by_id,
find_ingredients_by_meal_id as find_ingredients_by_meal_id,

48
ingredients/models.py Normal file
View file

@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Any, ClassVar, List, Optional
from pydantic import Field, field_validator
from common import ApiModel
from products import Product
from units import ALL_UNITS
class Ingredient(ApiModel):
KEYS: ClassVar[List[str]] = [
"id",
"name",
"line",
"preparation",
"unit",
"quantity",
"product_id",
"recipe_id",
"meal_id",
]
id: int = -1
name: str
line: str
unit: str = Field(
title="Unit",
description="Measurement unit (enum values are advisory; runtime accepts any string)",
json_schema_extra={"enum": [u.name for u in ALL_UNITS]},
)
quantity: float
preparation: str
product_id: Optional[int] = None
recipe_id: Optional[int] = None
meal_id: Optional[int] = None
product: Optional[Product] = None
# Ensure quantity is stored as a float even if provided as a string in tests
@field_validator("quantity", mode="before")
@classmethod
def _coerce_quantity(cls, v: Any) -> Any:
if isinstance(v, str):
try:
return float(v)
except ValueError:
return v
return v

View file

@ -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 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 | 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
from ingredients.models import Ingredient
from products.models import Product
async def create(conn):

285
main.py
View file

@ -1,21 +1,29 @@
import datetime
import os
from typing import Annotated, Dict, List, Optional, Any
from contextlib import asynccontextmanager
from typing import Any, Dict
import aiosqlite
from fastapi import Depends, FastAPI, APIRouter, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import Field
import db
import products
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 api.deps import get_db, cookie_person, error_response
class CamelCaseRoute(APIRoute):
@ -40,148 +48,10 @@ async def app_lifespan(app: FastAPI):
await client.aclose()
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
api_v1 = APIRouter(route_class=CamelCaseRoute)
DATABASE_PATH = settings.database_path
# 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):
# RFC7807 Problem Details handlers (standalone functions, registered in factory)
async def http_exc_handler(request: Request, exc: Exception):
# Narrow to StarletteHTTPException at runtime
assert isinstance(exc, StarletteHTTPException)
body = ProblemDetails(
title=str(exc.detail) if exc.detail else "HTTP Error",
status=exc.status_code,
@ -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: ValidationError):
async def validation_exc_handler(request: Request, exc: Exception):
assert isinstance(exc, ValidationError)
errors: Dict[str, Any] = {}
for e in exc.errors():
loc = ".".join([str(p) for p in e.get("loc", [])])
@ -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: RequestValidationError):
async def request_validation_exc_handler(request: Request, exc: Exception):
assert isinstance(exc, RequestValidationError)
errors: Dict[str, Any] = {}
for e in exc.errors():
loc = ".".join([str(p) for p in e.get("loc", [])])
@ -231,46 +101,71 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
)
# Mount versioned API router
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
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
class HealthStatus(ApiModel):
status: str = "ok"
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
# Back-compat shims removed; tests should import helpers from feature modules
async def healthz() -> HealthStatus:
return HealthStatus()
if settings.prod:
from fastapi.staticfiles import StaticFiles
def create_app() -> FastAPI:
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")
else:
# Proxy the request to the frontend development server
from starlette.background import BackgroundTask
from starlette.requests import Request
from starlette.responses import StreamingResponse
# OpenAPI augmentation
extend_with_problem_and_cookie_auth(app)
async def _reverse_proxy(request: Request):
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),
)
# Exception handlers
app.add_exception_handler(StarletteHTTPException, http_exc_handler)
app.add_exception_handler(ValidationError, validation_exc_handler)
app.add_exception_handler(RequestValidationError, request_validation_exc_handler)
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

View file

@ -1,6 +1,5 @@
from meals.db import (
Meal as Meal,
MealRecipe as MealRecipe,
from meals.models import Meal as Meal, MealRecipe as MealRecipe
from meals.repository import (
bulk_load_participants as bulk_load_participants,
create as create,
delete_meal as delete_meal,
@ -19,6 +18,10 @@ from meals.db import (
sync_meal_recipes as sync_meal_recipes,
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.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
View file

@ -0,0 +1,35 @@
from __future__ import annotations
import datetime
from typing import ClassVar, List, Optional
from pydantic import Field
from common import ApiModel
from ingredients import Ingredient
from persons.models import Person
from recipes import Recipe
class MealRecipe(ApiModel):
meal_id: int
recipe_id: int
servings: float
recipe: Optional[Recipe] = None
class Meal(ApiModel):
KEYS: ClassVar[List[str]] = ["id", "suggested_date", "consumed_date", "purchase_date"]
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[Person] = Field(default_factory=list)
cleanup: List[Person] = Field(default_factory=list)
consumers: List[Person] = Field(default_factory=list)
recipes: List[MealRecipe] = Field(default_factory=list)
extra_ingredients: List[Ingredient] = Field(default_factory=list)
# Set from shopping list
purchase_date: Optional[datetime.datetime] = None

View file

@ -1,44 +1,19 @@
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 (
Ingredient,
delete_ingredients_by_meal_id,
find_ingredients_by_meal_id,
insert_ingredient,
)
from persons import Person
from recipes import Recipe, load_recipe_ingredients, row_to_recipe
from meals.models import Meal, MealRecipe
from persons.models import Person
from persons.repository import get_by_ids as persons_get_by_ids
from recipes.models import Recipe
from recipes.repository import load_recipe_ingredients, row_to_recipe
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
from .roles import ROLE_CHEF, ROLE_CLEANUP, ROLE_CONSUMER
async def create(conn):

View file

@ -2,8 +2,8 @@ from __future__ import annotations
from typing import List, Set
from persons import Person
from meals.db import Meal
from meals.models import Meal
from persons.models import Person
def get_duplicates(items: List[Person]) -> Set[str]:

View file

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

View file

@ -1043,7 +1043,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/ShoppingListItem"
}
}
}
},
@ -1101,7 +1103,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Ok"
}
}
}
},
@ -1284,7 +1288,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/Person"
}
}
}
},
@ -1361,7 +1367,9 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
"schema": {
"$ref": "#/components/schemas/HealthStatus"
}
}
}
}
@ -1443,6 +1451,17 @@
"type": "object",
"title": "HTTPValidationError"
},
"HealthStatus": {
"properties": {
"status": {
"type": "string",
"title": "Status",
"default": "ok"
}
},
"type": "object",
"title": "HealthStatus"
},
"Ingredient": {
"properties": {
"id": {
@ -1481,14 +1500,7 @@
"description": "Measurement unit (enum values are advisory; runtime accepts any string)"
},
"quantity": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
}
],
"type": "number",
"title": "Quantity"
},
"preparation": {
@ -1797,6 +1809,17 @@
],
"title": "MealRecipe"
},
"Ok": {
"properties": {
"ok": {
"type": "boolean",
"title": "Ok",
"default": true
}
},
"type": "object",
"title": "Ok"
},
"Page_Person_": {
"properties": {
"items": {

View file

@ -1,15 +1,15 @@
from persons.db import (
Person as Person,
from persons.models import Person as Person
from persons.repository import (
compute_prev_cursor as compute_prev_cursor,
count_all as count_all,
count_by_name as count_by_name,
create as create,
get_all as get_all,
get_all_paged as get_all_paged,
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_name as get_by_name,
get_by_ids as get_by_ids,
get_by_name as get_by_name,
insert_person as insert_person,
search_by_name as search_by_name,
search_by_name_paged as search_by_name_paged,
)

10
persons/models.py Normal file
View file

@ -0,0 +1,10 @@
from typing import ClassVar, List
from common import ApiModel
class Person(ApiModel):
KEYS: ClassVar[List[str]] = ["id", "name"]
id: int = -1
name: str

View file

@ -1,13 +1,6 @@
from typing import AsyncIterator, ClassVar, List, Optional
from typing import AsyncIterator, List, Optional
from common import ApiModel
class Person(ApiModel):
KEYS: ClassVar[List[str]] = ["id", "name"]
id: int = -1
name: str
from persons.models import Person
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
LIMIT ?
"""
params = (f"%{name}%", first_id, limit)
from typing import Any
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
else:
query = """
SELECT id
@ -176,6 +170,7 @@ async def compute_prev_cursor(conn, first_id: int, limit: int, name: Optional[st
ORDER BY id DESC
LIMIT ?
"""
from typing import Any
params = (first_id, limit)
async with conn.execute(query, params) as c:

View file

@ -2,8 +2,8 @@ import json
from typing import List, Optional, Tuple
from products import coles, woolworths
from products.db import (
Product,
from products.models import Product
from products.repository import (
add_tag,
find_product_by_id as find_product_by_id,
find_product_by_key,

32
products/models.py Normal file
View 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

View file

@ -1,34 +1,7 @@
import json
from typing import AsyncIterator, ClassVar, List, Optional
from typing import AsyncIterator, 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
from products.models import Product
async def create(conn):

View file

@ -1,6 +1,6 @@
[tool.black]
line-length = 100
target-version = ["py310"]
target-version = ["py311"]
include = "\\.pyi?$"
[tool.ruff]
@ -25,7 +25,7 @@ combine-as-imports = true
known-first-party = ["ingredients", "meals", "persons", "products", "recipes", "shopping"]
[tool.mypy]
python_version = "3.10"
python_version = "3.11"
warn_unused_ignores = true
warn_redundant_casts = true
warn_unused_configs = true
@ -35,8 +35,6 @@ no_implicit_optional = true
check_untyped_defs = true
exclude = "^(\\.*/)?tests($|/)"
disable_error_code = ["import-untyped"]
[[tool.mypy.overrides]]
module = ["tests.*"]
ignore_errors = true

View file

@ -2,17 +2,17 @@ import re
from typing import Optional
from ingredients import match_existing_products, parse_ingredient_from_nlp
from persons import Person
from recipes.db import (
Recipe as Recipe,
from persons.models import Person
from recipes.models import Recipe as Recipe
from recipes.repository import (
compute_prev_cursor as compute_prev_cursor,
count_all as count_all,
count_by_name as count_by_name,
find_recipe_by_id as find_recipe_by_id,
find_recipes_by_name as find_recipes_by_name,
find_recipes_by_name_paged as find_recipes_by_name_paged,
get_all as get_all,
get_all_paged as get_all_paged,
compute_prev_cursor as compute_prev_cursor,
count_all as count_all,
count_by_name as count_by_name,
hide_recipe as hide_recipe,
insert_recipe as insert_recipe,
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]
return Recipe(
id=0,
id=-1,
name=name,
link=url,
serves=serves,

44
recipes/models.py Normal file
View file

@ -0,0 +1,44 @@
from __future__ import annotations
import datetime
from typing import ClassVar, List, Optional
from pydantic import Field
from common import ApiModel
from ingredients import Ingredient
from persons.models import Person
class Recipe(ApiModel):
KEYS: ClassVar[List[str]] = [
"id",
"name",
"link",
"serves",
"image_urls",
"based_on_recipe",
"created_by_id",
"date_created",
"hidden_by_id",
"date_hidden",
]
NON_INSERT_KEYS: ClassVar[List[str]] = ["id", "created_date", "hidden_by_id", "date_hidden"]
id: int = -1
name: str
link: str
serves: int
image_urls: List[str] = Field(default_factory=list)
ingredients: List[Ingredient] = Field(default_factory=list)
based_on_recipe: Optional[int] = None
date_created: datetime.datetime = Field(
default_factory=lambda: datetime.datetime.now().astimezone()
)
created_by_id: Optional[int]
created_by: Optional[Person] = None
date_hidden: Optional[datetime.datetime] = None
hidden_by_id: Optional[int] = None
hidden_by: Optional[Person] = None

View file

@ -1,46 +1,10 @@
import datetime
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 common import ApiModel
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
from ingredients import find_ingredients_by_recipe_id
from persons.models import Person
from recipes.models import Recipe
async def create(conn):
@ -200,22 +164,24 @@ async def compute_prev_cursor(
if limit <= 0:
return None
if name:
query = f"""
query = """
SELECT id
FROM Recipe
WHERE name LIKE ? AND date_hidden IS NULL AND id < ?
ORDER BY id DESC
LIMIT ?
"""
params = (f"%{name}%", first_id, limit)
from typing import Any
params: tuple[Any, ...] = (f"%{name}%", first_id, limit)
else:
query = f"""
query = """
SELECT id
FROM Recipe
WHERE date_hidden IS NULL AND id < ?
ORDER BY id DESC
LIMIT ?
"""
from typing import Any
params = (first_id, limit)
async with conn.execute(query, params) as c:
@ -227,7 +193,7 @@ async def compute_prev_cursor(
async def count_all(conn) -> int:
cursor = await conn.execute(
f"""
"""
SELECT COUNT(1)
FROM Recipe
WHERE date_hidden IS NULL
@ -239,7 +205,7 @@ async def count_all(conn) -> int:
async def count_by_name(conn, name: str) -> int:
cursor = await conn.execute(
f"""
"""
SELECT COUNT(1)
FROM Recipe
WHERE name LIKE ? AND date_hidden IS NULL

View file

@ -7,7 +7,7 @@ ROOT = os.path.dirname(os.path.dirname(__file__))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from main import app
from main import app # noqa: E402
if __name__ == "__main__":
with open("openapi.json", "w") as f:

View file

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

View file

@ -6,8 +6,8 @@ anywhere (including tests) without side effects.
"""
from __future__ import annotations
from dataclasses import dataclass
import os
from dataclasses import dataclass
@dataclass(frozen=True)

View file

@ -3,9 +3,8 @@ from typing import Any, Dict, Iterable, Iterator, List, Tuple
import ingredients
import meals
import recipes
from shopping.db import (
ShoppingList as ShoppingList,
ShoppingListItem as ShoppingListItem,
from shopping.models import ShoppingList as ShoppingList, ShoppingListItem as ShoppingListItem
from shopping.repository import (
find_items_by_list_id as _find_items_by_list_id,
get_purchased_ingredients as _get_purchased_ingredients,
is_requested as is_requested,
@ -14,6 +13,7 @@ from shopping.db import (
remove_request as remove_request,
request as request,
update_purchased_meals as update_purchased_meals,
validate_request as validate_request,
)

48
shopping/models.py Normal file
View file

@ -0,0 +1,48 @@
from datetime import datetime
from enum import Enum
from typing import ClassVar, List, Optional
from pydantic import Field
from common import BaseLinkedModel
from persons.models import Person
class ShoppingListItem(BaseLinkedModel):
KEYS: ClassVar[List[str]] = [
"id",
"ingredient_id",
"list_id",
"person_id",
"meal_id",
"recipe_id",
"created_date",
]
id: int = -1
list_id: Optional[int] = None
person_id: int = -1
ingredient_id: Optional[int] = None
recipe_id: Optional[int] = None
meal_id: Optional[int] = None
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
class StoreEnum(str, Enum):
woolworths = "woolworths"
coles = "coles"
home = ""
class ShoppingList(BaseLinkedModel):
KEYS: ClassVar[List[str]] = ["id", "created_date", "store_name"]
id: int = -1
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
store_name: StoreEnum = StoreEnum.home
purchased_by_id: int = -1
purchased_by: Optional[Person] = None
items: List[ShoppingListItem] = Field(default_factory=list)

View file

@ -1,53 +1,6 @@
from datetime import datetime
from enum import Enum
from typing import Any, AsyncIterator, ClassVar, List, Optional
from typing import Any, AsyncIterator, List, Optional
from pydantic import Field
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)
from shopping.models import ShoppingList, ShoppingListItem
async def create(conn):
@ -82,7 +35,9 @@ async def create(conn):
# Useful indexes for queries
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_list_id ON ShoppingListItem(list_id);")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_meal_id ON ShoppingListItem(meal_id);")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_shopping_item_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:
@ -101,6 +56,8 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
if shopping_list.items is None or len(shopping_list.items) == 0:
raise ValueError("Shopping list must have items")
from datetime import datetime
shopping_list.created_date = datetime.now().astimezone()
async with conn.execute(
@ -186,6 +143,8 @@ async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
purchased_ingredient_ids = {
item.ingredient_id async for item in get_purchased_ingredients(conn, meal_ids)
}
from meals.repository import find_meal_by_id, mark_purchased
for meal_id in meal_ids:
meal = await find_meal_by_id(conn, meal_id)
if not meal:
@ -202,7 +161,7 @@ async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
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:
return False
@ -218,8 +177,10 @@ async def is_requested(conn, meal: Meal) -> bool:
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:
from ingredients.repository import insert_ingredient
if ingredient is not None and meal is not None:
raise ValueError("Cannot request both an ingredient and a meal")
@ -256,9 +217,9 @@ async def request(
async def remove_request(
conn,
person: Optional[Person] = None,
meal: Optional[Meal] = None,
ingredient: Optional[Ingredient] = None,
person: Optional[Any] = None,
meal: Optional[Any] = None,
ingredient: Optional[Any] = None,
) -> bool:
if meal is not None:
async with conn.execute(

View file

@ -249,7 +249,7 @@ class Recipes:
)
from meals import db as meals_db
from meals import repository as meals_db
from datetime import datetime

View file

@ -12,8 +12,8 @@ def reload_test_data():
from db import connect, create
import ingredients
import ingredients.db as ingredients_db
import products.db as products_db
import ingredients.repository as ingredients_db
import products.repository as products_db
import units
@ -208,8 +208,8 @@ def reload_test_data():
from db import connect, create
import ingredients
import ingredients.db as ingredients_db
import products.db as products_db
import ingredients.repository as ingredients_db
import products.repository as products_db
import units

View file

@ -18,8 +18,8 @@ import main
from meals import get_duplicates
from api.meals import validate_meal
import meals
import meals.db as meals_db
from meals.db import Meal, MealRecipe
import meals.repository as meals_db
from meals.models import Meal, MealRecipe
import persons
import recipes
import ingredients
@ -499,7 +499,7 @@ class TestMainHelperFunctions(unittest.TestCase):
recipes=[],
extra_ingredients=[],
)
result = validate_meal(meal)
result = validate_meal(meal)
self.assertIsNotNone(result)
self.assertEqual(result.status_code, 400)

View file

@ -13,8 +13,8 @@ def reload_test_data():
from db import connect, create
import meals
import meals.db as meals_db
from meals.db import Meal, MealRecipe
import meals.repository as meals_db
from meals.models import Meal, MealRecipe
import persons
import recipes
import ingredients

View file

@ -2,7 +2,7 @@ import unittest
import tests.test_data as test_data
import products.db as products_db
import products.repository as products_db
from db import connect, create
import importlib

File diff suppressed because it is too large Load diff