diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..f07c359 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,2 @@ +# API package for FastAPI routers. +# Routers will be split by feature: recipes, meals, persons, shopping, auth. diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..e4b7ef2 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +import aiosqlite +from fastapi import APIRouter, Depends, Request + +import persons +from common import ProblemDetails +from main import get_db, cookie_person # temporary during extraction + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", operation_id="login", summary="Login and set user_id cookie", + responses={404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}}) +async def login( + data: Any, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None +) -> persons.Person: + raise NotImplementedError("login extraction pending") + + +@router.post("/refresh", operation_id="refresh", summary="Refresh current user from cookie") +async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: + raise NotImplementedError("current_user extraction pending") diff --git a/api/meals.py b/api/meals.py new file mode 100644 index 0000000..608b9a8 --- /dev/null +++ b/api/meals.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import List, Optional +import datetime + +import aiosqlite +from fastapi import APIRouter, Depends, Query, Request + +import meals +import persons +import shopping +from common import ProblemDetails +from main import get_db, cookie_person # temporary imports during extraction + +router = APIRouter(prefix="/meals", tags=["meals"]) + + +@router.get("/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range") +async def get_upcoming_meals( + date_from: datetime.datetime = Query(..., alias="from"), + to: datetime.datetime = Query(...), + conn: aiosqlite.Connection = Depends(get_db), +) -> List[meals.Meal]: + raise NotImplementedError("get_upcoming_meals extraction pending") + + +@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id", + responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) +async def get_meal( + meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None +) -> meals.Meal: + raise NotImplementedError("get_meal extraction pending") + + +@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal", + responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}}) +async def create_meal( + meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None +) -> meals.Meal: + raise NotImplementedError("create_meal extraction pending") + + +@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal", + responses={ + 400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}, + 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, + }) +async def update_meal( + meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None +) -> meals.Meal: + raise NotImplementedError("update_meal extraction pending") + + +@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed", + responses={ + 400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}, + 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, + }) +async def mark_consumed( + meal_id: int, + consumed_date: Optional[datetime.datetime] = None, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), + request: Request | None = None, +) -> meals.Meal: + raise NotImplementedError("mark_consumed extraction pending") + + +@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal", + responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) +async def delete_meal( + meal_id: int, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), + request: Request | None = None, +) -> meals.Meal: + raise NotImplementedError("delete_meal extraction pending") diff --git a/api/persons.py b/api/persons.py new file mode 100644 index 0000000..15a1f2d --- /dev/null +++ b/api/persons.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import List, Optional + +import aiosqlite +from fastapi import APIRouter, Depends, Query, Request + +import persons +from common import Page, ProblemDetails +from main import get_db # temporary during extraction + +router = APIRouter(prefix="/persons", tags=["persons"]) + + +@router.get("", response_model=Page[persons.Person], operation_id="listPersons", summary="List persons (paginated)") +async def list_persons( + q: Optional[str] = Query(default=None, description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future)."), + 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[persons.Person]: + raise NotImplementedError("list_persons extraction pending") + + +@router.post("", response_model=persons.Person, operation_id="createPerson", summary="Create a person") +async def create_person( + person: persons.Person, conn: aiosqlite.Connection = Depends(get_db) +) -> persons.Person: + raise NotImplementedError("create_person extraction pending") diff --git a/api/recipes.py b/api/recipes.py new file mode 100644 index 0000000..6029941 --- /dev/null +++ b/api/recipes.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import List, Optional + +import aiosqlite +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 + +router = APIRouter(prefix="/recipes", tags=["recipes"]) + + +@router.get("", response_model=Page[recipes.Recipe], operation_id="listRecipes", summary="List recipes (paginated)") +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)."), + 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") + + +@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": {}}}}) +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") + + +@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": {}}}}) +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") + + +@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": {}}}}) +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") + + +@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") diff --git a/api/shopping.py b/api/shopping.py new file mode 100644 index 0000000..8fc92de --- /dev/null +++ b/api/shopping.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Dict, List + +import aiosqlite +from fastapi import APIRouter, Depends, Request + +import ingredients +import meals +import persons +import recipes +import shopping +from common import ProblemDetails +from main import get_db, cookie_person # temporary during extraction + +router = APIRouter(prefix="/shopping", tags=["shopping"]) + + +@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list") +async def get_current_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), +): + raise NotImplementedError("get_current_shopping_list extraction pending") + + +@router.get("/{list_id}", 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, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None +): + raise NotImplementedError("get_shopping_list extraction pending") + + +@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list") +async def purchase_ingredients( + shopping_list: shopping.ShoppingList, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +): + raise NotImplementedError("purchase_ingredients extraction pending") + + +@router.get("/current/me/ingredients", operation_id="getMyShoppingList", summary="Get my outstanding ingredient requests") +async def get_my_shopping_list( + conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person) +) -> List[ingredients.Ingredient]: + raise NotImplementedError("get_my_shopping_list extraction pending") + + +@router.post("/current/me/ingredients", operation_id="syncMyShoppingList", summary="Sync my outstanding ingredient requests") +async def sync_my_shopping_list( + requests: List[ingredients.Ingredient], + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), +) -> List[ingredients.Ingredient]: + raise NotImplementedError("sync_my_shopping_list extraction pending") + + +@router.post("/current/meals/{meal_id}", 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( + meal_id: int, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), + request: Request | None = None, +): + raise NotImplementedError("request_meal extraction pending") + + +@router.delete("/current/meals/{meal_id}", 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, + conn: aiosqlite.Connection = Depends(get_db), + person: persons.Person = Depends(cookie_person), + request: Request | None = None, +): + raise NotImplementedError("unrequest_meal extraction pending") diff --git a/refactor-project-strategy.md b/refactor-project-strategy.md index 2f07a75..56719d1 100644 --- a/refactor-project-strategy.md +++ b/refactor-project-strategy.md @@ -142,6 +142,7 @@ Note: We can adopt this structure gradually without moving DB code immediately; ## Progress log - 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 --- diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..41baff9 --- /dev/null +++ b/settings.py @@ -0,0 +1,26 @@ +""" +Centralized runtime settings for the Doof backend. + +No external dependencies; reads from environment only so it can be imported +anywhere (including tests) without side effects. +""" +from __future__ import annotations + +from dataclasses import dataclass +import os + + +@dataclass(frozen=True) +class Settings: + # Database + database_path: str = os.environ.get("DOOF_DB", "./data/doof.sqlite") + + # Environment flags + prod: bool = os.environ.get("DOOF_PROD", "false").lower() in {"1", "true", "yes"} + + # Frontend dev server for reverse proxy in non-prod + frontend_dev_url: str = os.environ.get("FRONTEND_DEV_URL", "http://localhost:8080/") + + +# A module-level singleton for convenience imports +settings = Settings()