API extractions
This commit is contained in:
parent
2bbed57313
commit
53b07343d1
5 changed files with 299 additions and 22 deletions
43
api/auth.py
43
api/auth.py
|
|
@ -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
|
||||
|
|
|
|||
109
api/meals.py
109
api/meals.py
|
|
@ -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
|
||||
|
|
|
|||
157
api/shopping.py
157
api/shopping.py
|
|
@ -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(
|
||||
|
|
|
|||
7
main.py
7
main.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Reference in a new issue