diff --git a/api/auth.py b/api/auth.py index e4b7ef2..402f3ba 100644 --- a/api/auth.py +++ b/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 diff --git a/api/meals.py b/api/meals.py index 608b9a8..a141627 100644 --- a/api/meals.py +++ b/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 diff --git a/api/shopping.py b/api/shopping.py index 8fc92de..d89f23d 100644 --- a/api/shopping.py +++ b/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( diff --git a/main.py b/main.py index 997066a..b8a2c37 100644 --- a/main.py +++ b/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 diff --git a/refactor-project-strategy.md b/refactor-project-strategy.md index a616796..17fc9a2 100644 --- a/refactor-project-strategy.md +++ b/refactor-project-strategy.md @@ -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"})