From edfc341b4b7d5a1bf268d24ea7ac5ca4f4658b97 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 18 Oct 2025 16:54:35 +1100 Subject: [PATCH] extract recipes --- api/recipes.py | 264 +++++++++++++++++++++++++++++------ main.py | 238 +------------------------------ refactor-project-strategy.md | 5 +- 3 files changed, 234 insertions(+), 273 deletions(-) diff --git a/api/recipes.py b/api/recipes.py index 6029941..d9fa957 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -8,70 +8,254 @@ from fastapi import APIRouter, Depends, Query, Request import ingredients import recipes import persons -from common import Page, ProblemDetails -from main import get_db, cookie_person, error_response # temporary imports during extraction +from common import Page, ProblemDetails, ApiModel +from pydantic import Field +from main import get_db, cookie_person, error_response + router = APIRouter(prefix="/recipes", tags=["recipes"]) -@router.get("", response_model=Page[recipes.Recipe], operation_id="listRecipes", summary="List recipes (paginated)") +class ProductUrl(ApiModel): + url: str + tags: List[str] = Field(default_factory=list) + + +@router.get( + "/parse", + response_model=None, + operation_id="parseRecipe", + summary="Parse a recipe from a URL", + responses={ + 400: { + "model": ProblemDetails, + "description": "Recipe not found", + "content": {"application/problem+json": {}}, + } + }, +) +async def parse_recipe_handler( + url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None +) -> recipes.Recipe | ProblemDetails: + parsed = await recipes.parse_recipe(conn, person, url) + if not parsed: + return error_response(request, 400, "Recipe not found") + return parsed + + +@router.get( + "/ingredients/parse", + operation_id="parseIngredients", + summary="Parse raw ingredient lines", +) +async def parse_ingredients( + lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"), + conn: aiosqlite.Connection = Depends(get_db), +) -> List[ingredients.Ingredient]: + had_links = False + result = [] + for line in lines: + ingredient = await ingredients.parse_ingredient_from_link(conn, line) + if ingredient: + result.append(ingredient) + had_links = True + continue + + ingredient = ingredients.parse_ingredient_from_nlp(line) + if ingredient: + result.append(ingredient) + continue + + if had_links: + await conn.commit() + + await ingredients.match_existing_products(conn, result) + return result + + +async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]: + r = await recipes.find_recipe_by_id(conn, id) + if not r: + return None + + r.ingredients = [] + async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id): + r.ingredients.append(ingredient) + + if r.created_by_id is not None: + r.created_by = await persons.get_by_id(conn, r.created_by_id) + + return r + + +@router.get( + "", + operation_id="listRecipes", + response_model=Page[recipes.Recipe], + summary="List recipes (paginated)", + responses={ + 200: { + "description": "A page of recipes", + "content": { + "application/json": { + "example": { + "items": [ + { + "id": 1, + "name": "Example Recipe", + "link": "https://example.com/recipes/1", + "serves": 4, + "imageUrls": [], + "ingredients": [] + } + ], + "nextCursor": "2", + "prevCursor": "0", + "total": 1 + } + } + }, + } + }, +) async def list_recipes( - q: Optional[str] = Query(default=None, description="Optional case-insensitive name filter (matches recipe name with SQL LIKE)."), - cursor: Optional[str] = Query(default=None, description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page."), - limit: int = Query(50, ge=1, le=200, description="Maximum number of items to return (1-200)."), + q: Optional[str] = Query( + default=None, + description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", + ), + cursor: Optional[str] = Query( + default=None, + description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", + ), + limit: int = Query( + 50, + ge=1, + le=200, + description="Maximum number of items to return (1-200).", + ), conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None, ) -> Page[recipes.Recipe]: - # Placeholder: implementation will be moved from main.get_recipes in a later step. - raise NotImplementedError("list_recipes extraction pending") + last_id = None + if cursor: + try: + last_id = int(cursor) + except ValueError: + last_id = None + + fetch_limit = limit + 1 + paged: List[recipes.Recipe] = [] + if q: + async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit): + paged.append(r) + else: + async for r in recipes.get_all_paged(conn, last_id, fetch_limit): + paged.append(r) + + has_more = len(paged) > limit + items = paged[:limit] + # load ingredients for items + for r in items: + r.ingredients = [] + async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id): + r.ingredients.append(ing) + next_cursor = str(items[-1].id) if has_more and items else None + # Compute prevCursor via DB helper + prev_cursor: Optional[str] = None + if items: + first_id = items[0].id + prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q) + total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn)) + return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total) -@router.get("/{recipe_id}", response_model=recipes.Recipe, operation_id="getRecipe", summary="Get a single recipe", - responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) +@router.get( + "/{recipe_id}", + response_model=None, + operation_id="getRecipe", + summary="Get a single recipe", + responses={ + 404: { + "model": ProblemDetails, + "description": "Recipe not found", + "content": {"application/problem+json": {}}, + } + }, +) async def get_recipe( recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None -) -> recipes.Recipe: - # Placeholder: implementation will be moved from main.get_recipe in a later step. - raise NotImplementedError("get_recipe extraction pending") +) -> recipes.Recipe | ProblemDetails: + r = await load_full_recipe(conn, recipe_id) + if not r: + return error_response(request, 404, "Recipe not found") + + return r -@router.post("", response_model=recipes.Recipe, operation_id="createRecipe", summary="Create a new recipe (versioning semantics applied)", - responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}}) +@router.post( + "", + response_model=None, + operation_id="createRecipe", + summary="Create a new recipe (versioning semantics applied)", + responses={ + 400: { + "model": ProblemDetails, + "description": "Validation error", + "content": {"application/problem+json": {}}, + } + }, +) async def create_recipe( recipe: recipes.Recipe, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), request: Request | None = None, -) -> recipes.Recipe: - # Placeholder: implementation will be moved from main.create_recipe in a later step. - raise NotImplementedError("create_recipe extraction pending") +) -> recipes.Recipe | ProblemDetails: + if not recipe.ingredients: + return error_response(request, 400, "Recipe must have at least one ingredient") + + if recipe.id >= 0: + await recipes.hide_recipe(conn, recipe.id, user) + recipe.based_on_recipe = recipe.id + recipe.id = 0 + + recipe.created_by_id = user.id + await recipes.insert_recipe(conn, recipe) + for ingredient in recipe.ingredients: + ingredient.recipe_id = recipe.id + if ingredient.product: + ingredient.product_id = ingredient.product.id + + await ingredients.insert_ingredient(conn, ingredient) + + await conn.commit() + + return recipe -@router.delete("/{recipe_id}", response_model=recipes.Recipe, operation_id="deleteRecipe", summary="Soft-delete (hide) a recipe", - responses={404: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) +@router.delete( + "/{recipe_id}", + response_model=None, + operation_id="deleteRecipe", + summary="Soft-delete (hide) a recipe", + responses={ + 404: { + "model": ProblemDetails, + "description": "Recipe not found", + "content": {"application/problem+json": {}}, + } + }, +) async def delete_recipe( recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), user: persons.Person = Depends(cookie_person), request: Request | None = None, -) -> recipes.Recipe: - # Placeholder: implementation will be moved from main.delete_recipe in a later step. - raise NotImplementedError("delete_recipe extraction pending") +) -> recipes.Recipe | ProblemDetails: + recipe = await recipes.find_recipe_by_id(conn, recipe_id) + if not recipe: + return error_response(request, 404, "Recipe not found") - -@router.get("/parse", response_model=None, operation_id="parseRecipe", summary="Parse a recipe from a URL", - responses={400: {"model": ProblemDetails, "description": "Recipe not found", "content": {"application/problem+json": {}}}}) -async def parse_recipe_handler( - url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None -) -> recipes.Recipe: - # Placeholder: implementation will be moved from main.parse_recipe_handler in a later step. - raise NotImplementedError("parse_recipe_handler extraction pending") - - -@router.get("/ingredients/parse", operation_id="parseIngredients", summary="Parse raw ingredient lines") -async def parse_ingredients( - lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"), - conn: aiosqlite.Connection = Depends(get_db), -) -> List[ingredients.Ingredient]: - # Placeholder: implementation will be moved from main.parse_ingredients in a later step. - raise NotImplementedError("parse_ingredients extraction pending") + await recipes.hide_recipe(conn, recipe_id, user) + await conn.commit() + return recipe diff --git a/main.py b/main.py index 8976979..6778ee2 100644 --- a/main.py +++ b/main.py @@ -134,61 +134,6 @@ def _extend_openapi_with_problem_responses(app: FastAPI) -> None: _extend_openapi_with_problem_responses(app) -@api_v1.get( - "/recipes/parse", - response_model=None, - operation_id="parseRecipe", - tags=["recipes"], - summary="Parse a recipe from a URL", - responses={ - 400: { - "model": ProblemDetails, - "description": "Recipe not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def parse_recipe_handler( - url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request = None -) -> recipes.Recipe | JSONResponse: - parsed = await recipes.parse_recipe(conn, person, url) - if not parsed: - return error_response(request, 400, "Recipe not found") - return parsed - - -@api_v1.get( - "/recipes/ingredients/parse", - operation_id="parseIngredients", - tags=["ingredients"], - summary="Parse raw ingredient lines", -) -async def parse_ingredients( - lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")], - conn: aiosqlite.Connection = Depends(get_db), -) -> List[ingredients.Ingredient]: - - had_links = False - result = [] - for line in lines: - ingredient = await ingredients.parse_ingredient_from_link(conn, line) - if ingredient: - result.append(ingredient) - had_links = True - continue - - ingredient = ingredients.parse_ingredient_from_nlp(line) - if ingredient: - result.append(ingredient) - continue - - if had_links: - await conn.commit() - - await ingredients.match_existing_products(conn, result) - return result - - class ProductUrl(ApiModel): url: str tags: List[str] = Field(default_factory=list) @@ -221,182 +166,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci return r -@api_v1.get( - "/recipes", - operation_id="listRecipes", - response_model=Page[recipes.Recipe], - tags=["recipes"], - summary="List recipes (paginated)", - responses={ - 200: { - "description": "A page of recipes", - "content": { - "application/json": { - "example": { - "items": [ - { - "id": 1, - "name": "Example Recipe", - "link": "https://example.com/recipes/1", - "serves": 4, - "imageUrls": [], - "ingredients": [] - } - ], - "nextCursor": "2", - "prevCursor": "0", - "total": 1 - } - } - }, - } - }, -) -async def get_recipes( - q: Optional[str] = Query( - default=None, - description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", - ), - cursor: Optional[str] = Query( - default=None, - description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", - ), - limit: int = Query( - 50, - ge=1, - le=200, - description="Maximum number of items to return (1-200).", - ), - conn: aiosqlite.Connection = Depends(get_db), - request: Request = None, -) -> List[recipes.Recipe] | Page[recipes.Recipe]: - # v1: DB-backed pagination using limit+1 strategy - last_id = None - if cursor: - try: - last_id = int(cursor) - except ValueError: - last_id = None - - fetch_limit = limit + 1 - paged: List[recipes.Recipe] = [] - if q: - async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit): - paged.append(r) - else: - async for r in recipes.get_all_paged(conn, last_id, fetch_limit): - paged.append(r) - - has_more = len(paged) > limit - items = paged[:limit] - # load ingredients for items - for r in items: - r.ingredients = [] - async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id): - r.ingredients.append(ing) - next_cursor = str(items[-1].id) if has_more and items else None - # Compute prevCursor via DB helper - prev_cursor: Optional[str] = None - if items: - first_id = items[0].id - prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q) - total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn)) - return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total) - - -@api_v1.get( - "/recipes/{recipe_id}", - response_model=None, - operation_id="getRecipe", - tags=["recipes"], - summary="Get a single recipe", - responses={ - 404: { - "model": ProblemDetails, - "description": "Recipe not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def get_recipe( - recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None -) -> recipes.Recipe | JSONResponse: - r = await load_full_recipe(conn, recipe_id) - if not r: - return error_response(request, 404, "Recipe not found") - - return r - - -@api_v1.post( - "/recipes", - response_model=None, - operation_id="createRecipe", - tags=["recipes"], - summary="Create a new recipe (versioning semantics applied)", - responses={ - 400: { - "model": ProblemDetails, - "description": "Validation error", - "content": {"application/problem+json": {}}, - } - }, -) -async def create_recipe( - recipe: recipes.Recipe, - conn: aiosqlite.Connection = Depends(get_db), - user: persons.Person = Depends(cookie_person), - request: Request = None, -) -> recipes.Recipe | JSONResponse: - if not recipe.ingredients: - return error_response(request, 400, "Recipe must have at least one ingredient") - - if recipe.id >= 0: - await recipes.hide_recipe(conn, recipe.id, user) - recipe.based_on_recipe = recipe.id - recipe.id = 0 - - recipe.created_by_id = user.id - await recipes.insert_recipe(conn, recipe) - for ingredient in recipe.ingredients: - ingredient.recipe_id = recipe.id - if ingredient.product: - ingredient.product_id = ingredient.product.id - - await ingredients.insert_ingredient(conn, ingredient) - - await conn.commit() - - return recipe - - -@api_v1.delete( - "/recipes/{recipe_id}", - response_model=None, - operation_id="deleteRecipe", - tags=["recipes"], - summary="Soft-delete (hide) a recipe", - responses={ - 404: { - "model": ProblemDetails, - "description": "Recipe not found", - "content": {"application/problem+json": {}}, - } - }, -) -async def delete_recipe( - recipe_id: int, - conn: aiosqlite.Connection = Depends(get_db), - user: persons.Person = Depends(cookie_person), - request: Request = None, -) -> recipes.Recipe | JSONResponse: - recipe = await recipes.find_recipe_by_id(conn, recipe_id) - if not recipe: - return error_response(request, 404, "Recipe not found") - - await recipes.hide_recipe(conn, recipe_id, user) - await conn.commit() - return recipe +from api import recipes as recipes_router # type: ignore @api_v1.get( @@ -1038,6 +808,12 @@ 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(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted + + +@app.get("/healthz") +async def healthz(): + return {"status": "ok"} if os.environ.get("DOOF_PROD", False): diff --git a/refactor-project-strategy.md b/refactor-project-strategy.md index 56719d1..c72c1a9 100644 --- a/refactor-project-strategy.md +++ b/refactor-project-strategy.md @@ -31,14 +31,14 @@ Acceptance criteria ## Phase 1 — API structure and lifecycle - [ ] Extract routers by feature - - [ ] api/recipes.py + - [x] api/recipes.py - [ ] api/meals.py - [ ] api/persons.py - [ ] api/shopping.py - [ ] api/auth.py - [ ] Wire routers in main with minimal app code - [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed -- [ ] Add /healthz endpoint (simple JSON: {"status": "ok"}) +- [x] Add /healthz endpoint (simple JSON: {"status": "ok"}) Acceptance criteria - main.py primarily wires app, routers, settings, and lifespan @@ -143,6 +143,7 @@ Note: We can adopt this structure gradually without moving DB code immediately; - 2025-10-18: Created strategy document and added settings.py (not yet wired) - 2025-10-18: Finalized router layout and health endpoint plan — Phase 0 complete - 2025-10-18: Created api package and scaffolded routers (recipes, meals, persons, shopping, auth) with placeholders +- 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz ---