API extractions

This commit is contained in:
jableader 2025-10-18 17:05:48 +11:00
parent 2bbed57313
commit 53b07343d1
5 changed files with 299 additions and 22 deletions

View file

@ -1,25 +1,44 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
import persons import persons
from common import ProblemDetails from common import ProblemDetails, ApiModel
from main import get_db, cookie_person # temporary during extraction from main import get_db, cookie_person, error_response
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", operation_id="login", summary="Login and set user_id cookie", class LoginBody(ApiModel):
responses={404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}}) username: str
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") @router.post(
"/login",
response_model=None,
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: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = 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
@router.post(
"/refresh",
operation_id="refresh",
summary="Refresh current user from cookie",
)
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
raise NotImplementedError("current_user extraction pending") return user

View file

@ -10,7 +10,10 @@ import meals
import persons import persons
import shopping import shopping
from common import ProblemDetails from common import ProblemDetails
from main import get_db, cookie_person # temporary imports during extraction from main import get_db, cookie_person, error_response # temporary imports during extraction
from common import ApiModel, Field
import datetime
from typing import Dict
router = APIRouter(prefix="/meals", tags=["meals"]) router = APIRouter(prefix="/meals", tags=["meals"])
@ -21,7 +24,14 @@ async def get_upcoming_meals(
to: datetime.datetime = Query(...), to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]: ) -> List[meals.Meal]:
raise NotImplementedError("get_upcoming_meals extraction pending") 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
@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",
@ -29,7 +39,11 @@ async def get_upcoming_meals(
async def get_meal( async def get_meal(
meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal: ) -> meals.Meal:
raise NotImplementedError("get_meal extraction pending") meal = await meals.find_meal_by_id(conn, meal_id)
if not meal:
return error_response(request, 404, "Meal not found")
return 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",
@ -37,7 +51,13 @@ async def get_meal(
async def create_meal( async def create_meal(
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal: ) -> meals.Meal:
raise NotImplementedError("create_meal extraction pending") validation_response = validate_meal(meal, request)
if validation_response:
return validation_response
await meals.insert_meal(conn, meal)
await conn.commit()
return meal
@router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal", @router.put("/{meal_id}", response_model=meals.Meal, operation_id="updateMeal", summary="Update an existing meal",
@ -48,7 +68,21 @@ async def create_meal(
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, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
) -> meals.Meal: ) -> meals.Meal:
raise NotImplementedError("update_meal extraction pending") 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)
@router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed", @router.post("/{meal_id}/consumed", response_model=meals.Meal, operation_id="markMealConsumed", summary="Mark a meal as consumed",
@ -63,7 +97,18 @@ async def mark_consumed(
person: persons.Person = Depends(cookie_person), person: persons.Person = Depends(cookie_person),
request: Request | None = None, request: Request | None = None,
) -> meals.Meal: ) -> meals.Meal:
raise NotImplementedError("mark_consumed extraction pending") 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
@router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal", @router.delete("/{meal_id}", response_model=meals.Meal, operation_id="deleteMeal", summary="Delete a meal",
@ -74,4 +119,54 @@ async def delete_meal(
person: persons.Person = Depends(cookie_person), person: persons.Person = Depends(cookie_person),
request: Request | None = None, request: Request | None = None,
) -> meals.Meal: ) -> meals.Meal:
raise NotImplementedError("delete_meal extraction pending") 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
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[Request]:
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

View file

@ -11,10 +11,165 @@ import persons
import recipes import recipes
import shopping import shopping
from common import ProblemDetails from common import ProblemDetails
from main import get_db, cookie_person # temporary during extraction from main import get_db, cookie_person, error_response # temporary during extraction
router = APIRouter(prefix="/shopping", tags=["shopping"]) router = APIRouter(prefix="/shopping", tags=["shopping"])
from common import ApiModel, Field
from typing import Optional
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)
@router.get(
"/current",
response_model=CurrentShoppingList,
operation_id="getCurrentShoppingList",
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)
@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:
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,
)
@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)) -> 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
@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]:
return await shopping.get_persons_requests(conn, person.id)
@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]:
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
@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:
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
@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:
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 {}
@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list") @router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list")
async def get_current_shopping_list( async def get_current_shopping_list(

View file

@ -391,6 +391,9 @@ async def delete_meal(
await conn.commit() await conn.commit()
return meal 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): class CurrentShoppingList(ApiModel):
outstanding_items: List[shopping.ShoppingListItem] outstanding_items: List[shopping.ShoppingListItem]
@ -619,6 +622,8 @@ async def unrequest_meal(
await conn.commit() await conn.commit()
return {} 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 from api import persons as persons_router # type: ignore
@ -662,6 +667,8 @@ async def login(
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
return user 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 # RFC7807 Problem Details handlers
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException

View file

@ -33,9 +33,10 @@ Acceptance criteria
- [ ] Extract routers by feature - [ ] Extract routers by feature
- [x] api/recipes.py - [x] api/recipes.py
- [ ] api/meals.py - [ ] api/meals.py
- [x] api/meals.py
- [x] api/persons.py - [x] api/persons.py
- [ ] api/shopping.py - [x] api/shopping.py
- [ ] api/auth.py - [x] api/auth.py
- [ ] Wire routers in main with minimal app code - [ ] Wire routers in main with minimal app code
- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed - [ ] 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"})