Removed redundant code from master and route to new submodules

This commit is contained in:
jableader 2025-10-18 17:52:02 +11:00
parent f51c90f922
commit a3c0a2701e
7 changed files with 52 additions and 609 deletions

View file

@ -25,7 +25,7 @@ class LoginBody(ApiModel):
404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}} 404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}
}, },
) )
async def login(data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> persons.Person | JSONResponse: async def login(request: Request, data: LoginBody, conn: aiosqlite.Connection = Depends(get_db)) -> persons.Person | JSONResponse:
person = await persons.get_by_name(conn, data.username) person = await persons.get_by_name(conn, data.username)
if not person: if not person:
return error_response(request, 404, "Person not found") return error_response(request, 404, "Person not found")

View file

@ -37,7 +37,7 @@ async def get_upcoming_meals(
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id", @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": {}}}}) responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def get_meal( async def get_meal(
meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal: ) -> meals.Meal:
meal = await meals.find_meal_by_id(conn, meal_id) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: if not meal:
@ -49,7 +49,7 @@ async def get_meal(
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal", @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": {}}}}) responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
async def create_meal( async def create_meal(
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal: ) -> meals.Meal:
validation_response = validate_meal(meal, request) validation_response = validate_meal(meal, request)
if validation_response: if validation_response:
@ -66,7 +66,7 @@ async def create_meal(
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}, 404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
}) })
async def update_meal( async def update_meal(
meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal: ) -> meals.Meal:
if meal.id != meal_id: if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body") return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
@ -92,10 +92,10 @@ async def update_meal(
}) })
async def mark_consumed( async def mark_consumed(
meal_id: int, meal_id: int,
request: Request,
consumed_date: Optional[datetime.datetime] = None, consumed_date: Optional[datetime.datetime] = None,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person), person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal: ) -> meals.Meal:
if consumed_date and not consumed_date.tzinfo: if consumed_date and not consumed_date.tzinfo:
return error_response(request, 400, "Consumed date must include timezone") return error_response(request, 400, "Consumed date must include timezone")
@ -115,9 +115,9 @@ async def mark_consumed(
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}}) responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def delete_meal( async def delete_meal(
meal_id: int, meal_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person), person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal: ) -> meals.Meal:
meal = await meals.find_meal_by_id(conn, meal_id) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: if not meal:

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query
import persons import persons
from common import Page from common import Page
@ -54,7 +54,6 @@ async def list_persons(
description="Maximum number of items to return (1-200).", description="Maximum number of items to return (1-200).",
), ),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None,
) -> Page[persons.Person]: ) -> Page[persons.Person]:
# v1: DB-backed pagination # v1: DB-backed pagination
last_id = None last_id = None

View file

@ -8,19 +8,12 @@ from fastapi import APIRouter, Depends, Query, Request
import ingredients import ingredients
import recipes import recipes
import persons import persons
from common import Page, ProblemDetails, ApiModel from common import Page, ProblemDetails
from pydantic import Field
from api.deps import get_db, cookie_person, error_response from api.deps import get_db, cookie_person, error_response
router = APIRouter(prefix="/recipes", tags=["recipes"]) router = APIRouter(prefix="/recipes", tags=["recipes"])
class ProductUrl(ApiModel):
url: str
tags: List[str] = Field(default_factory=list)
@router.get( @router.get(
"/parse", "/parse",
response_model=None, response_model=None,
@ -35,7 +28,7 @@ class ProductUrl(ApiModel):
}, },
) )
async def parse_recipe_handler( async def parse_recipe_handler(
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person)
) -> recipes.Recipe | ProblemDetails: ) -> recipes.Recipe | ProblemDetails:
parsed = await recipes.parse_recipe(conn, person, url) parsed = await recipes.parse_recipe(conn, person, url)
if not parsed: if not parsed:
@ -119,6 +112,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
}, },
) )
async def list_recipes( async def list_recipes(
request: Request,
q: Optional[str] = Query( q: Optional[str] = Query(
default=None, default=None,
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).", description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
@ -134,7 +128,6 @@ async def list_recipes(
description="Maximum number of items to return (1-200).", description="Maximum number of items to return (1-200).",
), ),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None,
) -> Page[recipes.Recipe]: ) -> Page[recipes.Recipe]:
last_id = None last_id = None
if cursor: if cursor:
@ -183,7 +176,7 @@ async def list_recipes(
}, },
) )
async def get_recipe( async def get_recipe(
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> recipes.Recipe | ProblemDetails: ) -> recipes.Recipe | ProblemDetails:
r = await load_full_recipe(conn, recipe_id) r = await load_full_recipe(conn, recipe_id)
if not r: if not r:
@ -207,9 +200,9 @@ async def get_recipe(
) )
async def create_recipe( async def create_recipe(
recipe: recipes.Recipe, recipe: recipes.Recipe,
request: Request,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person), user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe | ProblemDetails: ) -> recipes.Recipe | ProblemDetails:
if not recipe.ingredients: if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient") return error_response(request, 400, "Recipe must have at least one ingredient")
@ -248,9 +241,9 @@ async def create_recipe(
) )
async def delete_recipe( async def delete_recipe(
recipe_id: int, recipe_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person), user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe | ProblemDetails: ) -> recipes.Recipe | ProblemDetails:
recipe = await recipes.find_recipe_by_id(conn, recipe_id) recipe = await recipes.find_recipe_by_id(conn, recipe_id)
if not recipe: if not recipe:

View file

