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": {}}}
},
)
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)
if not person:
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",
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
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
meal = await meals.find_meal_by_id(conn, meal_id)
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",
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
meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
validation_response = validate_meal(meal, request)
if validation_response:
@ -66,7 +66,7 @@ async def create_meal(
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
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
) -> meals.Meal:
if meal.id != meal_id:
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(
meal_id: int,
request: Request,
consumed_date: Optional[datetime.datetime] = None,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal:
if consumed_date and not consumed_date.tzinfo:
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": {}}}})
async def delete_meal(
meal_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> meals.Meal:
meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:

View file

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

View file

@ -8,19 +8,12 @@ from fastapi import APIRouter, Depends, Query, Request
import ingredients
import recipes
import persons
from common import Page, ProblemDetails, ApiModel
from pydantic import Field
from common import Page, ProblemDetails
from api.deps import get_db, cookie_person, error_response
router = APIRouter(prefix="/recipes", tags=["recipes"])
class ProductUrl(ApiModel):
url: str
tags: List[str] = Field(default_factory=list)
@router.get(
"/parse",
response_model=None,
@ -35,7 +28,7 @@ class ProductUrl(ApiModel):
},
)
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:
parsed = await recipes.parse_recipe(conn, person, url)
if not parsed:
@ -119,6 +112,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
},
)
async def list_recipes(
request: Request,
q: Optional[str] = Query(
default=None,
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).",
),
conn: aiosqlite.Connection = Depends(get_db),
request: Request | None = None,
) -> Page[recipes.Recipe]:
last_id = None
if cursor:
@ -183,7 +176,7 @@ async def list_recipes(
},
)
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:
r = await load_full_recipe(conn, recipe_id)
if not r:
@ -207,9 +200,9 @@ async def get_recipe(
)
async def create_recipe(
recipe: recipes.Recipe,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe | ProblemDetails:
if not recipe.ingredients:
return error_response(request, 400, "Recipe must have at least one ingredient")
@ -248,9 +241,9 @@ async def create_recipe(
)
async def delete_recipe(
recipe_id: int,
request: Request,
conn: aiosqlite.Connection = Depends(get_db),
user: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> recipes.Recipe | ProblemDetails:
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
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"])
from common import ApiModel, Field
from typing import Optional
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",
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)
if not shopping_list:
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",
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)
if not meal:
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",
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)
if not meal:
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 {}
@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")
# Removed duplicate placeholder endpoints left over from earlier scaffolding

548
main.py
View file

@ -1,20 +1,16 @@
import datetime
import os
from typing import Annotated, Dict, List, Optional, Any
from contextlib import asynccontextmanager
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.responses import JSONResponse
from pydantic import BaseModel, Field
from pydantic import Field
import db
import ingredients
import meals
import persons
import products
import recipes
import shopping
from fastapi.routing import APIRoute
from common import ProblemDetails, Page, ApiModel
@ -29,7 +25,22 @@ class CamelCaseRoute(APIRoute):
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)
DATABASE_PATH = settings.database_path
@ -127,530 +138,21 @@ async def create_product(
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
@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
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
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
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
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
# RFC7807 Problem Details handlers
from starlette.exceptions import HTTPException as StarletteHTTPException
from pydantic import ValidationError
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(StarletteHTTPException)
@ -707,7 +209,10 @@ 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.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
@app.get("/healthz")
@ -715,21 +220,20 @@ async def healthz():
return {"status": "ok"}
if os.environ.get("DOOF_PROD", False):
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
import httpx
from starlette.background import BackgroundTask
from starlette.requests import Request
from starlette.responses import StreamingResponse
client = httpx.AsyncClient(base_url="http://localhost:8080/")
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()
)

View file

@ -37,7 +37,7 @@ Acceptance criteria
- [x] api/shopping.py
- [x] api/auth.py
- [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] 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
@ -47,6 +47,13 @@ Acceptance criteria
- Reverse proxy cleaned up; no stray global clients
- 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
@ -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 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: Moved dev reverse proxy to app lifespan; removed dead code from main.py; deduplicated models; tests all passing
---
## 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 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
- Phase 5: Add fixtures for DB/auth and tests for health + 201 Location