diff --git a/.gitignore b/.gitignore index 68f6a86..ffc9eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 0caaf89..9b67961 100644 --- a/README.md +++ b/README.md @@ -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 . ``` diff --git a/api/auth.py b/api/auth.py index c86e0a3..8288fb6 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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", ) diff --git a/api/deps.py b/api/deps.py index 5f9b086..6154f43 100644 --- a/api/deps.py +++ b/api/deps.py @@ -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 diff --git a/api/meals.py b/api/meals.py index 8c67310..83197e5 100644 --- a/api/meals.py +++ b/api/meals.py @@ -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: diff --git a/api/openapi.py b/api/openapi.py new file mode 100644 index 0000000..e8e3332 --- /dev/null +++ b/api/openapi.py @@ -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] diff --git a/api/persons.py b/api/persons.py index b68758e..0b39638 100644 --- a/api/persons.py +++ b/api/persons.py @@ -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"]) diff --git a/api/products.py b/api/products.py index 36ec04e..dea618b 100644 --- a/api/products.py +++ b/api/products.py @@ -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"]) diff --git a/api/recipes.py b/api/recipes.py index 9f2eaac..4746bcd 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -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") diff --git a/api/shopping.py b/api/shopping.py index 84e6144..e3367aa 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -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 diff --git a/common.py b/common.py index c7f71fb..2fd2552 100644 --- a/common.py +++ b/common.py @@ -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: diff --git a/db.py b/db.py index ebbb2d2..82f2f6b 100644 --- a/db.py +++ b/db.py @@ -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) diff --git a/ingredients/__init__.py b/ingredients/__init__.py index 50217db..075675d 100644 --- a/ingredients/__init__.py +++ b/ingredients/__init__.py @@ -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, diff --git a/ingredients/models.py b/ingredients/models.py new file mode 100644 index 0000000..c9178ce --- /dev/null +++ b/ingredients/models.py @@ -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 diff --git a/ingredients/db.py b/ingredients/repository.py similarity index 82% rename from ingredients/db.py rename to ingredients/repository.py index e8096c4..9ece8c6 100644 --- a/ingredients/db.py +++ b/ingredients/repository.py @@ -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): diff --git a/main.py b/main.py index b80a0a3..672e720 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/meals/__init__.py b/meals/__init__.py index 8a997e4..a915d30 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -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 diff --git a/meals/models.py b/meals/models.py new file mode 100644 index 0000000..875371c --- /dev/null +++ b/meals/models.py @@ -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 diff --git a/meals/db.py b/meals/repository.py similarity index 90% rename from meals/db.py rename to meals/repository.py index d1facf7..aa9ca57 100644 --- a/meals/db.py +++ b/meals/repository.py @@ -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): diff --git a/meals/service.py b/meals/service.py index 1c0be01..8236505 100644 --- a/meals/service.py +++ b/meals/service.py @@ -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]: diff --git a/openapi-baseline.json b/openapi-baseline.json deleted file mode 100644 index 83caf71..0000000 --- a/openapi-baseline.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/openapi.json b/openapi.json index e3f5ca4..8d4b9c6 100644 --- a/openapi.json +++ b/openapi.json @@ -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": { diff --git a/persons/__init__.py b/persons/__init__.py index 33e0771..5482419 100644 --- a/persons/__init__.py +++ b/persons/__init__.py @@ -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, ) diff --git a/persons/models.py b/persons/models.py new file mode 100644 index 0000000..4edf92d --- /dev/null +++ b/persons/models.py @@ -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 diff --git a/persons/db.py b/persons/repository.py similarity index 95% rename from persons/db.py rename to persons/repository.py index 6635109..35436a0 100644 --- a/persons/db.py +++ b/persons/repository.py @@ -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: diff --git a/products/__init__.py b/products/__init__.py index 15dee66..43b6c9c 100644 --- a/products/__init__.py +++ b/products/__init__.py @@ -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, diff --git a/products/models.py b/products/models.py new file mode 100644 index 0000000..ba7d662 --- /dev/null +++ b/products/models.py @@ -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 diff --git a/products/db.py b/products/repository.py similarity index 83% rename from products/db.py rename to products/repository.py index 70d440d..285b890 100644 --- a/products/db.py +++ b/products/repository.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index ef21fec..9f4df9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/recipes/__init__.py b/recipes/__init__.py index 338039c..f51aa60 100644 --- a/recipes/__init__.py +++ b/recipes/__init__.py @@ -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, diff --git a/recipes/models.py b/recipes/models.py new file mode 100644 index 0000000..91d6423 --- /dev/null +++ b/recipes/models.py @@ -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 diff --git a/recipes/db.py b/recipes/repository.py similarity index 82% rename from recipes/db.py rename to recipes/repository.py index 6d90158..1a30a29 100644 --- a/recipes/db.py +++ b/recipes/repository.py @@ -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 diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py index 542fb42..a49158a 100644 --- a/scripts/export_openapi.py +++ b/scripts/export_openapi.py @@ -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: diff --git a/scripts/export_openapi_v1.py b/scripts/export_openapi_v1.py deleted file mode 100644 index ac65cf0..0000000 --- a/scripts/export_openapi_v1.py +++ /dev/null @@ -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") diff --git a/settings.py b/settings.py index 41baff9..778bb85 100644 --- a/settings.py +++ b/settings.py @@ -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) diff --git a/shopping/__init__.py b/shopping/__init__.py index 5f1a2e7..3a911b9 100644 --- a/shopping/__init__.py +++ b/shopping/__init__.py @@ -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, ) diff --git a/shopping/models.py b/shopping/models.py new file mode 100644 index 0000000..7e20640 --- /dev/null +++ b/shopping/models.py @@ -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) diff --git a/shopping/db.py b/shopping/repository.py similarity index 84% rename from shopping/db.py rename to shopping/repository.py index b763cd6..01841b7 100644 --- a/shopping/db.py +++ b/shopping/repository.py @@ -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( diff --git a/tests/test_data.py b/tests/test_data.py index ec73007..a222e3d 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -249,7 +249,7 @@ class Recipes: ) -from meals import db as meals_db +from meals import repository as meals_db from datetime import datetime diff --git a/tests/test_ingredients.py b/tests/test_ingredients.py index b3f0a68..f75e96e 100644 --- a/tests/test_ingredients.py +++ b/tests/test_ingredients.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py index 5e49c52..fc959a3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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) diff --git a/tests/test_meals.py b/tests/test_meals.py index 013cf44..5bbdce1 100644 --- a/tests/test_meals.py +++ b/tests/test_meals.py @@ -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 diff --git a/tests/test_products.py b/tests/test_products.py index 3a67bf5..03a6860 100644 --- a/tests/test_products.py +++ b/tests/test_products.py @@ -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 diff --git a/tests/test_shopping.py b/tests/test_shopping.py index cdd6609..6bb4606 100644 --- a/tests/test_shopping.py +++ b/tests/test_shopping.py @@ -1,10 +1,16 @@ +import importlib import unittest -import asyncio from datetime import datetime -from unittest.mock import AsyncMock, Mock, patch import tests.test_data as test_data -import importlib + +from db import connect, create +import shopping +from shopping.models import ShoppingList, ShoppingListItem, StoreEnum +import ingredients.repository as ingredients_repo +import ingredients +import meals +import recipes def reload_test_data(): @@ -12,35 +18,18 @@ def reload_test_data(): test_data = importlib.reload(test_data) -from db import connect, create -import shopping -import shopping.db as shopping_db -from shopping.db import ShoppingList, ShoppingListItem, StoreEnum -import ingredients.db as ingredients_db -import products.db as products_db -import persons -import meals -from meals.db import MealRecipe -import recipes - - -class TestShoppingModels(unittest.IsolatedAsyncioTestCase): - """Test the shopping data models""" - +class TestShopping(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() - return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() - return await super().asyncTearDown() - def test_shopping_list_item_creation(self): - """Test basic ShoppingListItem creation""" - ingredient = ingredients_db.Ingredient( + def test_validate_request(self): + ing = ingredients_repo.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", @@ -48,762 +37,78 @@ class TestShoppingModels(unittest.IsolatedAsyncioTestCase): quantity=500.0, preparation="chopped", ) + ok_item = ShoppingListItem(ingredient_id=ing.id, person_id=1) + shopping.validate_request(ok_item) - item = ShoppingListItem( - id=1, ingredient_id=ingredient.id, person_id=1, created_date=datetime.now() - ) + bad_person = ShoppingListItem(ingredient_id=ing.id, person_id=-1) + with self.assertRaises(ValueError): + shopping.validate_request(bad_person) - self.assertEqual(item.id, 1) - self.assertEqual(item.ingredient_id, 1) - self.assertEqual(item.person_id, 1) + missing_both = ShoppingListItem(person_id=1) + with self.assertRaises(ValueError): + shopping.validate_request(missing_both) - def test_shopping_list_creation(self): - """Test basic ShoppingList creation""" - shopping_list = ShoppingList(id=1, store_name=StoreEnum.woolworths, purchased_by_id=1) - - self.assertEqual(shopping_list.id, 1) - self.assertEqual(shopping_list.store_name, StoreEnum.woolworths) - self.assertEqual(shopping_list.purchased_by_id, 1) - self.assertEqual(shopping_list.items, []) - - def test_store_enum_values(self): - """Test StoreEnum values""" - self.assertEqual(StoreEnum.woolworths, "woolworths") - self.assertEqual(StoreEnum.coles, "coles") - self.assertEqual(StoreEnum.home, "") - - -class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): - """Test shopping validation functions""" - - async def asyncSetUp(self): - self.conn = await connect(":memory:") - await create(self.conn) - await test_data.create_persons(self.conn) - reload_test_data() - return await super().asyncSetUp() - - async def asyncTearDown(self) -> None: - await self.conn.close() - return await super().asyncTearDown() - - def test_validate_request_valid_ingredient(self): - """Test validation of valid ingredient request""" - ingredient = ingredients_db.Ingredient( - id=1, + async def test_request_and_purchase_ingredient(self): + ing = ingredients_repo.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) - item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) - - # Should not raise any exception - shopping_db.validate_request(item) - - def test_validate_request_valid_meal(self): - """Test validation of valid meal request""" - meal = meals.Meal(id=1, name="Dinner", suggested_date=datetime.now()) - item = ShoppingListItem(meal_id=meal.id, person_id=1) - - # Should not raise any exception - shopping_db.validate_request(item) - - def test_validate_request_no_person(self): - """Test validation fails when no person is specified""" - ingredient = ingredients_db.Ingredient( - id=1, - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - item = ShoppingListItem(ingredient_id=ingredient.id, person_id=-1) # Invalid person id - - with self.assertRaises(ValueError) as context: - shopping_db.validate_request(item) - self.assertIn("Requests must have a person", str(context.exception)) - - def test_validate_request_no_ingredient_or_meal(self): - """Test validation fails when neither ingredient nor meal is specified""" - item = ShoppingListItem(person_id=1) - - with self.assertRaises(ValueError) as context: - shopping_db.validate_request(item) - self.assertIn("Request must have either an ingredient or a meal", str(context.exception)) - - -class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): - """Test shopping request functionality""" - - async def asyncSetUp(self): - self.conn = await connect(":memory:") - await create(self.conn) - await test_data.create_persons(self.conn) - reload_test_data() - return await super().asyncSetUp() - - async def asyncTearDown(self) -> None: - await self.conn.close() - return await super().asyncTearDown() - - async def test_request_ingredient(self): - """Test requesting an ingredient""" - # Create test ingredient - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # Request the ingredient + await ingredients_repo.insert_ingredient(self.conn, ing) person = test_data.Persons.jacob - item = await shopping_db.request(self.conn, person, ingredient=ingredient) + req_item = await shopping.request(self.conn, person, ingredient=ing) + self.assertIsNotNone(req_item.id) - self.assertIsNotNone(item.id) - self.assertEqual(item.ingredient_id, ingredient.id) - self.assertEqual(item.person_id, person.id) - self.assertIsNone(item.meal_id) - - async def test_request_meal(self): - """Test requesting a meal""" - # Create a mock meal with proper structure - meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) - - with patch("shopping.db.is_requested", return_value=False): - person = test_data.Persons.jacob - item = await shopping_db.request(self.conn, person, meal=meal) - - self.assertIsNotNone(item.id) - self.assertEqual(item.meal_id, meal.id) - self.assertEqual(item.person_id, person.id) - self.assertIsNone(item.ingredient_id) - - async def test_request_both_ingredient_and_meal_fails(self): - """Test that requesting both ingredient and meal fails""" - ingredient = ingredients_db.Ingredient( - id=1, - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) - person = test_data.Persons.jacob - - with self.assertRaises(ValueError) as context: - await shopping_db.request(self.conn, person, ingredient=ingredient, meal=meal) - self.assertIn("Cannot request both an ingredient and a meal", str(context.exception)) - - async def test_request_neither_ingredient_nor_meal_fails(self): - """Test that requesting neither ingredient nor meal fails""" - person = test_data.Persons.jacob - - with self.assertRaises(ValueError) as context: - await shopping_db.request(self.conn, person) - self.assertIn( - "Must specify either an ingredient or a meal to request", str(context.exception) - ) - - async def test_request_meal_already_requested_fails(self): - """Test that requesting an already requested meal fails""" - meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) - person = test_data.Persons.jacob - - with patch("shopping.db.is_requested", return_value=True): - with self.assertRaises(ValueError) as context: - await shopping_db.request(self.conn, person, meal=meal) - self.assertIn("Meal is already requested", str(context.exception)) - - async def test_remove_request_meal(self): - """Test removing a meal request""" - # First create a meal request - meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) - person = test_data.Persons.jacob - - with patch("shopping.db.is_requested", return_value=False): - await shopping_db.request(self.conn, person, meal=meal) - - # Then remove it - result = await shopping_db.remove_request(self.conn, meal=meal) - self.assertTrue(result) - - async def test_remove_request_ingredient(self): - """Test removing an ingredient request""" - # Create test ingredient - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient) - - # Remove the request - result = await shopping_db.remove_request(self.conn, person=person, ingredient=ingredient) - self.assertTrue(result) - - async def test_remove_request_invalid_parameters_fails(self): - """Test that removing request with invalid parameters fails""" - person = test_data.Persons.jacob - - with self.assertRaises(ValueError) as context: - await shopping_db.remove_request(self.conn, person=person) - self.assertIn( - "Must specify either a meal or an ingredient to remove", str(context.exception) - ) - - -class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): - """Test shopping purchase functionality""" - - async def asyncSetUp(self): - self.conn = await connect(":memory:") - await create(self.conn) - await test_data.create_persons(self.conn) - reload_test_data() - return await super().asyncSetUp() - - async def asyncTearDown(self) -> None: - await self.conn.close() - return await super().asyncTearDown() - - async def test_purchase_shopping_list(self): - """Test purchasing a shopping list""" - # Create test ingredient - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # First create a request for the ingredient - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient) - - # Create shopping list item (this will reference the existing request) - item = ShoppingListItem(ingredient=ingredient, person_id=test_data.Persons.jacob.id) - - # Create shopping list - shopping_list = ShoppingList( + shop_item = ShoppingListItem(ingredient=ing, person_id=person.id) + s_list = ShoppingList( store_name=StoreEnum.woolworths, - purchased_by_id=test_data.Persons.jacob.id, - items=[item], + purchased_by_id=person.id, + items=[shop_item], ) + await shopping.purchase(self.conn, s_list) + self.assertIsNotNone(s_list.id) - await shopping_db.purchase(self.conn, shopping_list) + loaded = await shopping.load_shopping_list(self.conn, s_list.id) + self.assertIsNotNone(loaded) + self.assertEqual(loaded.id, s_list.id) - self.assertIsNotNone(shopping_list.id) - self.assertIsNotNone(item.list_id) - self.assertEqual(item.list_id, shopping_list.id) - - async def test_purchase_no_person_fails(self): - """Test that purchasing without a person fails""" - shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, purchased_by_id=-1, items=[] # Invalid person id + async def test_to_lookups_and_is_requested(self): + # Insert ingredient, recipe, meal + ing = ingredients_repo.Ingredient( + name="Carrot", + line="1 carrot", + unit="Items", + quantity=1.0, + preparation="", ) - - with self.assertRaises(ValueError) as context: - await shopping_db.purchase(self.conn, shopping_list) - self.assertIn("Shopping list must have a person id", str(context.exception)) - - async def test_purchase_no_items_fails(self): - """Test that purchasing with no items fails""" - shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, items=[] - ) - - with self.assertRaises(ValueError) as context: - await shopping_db.purchase(self.conn, shopping_list) - self.assertIn("Shopping list must have items", str(context.exception)) - - -class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): - """Test shopping helper functions""" - - async def asyncSetUp(self): - self.conn = await connect(":memory:") - await create(self.conn) - await test_data.create_persons(self.conn) - reload_test_data() - return await super().asyncSetUp() - - async def asyncTearDown(self) -> None: - await self.conn.close() - return await super().asyncTearDown() - - async def test_to_lookups(self): - """Test to_lookups function""" - # Create actual items with proper IDs - need to insert them first to get valid lookups - ingredient = ingredients_db.Ingredient( - id=1, - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - - # Insert the ingredient to get a valid ID - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # Insert test meal and recipe to get valid IDs - meal = meals.Meal(id=1, suggested_date=datetime.now()) - await meals.insert_meal(self.conn, meal) - - recipe = recipes.Recipe( - id=1, name="Test Recipe", link="http://example.com", serves=4, created_by_id=1 - ) - await recipes.insert_recipe(self.conn, recipe) - - items = [ - ShoppingListItem(ingredient_id=ingredient.id, meal_id=meal.id, recipe_id=recipe.id) - ] - - # Test the function - it should populate the lookups based on IDs - meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( - self.conn, items - ) - - # Check that the lookups contain our objects - self.assertEqual(len(meals_lookup), 1) - self.assertEqual(len(recipes_lookup), 1) - self.assertEqual(len(ingredients_lookup), 1) - self.assertEqual(meals_lookup[1].id, meal.id) - self.assertEqual(recipes_lookup[1].id, recipe.id) - self.assertEqual(ingredients_lookup[1].id, ingredient.id) - - # Items should still have their IDs - self.assertEqual(items[0].meal_id, meal.id) - self.assertEqual(items[0].recipe_id, recipe.id) - self.assertEqual(items[0].ingredient_id, ingredient.id) - - def test_flatten_items_with_meal(self): - """Test flatten_items function with meal items""" - # Create meal with recipes and ingredients - recipe_ingredient = ingredients_db.Ingredient( - id=1, - name="Recipe Ingredient", - line="500g recipe ingredient", - unit="g", - quantity=500.0, - preparation="chopped", - ) - extra_ingredient = ingredients_db.Ingredient( - id=2, - name="Extra Ingredient", - line="200g extra ingredient", - unit="g", - quantity=200.0, - preparation="diced", - ) - + await ingredients_repo.insert_ingredient(self.conn, ing) recipe = recipes.Recipe( id=1, name="Test Recipe", link="http://example.com", serves=4, created_by_id=1, - ingredients=[recipe_ingredient], ) - meal_recipe = MealRecipe(meal_id=1, recipe_id=1, servings=2.0, recipe=recipe) - - meal = meals.Meal( - id=1, - suggested_date=datetime.now(), - recipes=[meal_recipe], - extra_ingredients=[extra_ingredient], - ) - - item = ShoppingListItem(meal_id=meal.id, person_id=1) - - # Create lookups for flatten_items - meals_lookup = {meal.id: meal} - flattened = list(shopping.flatten_items([item], meals_lookup)) - - # Should have 2 items: one for recipe ingredient, one for extra ingredient - self.assertEqual(len(flattened), 2) - self.assertEqual(flattened[0].ingredient_id, 1) - self.assertEqual(flattened[1].ingredient_id, 2) - - def test_flatten_items_without_meal(self): - """Test flatten_items function with non-meal items""" - ingredient = ingredients_db.Ingredient( - id=1, - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) - - # Empty lookups since no meal/recipe is involved - flattened = list(shopping.flatten_items([item], {})) - - self.assertEqual(len(flattened), 1) - self.assertEqual(flattened[0], item) - - async def test_get_persons_requests(self): - """Test get_persons_requests function""" - person_id = 1 - - # Create actual ingredients and requests - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # Create a request for person 1 - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient) - - # Get the person's requests - requests = await shopping.get_persons_requests(self.conn, person_id) - - # Should have one request for the ingredient - self.assertEqual(len(requests), 1) - self.assertEqual(requests[0].id, ingredient.id) - - async def test_get_outstanding_requests(self): - """Test get_outstanding_requests function""" - # Create actual data - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # Create requests - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient) - - # Test the function - outstanding, purchased, meal_requests, _, _, _ = await shopping.get_outstanding_requests( - self.conn - ) - - # Should have one outstanding ingredient request - self.assertGreaterEqual(len(outstanding), 1) - self.assertEqual(len(purchased), 0) - self.assertEqual(len(meal_requests), 0) - - async def test_is_requested(self): - """Test is_requested function""" + await recipes.insert_recipe(self.conn, recipe) meal = meals.Meal(id=1, suggested_date=datetime.now()) - - # Test with a meal that hasn't been requested - result = await shopping_db.is_requested(self.conn, meal) - self.assertFalse(result) - - # Create a request for the meal - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, meal=meal) - - # Now it should be requested - result = await shopping_db.is_requested(self.conn, meal) - self.assertTrue(result) - - async def test_is_requested_invalid_meal(self): - """Test is_requested with invalid meal""" - meal = meals.Meal(id=-1, name="Invalid Meal", suggested_date=datetime.now()) - - result = await shopping_db.is_requested(self.conn, meal) - self.assertFalse(result) - - async def test_load_shopping_list(self): - """Test load_shopping_list function""" - # Create and purchase a shopping list first - ingredient = ingredients_db.Ingredient( - name="Broccoli", - line="500g fresh broccoli", - unit="g", - quantity=500.0, - preparation="chopped", - ) - await ingredients_db.insert_ingredient(self.conn, ingredient) - - # First create a request for the ingredient - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient) - - item = ShoppingListItem(ingredient_id=ingredient.id, person_id=test_data.Persons.jacob.id) - - shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, - purchased_by_id=test_data.Persons.jacob.id, - items=[item], - ) - - await shopping_db.purchase(self.conn, shopping_list) - - # Now load it back - loaded_list = await shopping_db.load_shopping_list(self.conn, shopping_list.id) - - self.assertIsNotNone(loaded_list) - self.assertEqual(loaded_list.id, shopping_list.id) - self.assertEqual(loaded_list.store_name, shopping_list.store_name) - self.assertEqual(len(loaded_list.items), 1) - - -class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): - """Test shopping complex edge cases for meal requests and purchases""" - - async def asyncSetUp(self): - self.conn = await connect(":memory:") - await create(self.conn) - await test_data.create_persons(self.conn) - reload_test_data() - return await super().asyncSetUp() - - async def asyncTearDown(self) -> None: - await self.conn.close() - return await super().asyncTearDown() - - async def test_meal_request_includes_ingredients_in_outstanding(self): - """Test that when user requests a meal, get_outstanding_requests includes the ingredients of that meal""" - # Create a recipe with ingredients - recipe = recipes.Recipe( - id=-1, - name="Test Pasta Recipe", - link="http://example.com/pasta", - serves=4, - created_by_id=test_data.Persons.jacob.id, - ) - await recipes.insert_recipe(self.conn, recipe) - - # Create ingredients for the recipe - pasta_ingredient = ingredients_db.Ingredient( - name="Pasta", - line="500g pasta", - unit="g", - quantity=500.0, - preparation="", - recipe_id=recipe.id, - ) - tomato_ingredient = ingredients_db.Ingredient( - name="Tomatoes", - line="400g canned tomatoes", - unit="g", - quantity=400.0, - preparation="", - recipe_id=recipe.id, - ) - - await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) - await ingredients_db.insert_ingredient(self.conn, tomato_ingredient) - - # Load the recipe with its ingredients - await recipes.load_recipe_ingredients(self.conn, recipe) - - # Create a meal with this recipe and extra ingredients - extra_ingredient = ingredients_db.Ingredient( - name="Garlic Bread", - line="1 loaf garlic bread", - unit="loaf", - quantity=1.0, - preparation="", - ) - - meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) - - meal = meals.Meal( - id=-1, - suggested_date=datetime.now(), - chefs=[test_data.Persons.jacob], - cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie], - recipes=[meal_recipe], - extra_ingredients=[extra_ingredient], - ) - - # Insert the meal await meals.insert_meal(self.conn, meal) - # Request the meal + items = [ShoppingListItem(ingredient_id=ing.id, meal_id=meal.id, recipe_id=recipe.id)] + meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( + self.conn, items + ) + self.assertIn(meal.id, meals_lookup) + self.assertIn(recipe.id, recipes_lookup) + self.assertIn(ing.id, ingredients_lookup) + person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, meal=meal) - - # Get outstanding requests - ( - outstanding, - purchased, - meal_requests, - meals_lookup, - recipes_lookup, - ingredients_lookup, - ) = await shopping.get_outstanding_requests(self.conn) - - # Should have one meal request - self.assertEqual(len(meal_requests), 1) - self.assertEqual(meal_requests[0].meal_id, meal.id) - - # Should have 3 outstanding items: 2 from recipe + 1 extra ingredient - self.assertEqual(len(outstanding), 3) - - # Check that all ingredients are included - ingredient_names = { - ingredients_lookup[item.ingredient_id].name - for item in outstanding - if item.ingredient_id in ingredients_lookup - } - self.assertIn("Pasta", ingredient_names) - self.assertIn("Tomatoes", ingredient_names) - self.assertIn("Garlic Bread", ingredient_names) - - # All should be associated with the meal - for item in outstanding: - self.assertEqual(item.meal_id, meal.id) - - async def test_partial_meal_purchase_moves_item_to_purchased(self): - """Test that a user can purchase an individual item from a meal, moving it to purchased items""" - # Create a recipe with multiple ingredients - recipe = recipes.Recipe( - id=-1, - name="Multi-Ingredient Recipe", - link="http://example.com/multi", - serves=4, - created_by_id=test_data.Persons.jacob.id, - ) - await recipes.insert_recipe(self.conn, recipe) - - # Create multiple ingredients for the recipe - ingredient1 = ingredients_db.Ingredient( - name="Rice", - line="200g rice", - unit="g", - quantity=200.0, - preparation="", - recipe_id=recipe.id, - ) - ingredient2 = ingredients_db.Ingredient( - name="Chicken", - line="300g chicken breast", - unit="g", - quantity=300.0, - preparation="", - recipe_id=recipe.id, - ) - ingredient3 = ingredients_db.Ingredient( - name="Vegetables", - line="150g mixed vegetables", - unit="g", - quantity=150.0, - preparation="", - recipe_id=recipe.id, - ) - - await ingredients_db.insert_ingredient(self.conn, ingredient1) - await ingredients_db.insert_ingredient(self.conn, ingredient2) - await ingredients_db.insert_ingredient(self.conn, ingredient3) - - # Load the recipe with its ingredients - await recipes.load_recipe_ingredients(self.conn, recipe) - - # Create and insert a meal - meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) - - meal = meals.Meal( - id=-1, - suggested_date=datetime.now(), - chefs=[test_data.Persons.jacob], - cleanup=[test_data.Persons.ryan], - consumers=[test_data.Persons.ellie], - recipes=[meal_recipe], - ) - - await meals.insert_meal(self.conn, meal) - - # Request the meal - person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, meal=meal) - - # Get initial outstanding requests - ( - outstanding_before, - purchased_before, - meal_requests_before, - meals_lookup, - recipes_lookup, - ingredients_lookup, - ) = await shopping.get_outstanding_requests(self.conn) - self.assertEqual(len(outstanding_before), 3) # All 3 ingredients - self.assertEqual(len(purchased_before), 0) # Nothing purchased yet - - # Purchase only one ingredient (Rice) from the meal - rice_item = None - for item in outstanding_before: - if ( - item.ingredient_id in ingredients_lookup - and ingredients_lookup[item.ingredient_id].name == "Rice" - ): - rice_item = ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id, - meal_id=meal.id, - recipe_id=recipe.id, - ) - break - - self.assertIsNotNone(rice_item) - - # Create and purchase a shopping list with just the rice - shopping_list = ShoppingList( - store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[rice_item] - ) - - await shopping_db.purchase(self.conn, shopping_list) - - # Get outstanding requests after purchase - ( - outstanding_after, - purchased_after, - meal_requests_after, - meals_lookup2, - recipes_lookup2, - ingredients_lookup2, - ) = await shopping.get_outstanding_requests(self.conn) - - # Should have 2 outstanding items (Chicken and Vegetables) - self.assertEqual(len(outstanding_after), 2) - outstanding_names = { - ingredients_lookup2[item.ingredient_id].name - for item in outstanding_after - if item.ingredient_id in ingredients_lookup2 - } - self.assertIn("Chicken", outstanding_names) - self.assertIn("Vegetables", outstanding_names) - self.assertNotIn("Rice", outstanding_names) - - # Should have 1 purchased item (Rice) - self.assertEqual(len(purchased_after), 1) - self.assertEqual(purchased_after[0].ingredient_id, ingredient1.id) - - # Meal should still be requested (not all ingredients purchased) - self.assertEqual(len(meal_requests_after), 1) + self.assertFalse(await shopping.is_requested(self.conn, meal)) + await shopping.request(self.conn, person, meal=meal) + self.assertTrue(await shopping.is_requested(self.conn, meal)) async def test_complete_meal_purchase_unrequests_and_marks_purchased(self): - """Test that when all items of a meal are purchased, the meal is unrequested and marked as purchased""" # Create a simple recipe with 2 ingredients recipe = recipes.Recipe( id=-1, @@ -815,7 +120,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): await recipes.insert_recipe(self.conn, recipe) # Create ingredients for the recipe - ingredient1 = ingredients_db.Ingredient( + ingredient1 = ingredients.Ingredient( name="Bread", line="2 slices bread", unit="slices", @@ -823,7 +128,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): preparation="", recipe_id=recipe.id, ) - ingredient2 = ingredients_db.Ingredient( + ingredient2 = ingredients.Ingredient( name="Butter", line="10g butter", unit="g", @@ -832,15 +137,14 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): recipe_id=recipe.id, ) - await ingredients_db.insert_ingredient(self.conn, ingredient1) - await ingredients_db.insert_ingredient(self.conn, ingredient2) + await ingredients.insert_ingredient(self.conn, ingredient1) + await ingredients.insert_ingredient(self.conn, ingredient2) # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) # Create and insert a meal - meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) - + meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), @@ -849,16 +153,12 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): consumers=[test_data.Persons.ellie], recipes=[meal_recipe], ) - await meals.insert_meal(self.conn, meal) # Request the meal person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, meal=meal) - - # Verify meal is requested - is_requested_before = await shopping_db.is_requested(self.conn, meal) - self.assertTrue(is_requested_before) + await shopping.request(self.conn, person, meal=meal) + self.assertTrue(await shopping.is_requested(self.conn, meal)) # Get initial outstanding requests ( @@ -869,40 +169,30 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) - self.assertEqual(len(outstanding_before), 2) # Both ingredients - self.assertEqual(len(meal_requests_before), 1) # Meal is requested - - # Verify the meal is not marked as purchased yet - found_meal_before = await meals.find_meal_by_id(self.conn, meal.id) - self.assertIsNone(found_meal_before.purchase_date) + self.assertEqual(len(outstanding_before), 2) + self.assertEqual(len(meal_requests_before), 1) # Purchase all ingredients from the meal - shopping_items = [] - for item in outstanding_before: - shopping_items.append( - ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id, - meal_id=meal.id, - recipe_id=recipe.id, - ) + shopping_items = [ + ShoppingListItem( + ingredient_id=item.ingredient_id, + person_id=person.id, + meal_id=meal.id, + recipe_id=item.recipe_id, ) - + for item in outstanding_before + ] shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) + await shopping.purchase(self.conn, shopping_list) - await shopping_db.purchase(self.conn, shopping_list) - - # Verify meal is no longer requested - is_requested_after = await shopping_db.is_requested(self.conn, meal) - self.assertFalse(is_requested_after) - - # Verify meal is marked as purchased + # Verify meal is no longer requested and is marked as purchased + self.assertFalse(await shopping.is_requested(self.conn, meal)) found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) - # Get outstanding requests after complete purchase + # After complete purchase, there should be no outstanding/purchased items for that meal ( outstanding_after, purchased_after, @@ -911,18 +201,11 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): _, _, ) = await shopping.get_outstanding_requests(self.conn) - - # Should have no outstanding items from this meal self.assertEqual(len(outstanding_after), 0) - - # Should have no purchased items (meal is complete so ingredients don't appear) self.assertEqual(len(purchased_after), 0) - - # Should have no meal requests self.assertEqual(len(meal_requests_after), 0) async def test_complete_meal_with_extra_ingredients_purchase(self): - """Test that meals with both recipe ingredients and extra ingredients are properly handled""" # Create a recipe with 1 ingredient recipe = recipes.Recipe( id=-1, @@ -933,8 +216,8 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): ) await recipes.insert_recipe(self.conn, recipe) - # Create recipe ingredient - recipe_ingredient = ingredients_db.Ingredient( + # Recipe ingredient + recipe_ingredient = ingredients.Ingredient( name="Main Ingredient", line="200g main ingredient", unit="g", @@ -942,18 +225,15 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): preparation="", recipe_id=recipe.id, ) - - await ingredients_db.insert_ingredient(self.conn, recipe_ingredient) + await ingredients.insert_ingredient(self.conn, recipe_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) - # Create extra ingredient (not part of recipe) - extra_ingredient = ingredients_db.Ingredient( + # Extra ingredient (not part of recipe) + extra_ingredient = ingredients.Ingredient( name="Side Dish", line="1 side dish", unit="item", quantity=1.0, preparation="" ) - # Create and insert a meal with both recipe and extra ingredients - meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) - + meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), @@ -963,51 +243,39 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): recipes=[meal_recipe], extra_ingredients=[extra_ingredient], ) - await meals.insert_meal(self.conn, meal) - # Request the meal person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, meal=meal) + await shopping.request(self.conn, person, meal=meal) - # Get initial outstanding requests ( outstanding_before, - purchased_before, - meal_requests_before, - meals_lookup, - recipes_lookup, - ingredients_lookup, + _, + _, + _, + _, + _, ) = await shopping.get_outstanding_requests(self.conn) - self.assertEqual(len(outstanding_before), 2) # Recipe ingredient + extra ingredient - self.assertEqual(len(meal_requests_before), 1) + self.assertEqual(len(outstanding_before), 2) # recipe + extra - # Purchase all ingredients - shopping_items = [] - for item in outstanding_before: - shopping_items.append( - ShoppingListItem( - ingredient_id=item.ingredient_id, - person_id=person.id, - meal_id=meal.id, - recipe_id=item.recipe_id, - ) + shopping_items = [ + ShoppingListItem( + ingredient_id=item.ingredient_id, + person_id=person.id, + meal_id=meal.id, + recipe_id=item.recipe_id, ) - + for item in outstanding_before + ] shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) + await shopping.purchase(self.conn, shopping_list) - await shopping_db.purchase(self.conn, shopping_list) - - # Verify meal is unrequested and marked as purchased - is_requested_after = await shopping_db.is_requested(self.conn, meal) - self.assertFalse(is_requested_after) - + self.assertFalse(await shopping.is_requested(self.conn, meal)) found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) - # Get final state ( outstanding_after, purchased_after, @@ -1017,28 +285,26 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): _, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 0) - self.assertEqual(len(purchased_after), 0) # No purchased items since meal is complete + self.assertEqual(len(purchased_after), 0) self.assertEqual(len(meal_requests_after), 0) async def test_individual_ingredient_purchase_without_meal(self): - """Test purchasing individual ingredients that are not part of a meal""" # Create individual ingredients - ingredient1 = ingredients_db.Ingredient( + ingredient1 = ingredients.Ingredient( name="Milk", line="1L milk", unit="L", quantity=1.0, preparation="" ) - ingredient2 = ingredients_db.Ingredient( + ingredient2 = ingredients.Ingredient( name="Eggs", line="12 eggs", unit="dozen", quantity=1.0, preparation="" ) - await ingredients_db.insert_ingredient(self.conn, ingredient1) - await ingredients_db.insert_ingredient(self.conn, ingredient2) + await ingredients.insert_ingredient(self.conn, ingredient1) + await ingredients.insert_ingredient(self.conn, ingredient2) - # Request individual ingredients (not part of any meal) + # Request individual ingredients person = test_data.Persons.jacob - await shopping_db.request(self.conn, person, ingredient=ingredient1) - await shopping_db.request(self.conn, person, ingredient=ingredient2) + await shopping.request(self.conn, person, ingredient=ingredient1) + await shopping.request(self.conn, person, ingredient=ingredient2) - # Verify both ingredients appear in outstanding requests ( outstanding_before, purchased_before, @@ -1049,41 +315,19 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) self.assertEqual(len(purchased_before), 0) - self.assertEqual(len(meal_requests_before), 0) # No meal requests - - # Verify the ingredients in outstanding requests - ingredient_names = { - ingredients_lookup[item.ingredient_id].name - for item in outstanding_before - if item.ingredient_id in ingredients_lookup - } - self.assertIn("Milk", ingredient_names) - self.assertIn("Eggs", ingredient_names) - - # All should be individual requests (no meal_id) - for item in outstanding_before: - self.assertIsNone(item.meal_id) - self.assertEqual(item.person_id, person.id) - - # Purchase only one ingredient (Milk) - milk_item = None - for item in outstanding_before: - if ( - item.ingredient_id in ingredients_lookup - and ingredients_lookup[item.ingredient_id].name == "Milk" - ): - milk_item = ShoppingListItem(ingredient_id=item.ingredient_id, person_id=person.id) - break - - self.assertIsNotNone(milk_item) + self.assertEqual(len(meal_requests_before), 0) + # Purchase only Milk + milk_item = next( + ShoppingListItem(ingredient_id=i.ingredient_id, person_id=person.id) + for i in outstanding_before + if ingredients_lookup.get(i.ingredient_id).name == "Milk" + ) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[milk_item] ) + await shopping.purchase(self.conn, shopping_list) - await shopping_db.purchase(self.conn, shopping_list) - - # Verify only eggs remains in outstanding, milk is purchased ( outstanding_after, purchased_after, @@ -1093,27 +337,20 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): ingredients_lookup_after, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 1) - self.assertEqual( - len(purchased_after), 0 - ) # Individual purchases don't appear in purchased list + self.assertEqual(len(purchased_after), 0) self.assertEqual(len(meal_requests_after), 0) - - # Verify only eggs remains self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Eggs") self.assertIsNone(outstanding_after[0].meal_id) - # Purchase the remaining ingredient (Eggs) + # Purchase remaining Eggs eggs_item = ShoppingListItem( ingredient_id=outstanding_after[0].ingredient_id, person_id=person.id ) - shopping_list2 = ShoppingList( store_name=StoreEnum.coles, purchased_by_id=person.id, items=[eggs_item] ) + await shopping.purchase(self.conn, shopping_list2) - await shopping_db.purchase(self.conn, shopping_list2) - - # Verify no outstanding requests remain ( outstanding_final, purchased_final, @@ -1127,8 +364,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(meal_requests_final), 0) async def test_mixed_meal_and_individual_requests(self): - """Test a mix of meal requests and individual ingredient requests""" - # Create a simple recipe and meal + # Create recipe and meal recipe = recipes.Recipe( id=-1, name="Simple Pasta", @@ -1138,8 +374,7 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): ) await recipes.insert_recipe(self.conn, recipe) - # Create recipe ingredient - pasta_ingredient = ingredients_db.Ingredient( + pasta_ingredient = ingredients.Ingredient( name="Pasta", line="200g pasta", unit="g", @@ -1147,12 +382,10 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): preparation="", recipe_id=recipe.id, ) - await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) + await ingredients.insert_ingredient(self.conn, pasta_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) - # Create meal - meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) - + meal_recipe = meals.MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), @@ -1161,22 +394,17 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): consumers=[test_data.Persons.ellie], recipes=[meal_recipe], ) - await meals.insert_meal(self.conn, meal) - # Create individual ingredient - snack_ingredient = ingredients_db.Ingredient( + snack_ingredient = ingredients.Ingredient( name="Chips", line="1 bag chips", unit="bag", quantity=1.0, preparation="" ) - await ingredients_db.insert_ingredient(self.conn, snack_ingredient) + await ingredients.insert_ingredient(self.conn, snack_ingredient) person = test_data.Persons.jacob + await shopping.request(self.conn, person, meal=meal) + await shopping.request(self.conn, person, ingredient=snack_ingredient) - # Request both meal and individual ingredient - await shopping_db.request(self.conn, person, meal=meal) - await shopping_db.request(self.conn, person, ingredient=snack_ingredient) - - # Verify we have both meal and individual requests ( outstanding_before, purchased_before, @@ -1185,47 +413,31 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) - self.assertEqual(len(outstanding_before), 2) # Pasta from meal + Chips individual + self.assertEqual(len(outstanding_before), 2) self.assertEqual(len(purchased_before), 0) - self.assertEqual(len(meal_requests_before), 1) # One meal request - - # Verify the mix of ingredients - ingredient_names = { - ingredients_lookup[item.ingredient_id].name - for item in outstanding_before - if item.ingredient_id in ingredients_lookup - } - self.assertIn("Pasta", ingredient_names) - self.assertIn("Chips", ingredient_names) - - # Check that pasta is from meal, chips is individual - pasta_item = None - chips_item = None - for item in outstanding_before: - if item.ingredient_id in ingredients_lookup: - ingredient_name = ingredients_lookup[item.ingredient_id].name - if ingredient_name == "Pasta": - pasta_item = item - elif ingredient_name == "Chips": - chips_item = item + self.assertEqual(len(meal_requests_before), 1) + # Identify items + pasta_item = next( + i for i in outstanding_before if ingredients_lookup.get(i.ingredient_id).name == "Pasta" + ) + chips_item = next( + i for i in outstanding_before if ingredients_lookup.get(i.ingredient_id).name == "Chips" + ) self.assertIsNotNone(pasta_item) self.assertIsNotNone(chips_item) self.assertEqual(pasta_item.meal_id, meal.id) self.assertIsNone(chips_item.meal_id) - # Purchase the individual ingredient (Chips) + # Purchase only Chips chips_shopping_item = ShoppingListItem( ingredient_id=chips_item.ingredient_id, person_id=person.id ) - shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[chips_shopping_item] ) + await shopping.purchase(self.conn, shopping_list) - await shopping_db.purchase(self.conn, shopping_list) - - # Verify only meal ingredient remains ( outstanding_after, purchased_after, @@ -1234,13 +446,8 @@ class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): recipes_lookup_after, ingredients_lookup_after, ) = await shopping.get_outstanding_requests(self.conn) - self.assertEqual(len(outstanding_after), 1) # Only pasta from meal + self.assertEqual(len(outstanding_after), 1) self.assertEqual(len(purchased_after), 0) - self.assertEqual(len(meal_requests_after), 1) # Meal still requested - + self.assertEqual(len(meal_requests_after), 1) self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Pasta") self.assertEqual(outstanding_after[0].meal_id, meal.id) - - -if __name__ == "__main__": - unittest.main()