@ -16,7 +16,6 @@ from api.deps import get_db, cookie_person, error_response
router = APIRouter(prefix="/shopping", tags=["shopping"]) router = APIRouter(prefix="/shopping", tags=["shopping"])
from common import ApiModel, Field from common import ApiModel, Field
from typing import Optional
class CurrentShoppingList(ApiModel): class CurrentShoppingList(ApiModel):
@ -81,7 +80,7 @@ class PurchasedShoppingList(ApiModel):
@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id", @router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id",
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}}) responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
async def get_shopping_list(list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> PurchasedShoppingList | ProblemDetails: async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | ProblemDetails:
shopping_list = await shopping.load_shopping_list(conn, list_id) shopping_list = await shopping.load_shopping_list(conn, list_id)
if not shopping_list: if not shopping_list:
return error_response(request, 404, "Shopping list not found") return error_response(request, 404, "Shopping list not found")
@ -149,7 +148,7 @@ class MealIdWrapper(ApiModel):
@router.post("/current/meals/me", response_model=None, operation_id="requestMeal", summary="Request a meal for shopping", @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": {}}}}) responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request | None = None) -> shopping.ShoppingListItem | ProblemDetails: async def request_meal(r: MealIdWrapper, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem | ProblemDetails:
meal = await meals.find_meal_by_id(conn, r.meal_id) meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal: if not meal:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
@ -161,7 +160,7 @@ async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(ge
@router.delete("/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", summary="Remove a meal request", @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": {}}}}) 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) -> dict | ProblemDetails: async def unrequest_meal(meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict | ProblemDetails:
meal = await meals.find_meal_by_id(conn, meal_id) meal = await meals.find_meal_by_id(conn, meal_id)
if not meal: if not meal:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
@ -171,63 +170,4 @@ async def unrequest_meal(meal_id: int, conn: aiosqlite.Connection = Depends(get_
return {} return {}
@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list") # Removed duplicate placeholder endpoints left over from earlier scaffolding
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")

548
main.py
View file

@ -1,20 +1,16 @@
import datetime import datetime
import os import os
from typing import Annotated, Dict, List, Optional, Any from typing import Annotated, Dict, List, Optional, Any
from contextlib import asynccontextmanager
import aiosqlite import aiosqlite
from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request from fastapi import Depends, FastAPI, APIRouter, Request
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import Field
import db import db
import ingredients
import meals
import persons
import products import products
import recipes
import shopping
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from common import ProblemDetails, Page, ApiModel from common import ProblemDetails, Page, ApiModel
@ -29,7 +25,22 @@ class CamelCaseRoute(APIRoute):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API") @asynccontextmanager
async def app_lifespan(app: FastAPI):
client = None
if not settings.prod:
import httpx
client = httpx.AsyncClient(base_url=settings.frontend_dev_url)
app.state.proxy_client = client
try:
yield
finally:
if client is not None:
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) api_v1 = APIRouter(route_class=CamelCaseRoute)
DATABASE_PATH = settings.database_path DATABASE_PATH = settings.database_path
@ -127,530 +138,21 @@ async def create_product(
return await products.get_or_create(conn, url.url, url.tags) return await products.get_or_create(conn, url.url, url.tags)
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
from api import recipes as recipes_router # type: ignore from api import recipes as recipes_router # type: ignore
@api_v1.get(
"/meals/upcoming",
operation_id="getUpcomingMeals",
tags=["meals"],
summary="List upcoming meals in a date range",
)
async def get_upcoming_meals(
date_from: Annotated[datetime.datetime, Query(alias="from")],
to: datetime.datetime,
conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]:
result = []
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal)
await meals.load_participants(conn, meal)
result.append(meal)
return result
@api_v1.get(
"/meals/{meal_id}",
response_model=None,
operation_id="getMeal",
tags=["meals"],
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
) -> meals.Meal | JSONResponse:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
return meal
def get_duplicates(items: List[meals.Person]) -> set[str]:
seen: set[int] = set()
duplicates: set[str] = set()
for item in items:
if item.id in seen:
duplicates.add(item.name)
seen.add(item.id)
return duplicates
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]:
if not meal.chefs:
return error_response(request, 400, "Meal must have at least one chef")
if not meal.cleanup:
return error_response(request, 400, "Meal must have at least one cleanup person")
if not meal.consumers:
return error_response(request, 400, "Meal must have at least one consumer")
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
return error_response(request, 400, "Meal must have at least one recipe or ingredient")
duplicates = get_duplicates(meal.chefs)
if duplicates:
return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}')
duplicates = get_duplicates(meal.cleanup)
if duplicates:
return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}')
duplicates = get_duplicates(meal.consumers)
if duplicates:
return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}')
zero_servings = [r for r in meal.recipes if r.servings == 0]
if zero_servings:
return error_response(request, 400, "Recipe servings must be greater than 0")
return None
@api_v1.post(
"/meals",
response_model=None,
operation_id="createMeal",
tags=["meals"],
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
) -> meals.Meal | JSONResponse:
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.insert_meal(conn, meal)
await conn.commit()
return meal
@api_v1.put(
"/meals/{meal_id}",
response_model=None,
operation_id="updateMeal",
tags=["meals"],
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
) -> meals.Meal | JSONResponse:
if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
existing = await meals.find_meal_by_id(conn, meal_id)
if not existing:
return error_response(request, 404, "Meal not found")
validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.update_meal(conn, meal)
await conn.commit()
return await get_meal(meal_id, conn)
@api_v1.post(
"/meals/{meal_id}/consumed",
response_model=None,
operation_id="markMealConsumed",
tags=["meals"],
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,
) -> meals.Meal | JSONResponse:
if consumed_date and not consumed_date.tzinfo:
return error_response(request, 400, "Consumed date must include timezone")
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
await shopping.remove_request(conn, person, meal=meal)
await conn.commit()
return meal
@api_v1.delete(
"/meals/{meal_id}",
response_model=None,
operation_id="deleteMeal",
tags=["meals"],
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,
) -> meals.Meal | JSONResponse:
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)
await meals.delete_meal(conn, meal.id)
await conn.commit()
return meal
from api import meals as meals_router # type: ignore from api import meals as meals_router # type: ignore
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
class CurrentShoppingList(ApiModel):
outstanding_items: List[shopping.ShoppingListItem]
requested_meals: List[shopping.ShoppingListItem]
purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list)
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict)
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
@api_v1.get(
"/shopping/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingList",
tags=["shopping"],
summary="Get the current aggregated shopping list",
)
async def get_current_shopping_list(
conn: aiosqlite.Connection = Depends(get_db),
) -> CurrentShoppingList:
(
outstanding_requests,
purchased_requests,
meal_requests,
meals_lookup,
recipes_lookup,
ingredients_lookup,
) = await shopping.get_outstanding_requests(conn)
other_shopping_list_ids = {item.list_id for item in purchased_requests}
shopping_list_lookup = {}
for list_id in other_shopping_list_ids:
if list_id is not None:
sl = await shopping.load_shopping_list(conn, list_id)
if sl is not None:
shopping_list_lookup[list_id] = sl
# Add any additional items from shopping lists to the existing lookups
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
if additional_items:
await shopping.to_lookups(
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
)
return CurrentShoppingList(
outstanding_items=outstanding_requests,
requested_meals=meal_requests,
purchased_items=purchased_requests,
meals_lookup=meals_lookup,
shopping_list_lookup=shopping_list_lookup,
ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup,
)
class PurchasedShoppingList(ApiModel):
list: shopping.ShoppingList
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
@api_v1.get(
"/shopping/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingList",
tags=["shopping"],
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
) -> PurchasedShoppingList | JSONResponse:
shopping_list = await shopping.load_shopping_list(conn, list_id)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=shopping_list,
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)
@api_v1.post(
"/shopping/",
operation_id="purchaseIngredients",
tags=["shopping"],
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),
) -> PurchasedShoppingList:
shopping_list = shopping.ShoppingList(
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
)
await shopping.purchase(conn, shopping_list)
await conn.commit()
result = PurchasedShoppingList(list=shopping_list)
await shopping.to_lookups(
conn,
shopping_list.items,
result.meals_lookup,
result.recipes_lookup,
result.ingredients_lookup,
)
return result
@api_v1.get(
"/shopping/current/me/ingredients",
operation_id="getMyShoppingList",
tags=["shopping"],
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]:
return await shopping.get_persons_requests(conn, person.id)
@api_v1.post(
"/shopping/current/me/ingredients",
operation_id="syncMyShoppingList",
tags=["shopping"],
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]:
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
return a.id == b.id or a.line == b.line
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
to_remove = [r for r in my_shopping_list if not any(isMatching(r, req) for req in requests)]
to_add = [req for req in requests if not any(isMatching(req, r) for r in my_shopping_list)]
for r in to_remove:
await shopping.remove_request(conn, person, ingredient=r)
for r in to_add:
if r.id < 0:
await ingredients.insert_ingredient(conn, r)
await shopping.request(conn, person, ingredient=r)
await conn.commit()
return await get_my_shopping_list(conn, person)
class MealIdWrapper(ApiModel):
meal_id: int
@api_v1.post(
"/shopping/current/meals/me",
response_model=None,
operation_id="requestMeal",
tags=["shopping"],
summary="Request a meal for shopping",
responses={
404: {
"model": ProblemDetails,
"description": "Meal not found",
"content": {"application/problem+json": {}},
}
},
)
async def request_meal(
r: MealIdWrapper,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request = None,
) -> shopping.ShoppingListItem | JSONResponse:
meal = await meals.find_meal_by_id(conn, r.meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
response = await shopping.request(conn, person, meal=meal)
await conn.commit()
return response
@api_v1.delete(
"/shopping/current/meals/{meal_id}",
response_model=None,
operation_id="unrequestMeal",
tags=["shopping"],
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,
) -> dict | JSONResponse:
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)
await conn.commit()
return {}
from api import shopping as shopping_router # type: ignore from api import shopping as shopping_router # type: ignore
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
from api import persons as persons_router # type: ignore from api import persons as persons_router # type: ignore
class LoginBody(ApiModel):
username: str
@api_v1.post(
"/auth/login",
response_model=None,
operation_id="login",
tags=["auth"],
summary="Login and set user_id cookie",
responses={
404: {
"model": ProblemDetails,
"description": "Person not found",
"content": {"application/problem+json": {}},
}
},
)
async def login(
data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
) -> persons.Person | JSONResponse:
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))
response.set_cookie(key="user_id", value=str(person.id))
return response
@api_v1.post(
"/auth/refresh",
operation_id="refresh",
tags=["auth"],
summary="Refresh current user from cookie",
)
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
return user
from api import auth as auth_router # type: ignore from api import auth as auth_router # type: ignore
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
# RFC7807 Problem Details handlers # RFC7807 Problem Details handlers
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from pydantic import ValidationError from pydantic import ValidationError
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(StarletteHTTPException) @app.exception_handler(StarletteHTTPException)
@ -707,7 +209,10 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
# Mount versioned API router # Mount versioned API router
app.include_router(api_v1, prefix="/api/v1", tags=["v1"]) app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
app.include_router(recipes_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(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
@app.get("/healthz") @app.get("/healthz")
@ -715,21 +220,20 @@ async def healthz():
return {"status": "ok"} return {"status": "ok"}
if os.environ.get("DOOF_PROD", False): if settings.prod:
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist") app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
else: else:
# Proxy the request to the frontend development server # Proxy the request to the frontend development server
import httpx
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
client = httpx.AsyncClient(base_url="http://localhost:8080/")
async def _reverse_proxy(request: Request): async def _reverse_proxy(request: Request):
import httpx
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8")) url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
client = app.state.proxy_client
rp_req = client.build_request( rp_req = client.build_request(
request.method, url, headers=request.headers.raw, content=request.stream() request.method, url, headers=request.headers.raw, content=request.stream()
) )

View file

@ -37,7 +37,7 @@ Acceptance criteria
- [x] api/shopping.py - [x] api/shopping.py
- [x] api/auth.py - [x] api/auth.py
- [x] Wire routers in main with minimal app code - [x] Wire routers in main with minimal app code
- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed - [x] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed
- [x] Add /healthz endpoint (simple JSON: {"status": "ok"}) - [x] Add /healthz endpoint (simple JSON: {"status": "ok"})
- [x] Create api/deps module for get_db, cookie_person, and error_response - [x] Create api/deps module for get_db, cookie_person, and error_response
- [x] Use settings.py (DOOF_DB) for DB path in main and deps - [x] Use settings.py (DOOF_DB) for DB path in main and deps
@ -47,6 +47,13 @@ Acceptance criteria
- Reverse proxy cleaned up; no stray global clients - Reverse proxy cleaned up; no stray global clients
- Health check available and excluded from auth/security - Health check available and excluded from auth/security
Status: Complete
Notes
- Lifespan handler initializes a dev httpx.AsyncClient using settings.frontend_dev_url and stores it on app.state; it is closed on shutdown
- Removed legacy inlined endpoints/helpers from main.py after extraction
- Kept product creation endpoint in main pending Phase 4 move
--- ---
## Phase 2 — API design and OpenAPI ## Phase 2 — API design and OpenAPI
@ -148,11 +155,11 @@ Note: We can adopt this structure gradually without moving DB code immediately;
- 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz - 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz
- 2025-10-18: Extracted persons routes to api/persons.py and wired router - 2025-10-18: Extracted persons routes to api/persons.py and wired router
- 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings - 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings
- 2025-10-18: Moved dev reverse proxy to app lifespan; removed dead code from main.py; deduplicated models; tests all passing
--- ---
## Next actions ## Next actions
- Phase 1: Move dev reverse proxy into a lifespan handler (startup/shutdown) and close AsyncClient cleanly
- Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location - Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location
- Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request - Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
- Phase 5: Add fixtures for DB/auth and tests for health + 201 Location - Phase 5: Add fixtures for DB/auth and tests for health + 201 Location