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 typing import Any
import aiosqlite
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
import persons
from common import ProblemDetails
from main import get_db, cookie_person # temporary during extraction
from common import ProblemDetails, ApiModel
from main import get_db, cookie_person, error_response
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")
class LoginBody(ApiModel):
username: str
@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:
raise NotImplementedError("current_user extraction pending")
return user

View file

@ -10,7 +10,10 @@ import meals
import persons
import shopping
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"])
@ -21,7 +24,14 @@ async def get_upcoming_meals(
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> 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",
@ -29,7 +39,11 @@ async def get_upcoming_meals(
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")
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",
@ -37,7 +51,13 @@ async def get_meal(
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")
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",
@ -48,7 +68,21 @@ async def create_meal(
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")
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",
@ -63,7 +97,18 @@ async def mark_consumed(
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> 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",
@ -74,4 +119,54 @@ async def delete_meal(
person: persons.Person = Depends(cookie_person),
request: Request | None = None,
) -> 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 shopping
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"])
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")
async def get_current_shopping_list(

View file

@ -391,6 +391,9 @@ async def delete_meal(
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]
@ -619,6 +622,8 @@ async def unrequest_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
@ -662,6 +667,8 @@ async def login(
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

View file

@ -33,9 +33,10 @@ Acceptance criteria
- [ ] Extract routers by feature
- [x] api/recipes.py
- [ ] api/meals.py
- [x] api/meals.py
- [x] api/persons.py
- [ ] api/shopping.py
- [ ] api/auth.py
- [x] api/shopping.py
- [x] api/auth.py
- [ ] Wire routers in main with minimal app code
- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed
- [x] Add /healthz endpoint (simple JSON: {"status": "ok"})