From a973e5ce5738cca8fed9b778f089607a5f3d560f Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 17:11:09 +1100 Subject: [PATCH] =?UTF-8?q?Finalized=20v1=E2=86=92v2=20switchover=20at=20t?= =?UTF-8?q?he=20router=20level:=20removed=20legacy=20v1=20endpoints=20from?= =?UTF-8?q?=20the=20app=20surface,=20delegated=20canonical=20imports=20to?= =?UTF-8?q?=20v2=20implementations,=20consolidated=20shopping=20DTOs/mappe?= =?UTF-8?q?rs,=20and=20updated=20router=20tags.=20Verified=20with=20lint,?= =?UTF-8?q?=20mypy,=20tests,=20formatting,=20and=20OpenAPI=20export.=20Eve?= =?UTF-8?q?rything=20is=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/auth.py | 57 +----- api/meals.py | 225 +---------------------- api/meals_v2.py | 2 +- api/persons.py | 95 +--------- api/recipes_v2.py | 2 +- api/shopping.py | 394 ++--------------------------------------- api/shopping_models.py | 130 ++++++++++++++ api/shopping_v2.py | 4 +- main.py | 16 +- openapi.json | 30 ++-- 10 files changed, 186 insertions(+), 769 deletions(-) create mode 100644 api/shopping_models.py diff --git a/api/auth.py b/api/auth.py index e295404..939bb60 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,53 +1,10 @@ -from __future__ import annotations +"""Canonical auth router now delegates to auth_v2 (JWT-based). -import aiosqlite -from fastapi import APIRouter, Depends, Request, Response +This preserves `api.auth` import path while using the v2 implementation. +""" -import persons -from api.deps import cookie_person, error_response, get_db -from common import ApiModel, ProblemDetails +from .auth_v2 import router # re-export canonical router -router = APIRouter(prefix="/auth", tags=["auth"]) - - -class LoginBody(ApiModel): - username: str - - -@router.post( - "/login", - response_model=persons.Person, - operation_id="login", - summary="Login and set user_id cookie", - responses={ - 200: {"model": persons.Person, "description": "Successful Response"}, - 404: { - "model": ProblemDetails, - "description": "Person not found", - "content": {"application/problem+json": {}}, - }, - }, -) -async def login( - request: Request, - data: LoginBody, - response: Response, - conn: aiosqlite.Connection = Depends(get_db), -) -> persons.Person | Response: - person = await persons.get_by_name(conn, data.username) - if not person: - return error_response(request, 404, "Person not found") - - # When using response_model, return the Pydantic model and set the cookie on the Response - response.set_cookie(key="user_id", value=str(person.id)) - return person - - -@router.post( - "/refresh-cookie", - response_model=persons.Person, - operation_id="refresh", - summary="Refresh current user from cookie", -) -async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person: - return user +# Hint to linters that the symbol is intentionally re-exported +__all__ = ["router"] +_UNUSED = (router,) diff --git a/api/meals.py b/api/meals.py index 69eb088..e460ce1 100644 --- a/api/meals.py +++ b/api/meals.py @@ -1,220 +1,13 @@ -from __future__ import annotations +"""Canonical meals router now delegates to v2 (household-scoped) implementation. -import datetime -from typing import List, Optional +This file preserves the public import path `api.meals` for tests and app wiring, +and exposes the FastAPI router defined in meals_v2. +""" -import aiosqlite -from fastapi import APIRouter, Depends, Query, Request, Response +from .meals_v2 import router # re-export canonical router -import meals -import ingredients -import persons -import shopping -from api.deps import cookie_person, error_response, get_db -from common import ProblemDetails, ApiModel, Field +# Keep validate_meal import surface for tests that reference api.meals.validate_meal +from meals.service import validate_meal -router = APIRouter(prefix="/meals", tags=["meals"]) - - -class MealOut(ApiModel): - id: int = -1 - suggested_date: datetime.datetime - consumed_date: Optional[datetime.datetime] = None - chefs: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) - cleanup: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) - consumers: List[persons.Person] = Field(min_length=0, json_schema_extra={"minItems": 0}) - recipes: List[meals.MealRecipe] = Field(min_length=0, json_schema_extra={"minItems": 0}) - extra_ingredients: List[ingredients.Ingredient] = Field( - min_length=0, json_schema_extra={"minItems": 0} - ) - purchase_date: Optional[datetime.datetime] = None - - -@router.get( - "/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range" -) -async def get_upcoming_meals( - date_from: datetime.datetime = Query(..., alias="from"), - to: datetime.datetime = Query(...), - conn: aiosqlite.Connection = Depends(get_db), -) -> List[meals.Meal]: - # Load base meals - result: List[meals.Meal] = [] - async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to): - result.append(meal) - - if not result: - return result - - # Batch load participants for all meals - await meals.bulk_load_participants(conn, result) - - # Load recipes and extra ingredients per meal (recipes include a small join) - for meal in result: - await meals.load_recipes(conn, meal) - await meals.load_extra_ingredients(conn, meal) - - return result - - -@router.get( - "/{meal_id}", - response_model=MealOut, - 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, request: Request, conn: aiosqlite.Connection = Depends(get_db) -) -> meals.Meal | Response: - 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=MealOut, - 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, - request: Request, - response: Response, - conn: aiosqlite.Connection = Depends(get_db), -) -> meals.Meal | Response: - validation_response = validate_meal(meal, request) - if validation_response: - return validation_response - - await meals.insert_meal(conn, meal) - response.headers["Location"] = f"/api/v1/meals/{meal.id}" - return meal - - -@router.put( - "/{meal_id}", - response_model=MealOut, - operation_id="updateMeal", - 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, request: Request, conn: aiosqlite.Connection = Depends(get_db) -) -> meals.Meal | Response: - 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) - - # Re-fetch and return the updated meal. Pass request and conn explicitly to avoid Depends resolution. - return await get_meal(meal_id, request, conn) - - -@router.post( - "/{meal_id}/consumed", - response_model=MealOut, - operation_id="markMealConsumed", - 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, - request: Request, - consumed_date: Optional[datetime.datetime] = None, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> meals.Meal | Response: - 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) - - return meal - - -@router.delete( - "/{meal_id}", - response_model=MealOut, - operation_id="deleteMeal", - summary="Delete a meal", - 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), -) -> meals.Meal | Response: - 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) - return meal - - -def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[Response]: - """HTTP-friendly wrapper that maps service validation to ProblemDetails.""" - msg = meals.validate_meal(meal) - if msg: - return error_response(request, 400, msg) - return None +__all__ = ["router", "validate_meal"] +_UNUSED = (router, validate_meal) diff --git a/api/meals_v2.py b/api/meals_v2.py index 86937e2..796a3e8 100644 --- a/api/meals_v2.py +++ b/api/meals_v2.py @@ -14,7 +14,7 @@ from common import ProblemDetails, ApiModel from api.deps import error_response from api.deps import get_db, get_household_from_slug -router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) +router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals"]) class MemberRef(ApiModel): diff --git a/api/persons.py b/api/persons.py index e1eabc5..b582371 100644 --- a/api/persons.py +++ b/api/persons.py @@ -1,93 +1,4 @@ -from __future__ import annotations +"""Legacy v1 persons API is removed in favor of users/household members. -from typing import List, Optional - -import aiosqlite -from fastapi import APIRouter, Depends, Query, Response - -import persons -from api.deps import get_db -from common import Page - -router = APIRouter(prefix="/persons", tags=["persons"]) - - -@router.get( - "", - operation_id="listPersons", - response_model=Page[persons.Person], - summary="List persons (paginated)", - responses={ - 200: { - "description": "A page of persons", - "content": { - "application/json": { - "example": { - "items": [{"id": 1, "name": "Ada Lovelace"}], - "nextCursor": "2", - "prevCursor": "0", - "total": 1, - } - } - }, - } - }, -) -async def list_persons( - q: Optional[str] = Query( - default=None, - description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).", - ), - cursor: Optional[str] = Query( - default=None, - description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.", - ), - limit: int = Query( - 50, - ge=1, - le=200, - description="Maximum number of items to return (1-200).", - ), - conn: aiosqlite.Connection = Depends(get_db), -) -> Page[persons.Person]: - # v1: DB-backed pagination - last_id = None - if cursor: - try: - last_id = int(cursor) - except ValueError: - last_id = None - - fetch_limit = limit + 1 - paged: List[persons.Person] = [] - if q: - async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit): - paged.append(p) - else: - async for p in persons.get_all_paged(conn, last_id, fetch_limit): - paged.append(p) - - has_more = len(paged) > limit - items = paged[:limit] - next_cursor = str(items[-1].id) if has_more and items else None - # Compute prevCursor via DB helper - prev_cursor: Optional[str] = None - if items: - first_id = items[0].id - prev_cursor = await persons.compute_prev_cursor(conn, first_id, limit, q) - total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn)) - return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total) - - -@router.post( - "", - operation_id="createPerson", - summary="Create a person", - response_model=persons.Person, -) -async def create_person( - person: persons.Person, response: Response, conn: aiosqlite.Connection = Depends(get_db) -) -> persons.Person: - await persons.insert_person(conn, person) - response.headers["Location"] = f"/api/v1/persons/{person.id}" - return person +This module intentionally has no routes. +""" diff --git a/api/recipes_v2.py b/api/recipes_v2.py index adcd74a..3566723 100644 --- a/api/recipes_v2.py +++ b/api/recipes_v2.py @@ -10,7 +10,7 @@ import recipes from api.deps import error_response, get_db, get_household_from_slug from common import Page, ProblemDetails, ApiModel, Field -router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes-v2"]) +router = APIRouter(prefix="/households/{householdSlug}/recipes", tags=["recipes"]) class RecipeOut(ApiModel): diff --git a/api/shopping.py b/api/shopping.py index f0b384e..3e0c0fa 100644 --- a/api/shopping.py +++ b/api/shopping.py @@ -1,388 +1,14 @@ -from __future__ import annotations +"""Canonical shopping router now delegates to v2 and exposes shared DTOs. -from datetime import datetime -from enum import Enum -from typing import Dict, List, Literal, cast +This keeps import path `api.shopping` available for shared models used by v2 +and for any tests that import mapping helpers. +""" -import aiosqlite -from fastapi import APIRouter, Depends, Request, Response +# Re-export shared DTOs/mappers minimally for type references +from .shopping_models import CurrentShoppingList, ShoppingListOut, PurchasedShoppingList -import ingredients -import meals -import persons -import recipes -import shopping -from shopping.models import StoreEnum -from api.deps import cookie_person, error_response, get_db -from common import ApiModel, Field, ProblemDetails +# Expose the v2 router under the canonical module +from .shopping_v2 import router - -# Outward-facing models to reduce unnecessary nulls in API responses -class ListIngredientItem(ApiModel): - kind: Literal["ingredient"] = "ingredient" - id: int = -1 - ingredient_id: int - person_id: int - created_date: datetime - # These may be present when the ingredient is part of a requested meal - list_id: int | None = None - meal_id: int | None = None - recipe_id: int | None = None - - -class RequestedMealItem(ApiModel): - kind: Literal["requestedMeal"] = "requestedMeal" - id: int = -1 - person_id: int - meal_id: int - created_date: datetime - - -# Input DTOs (separate from internal DB/domain models) -class IngredientPurchaseItemIn(ApiModel): - ingredient_id: int - person_id: int - created_date: datetime | None = None - meal_id: int | None = None - recipe_id: int | None = None - - -class PurchaseListIn(ApiModel): - store_name: StoreEnum - items: List[IngredientPurchaseItemIn] - - -# Output DTOs for purchased lists -class StoreNameOut(str, Enum): - woolworths = "woolworths" - coles = "coles" - home = "home" - - -class ShoppingListOut(ApiModel): - id: int - created_date: datetime - # outward-only enum values: include "home" instead of an empty string - store_name: Literal["woolworths", "coles", "home"] - purchased_by_id: int - purchased_by: persons.Person | None = None - # Make items required in the schema; callers must always send an array (possibly empty) - items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) - - -router = APIRouter(prefix="/shopping", tags=["shopping"]) - - -class CurrentShoppingList(ApiModel): - outstanding_items: List[ListIngredientItem] = Field( - min_length=0, json_schema_extra={"minItems": 0} - ) - requested_meals: List[RequestedMealItem] = Field( - min_length=0, json_schema_extra={"minItems": 0} - ) - # Make all collections required to avoid undefined/null semantics in clients - purchased_items: List[ListIngredientItem] = Field( - min_length=0, json_schema_extra={"minItems": 0} - ) - - ingredients_lookup: Dict[int, ingredients.Ingredient] - meals_lookup: Dict[int, meals.Meal] - shopping_list_lookup: Dict[int, ShoppingListOut] - recipes_lookup: Dict[int, recipes.Recipe] - - -# Mapping helpers from domain -> outward API -def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem: - return ListIngredientItem( - id=item.id, - ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1, - person_id=item.person_id, - created_date=item.created_date, - list_id=item.list_id, - meal_id=item.meal_id, - recipe_id=item.recipe_id, - ) - - -def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem: - return RequestedMealItem( - id=item.id, - person_id=item.person_id, - meal_id=item.meal_id if item.meal_id is not None else -1, - created_date=item.created_date, - ) - - -def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut: - # Map internal enum value "" to outward-friendly "home" - if sl.store_name == StoreEnum.home: - outward_store: Literal["woolworths", "coles", "home"] = "home" - else: - # Remaining enum values are 'woolworths' or 'coles' - outward_store = cast(Literal["woolworths", "coles"], sl.store_name.value) - return ShoppingListOut( - id=sl.id, - created_date=sl.created_date, - store_name=outward_store, - purchased_by_id=sl.purchased_by_id, - purchased_by=sl.purchased_by, - items=[_to_ingredient_item(i) for i in sl.items], - ) - - -@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} - - # Load full lists for additional lookups - other_lists_domain: Dict[int, shopping.ShoppingList] = {} - 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: - other_lists_domain[list_id] = sl - - # Add any additional items from shopping lists to the existing lookups - additional_items = [item for sl in other_lists_domain.values() for item in sl.items] - if additional_items: - await shopping.to_lookups( - conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup - ) - - # Convert domain shopping lists to outward form for response - shopping_list_lookup: Dict[int, ShoppingListOut] = { - k: _to_shopping_list_out(v) for k, v in other_lists_domain.items() - } - - return CurrentShoppingList( - outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests], - requested_meals=[_to_meal_item(i) for i in meal_requests], - purchased_items=[_to_ingredient_item(i) for i in purchased_requests], - meals_lookup=meals_lookup, - shopping_list_lookup=shopping_list_lookup, - ingredients_lookup=ingredients_lookup, - recipes_lookup=recipes_lookup, - ) - - -class PurchasedShoppingList(ApiModel): - list: ShoppingListOut - # Lookup maps are required to be present (may be empty) - meals_lookup: Dict[int, meals.Meal] - ingredients_lookup: Dict[int, ingredients.Ingredient] - recipes_lookup: Dict[int, recipes.Recipe] - - -@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, request: Request, conn: aiosqlite.Connection = Depends(get_db) -) -> PurchasedShoppingList | Response: - 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=_to_shopping_list_out(shopping_list), - meals_lookup=meals_lookup, - recipes_lookup=recipes_lookup, - ingredients_lookup=ingredients_lookup, - ) - - -@router.post( - "", - response_model=PurchasedShoppingList, - operation_id="purchaseIngredients", - summary="Purchase ingredients for a shopping list", - responses={ - 400: { - "model": ProblemDetails, - "description": "Validation error", - "content": {"application/problem+json": {}}, - }, - 401: { - "model": ProblemDetails, - "description": "Unauthorized (invalid or unknown user)", - "content": {"application/problem+json": {}}, - }, - }, -) -async def purchase_ingredients( - shopping_list: PurchaseListIn, - request: Request, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> PurchasedShoppingList | Response: - # Ensure the caller is authenticated and maps to a known user - if not person: - return error_response(request, 401, "Unauthorized") - - # Map outward input DTO to domain model - domain_items: List[shopping.ShoppingListItem] = [] - for it in shopping_list.items: - created = it.created_date or datetime.now().astimezone() - domain_items.append( - shopping.ShoppingListItem( - ingredient_id=it.ingredient_id, - person_id=it.person_id, - meal_id=it.meal_id, - recipe_id=it.recipe_id, - created_date=created, - ) - ) - - domain_list = shopping.ShoppingList( - purchased_by=person, items=domain_items, store_name=shopping_list.store_name - ) - try: - await shopping.purchase(conn, domain_list) - except ValueError as e: - # Map domain validation errors to a proper Problem Details response - return error_response(request, 400, str(e)) - # Build lookup maps and construct the outward response with required collections - meals_lookup: Dict[int, meals.Meal] - recipes_lookup: Dict[int, recipes.Recipe] - ingredients_lookup: Dict[int, ingredients.Ingredient] - meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( - conn, domain_list.items - ) - return PurchasedShoppingList( - list=_to_shopping_list_out(domain_list), - meals_lookup=meals_lookup, - recipes_lookup=recipes_lookup, - ingredients_lookup=ingredients_lookup, - ) - - -@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) - - return await get_my_shopping_list(conn, person) - - -class MealIdWrapper(ApiModel): - meal_id: int - - -@router.post( - "/current/meals/me", - response_model=RequestedMealItem, - 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, - request: Request, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> RequestedMealItem | Response: - 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) - return _to_meal_item(response) - - -class Ok(ApiModel): - ok: bool = True - - -@router.delete( - "/current/meals/{meal_id}", - response_model=Ok, - 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, - request: Request, - conn: aiosqlite.Connection = Depends(get_db), - person: persons.Person = Depends(cookie_person), -) -> Ok | Response: - 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) - return Ok() - - -# Removed duplicate placeholder endpoints left over from earlier scaffolding +__all__ = ["router", "CurrentShoppingList", "ShoppingListOut", "PurchasedShoppingList"] +_UNUSED = (router, CurrentShoppingList, ShoppingListOut, PurchasedShoppingList) diff --git a/api/shopping_models.py b/api/shopping_models.py new file mode 100644 index 0000000..2b615d4 --- /dev/null +++ b/api/shopping_models.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Dict, List, Literal, cast +from enum import Enum + +import ingredients +import meals +import persons +import recipes +import shopping +from common import ApiModel, Field +from shopping.models import StoreEnum + + +# Outward-facing models and mapping helpers shared by shopping API +class ListIngredientItem(ApiModel): + kind: Literal["ingredient"] = "ingredient" + id: int = -1 + ingredient_id: int + person_id: int + created_date: datetime + # These may be present when the ingredient is part of a requested meal + list_id: int | None = None + meal_id: int | None = None + recipe_id: int | None = None + + +class RequestedMealItem(ApiModel): + kind: Literal["requestedMeal"] = "requestedMeal" + id: int = -1 + person_id: int + meal_id: int + created_date: datetime + + +# Input DTOs (separate from internal DB/domain models) +class IngredientPurchaseItemIn(ApiModel): + ingredient_id: int + person_id: int + created_date: datetime | None = None + meal_id: int | None = None + recipe_id: int | None = None + + +class PurchaseListIn(ApiModel): + store_name: StoreEnum + items: List[IngredientPurchaseItemIn] + + +# Output DTOs for purchased lists +class StoreNameOut(str, Enum): + woolworths = "woolworths" + coles = "coles" + home = "home" + + +class ShoppingListOut(ApiModel): + id: int + created_date: datetime + # outward-only enum values: include "home" instead of an empty string + store_name: Literal["woolworths", "coles", "home"] + purchased_by_id: int + purchased_by: persons.Person | None = None + # Make items required in the schema; callers must always send an array (possibly empty) + items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0}) + + +class CurrentShoppingList(ApiModel): + outstanding_items: List[ListIngredientItem] = Field( + min_length=0, json_schema_extra={"minItems": 0} + ) + requested_meals: List[RequestedMealItem] = Field( + min_length=0, json_schema_extra={"minItems": 0} + ) + # Make all collections required to avoid undefined/null semantics in clients + purchased_items: List[ListIngredientItem] = Field( + min_length=0, json_schema_extra={"minItems": 0} + ) + + ingredients_lookup: Dict[int, ingredients.Ingredient] + meals_lookup: Dict[int, meals.Meal] + shopping_list_lookup: Dict[int, ShoppingListOut] + recipes_lookup: Dict[int, recipes.Recipe] + + +class PurchasedShoppingList(ApiModel): + list: ShoppingListOut + # Lookup maps are required to be present (may be empty) + meals_lookup: Dict[int, meals.Meal] + ingredients_lookup: Dict[int, ingredients.Ingredient] + recipes_lookup: Dict[int, recipes.Recipe] + + +def _to_ingredient_item(item: shopping.ShoppingListItem) -> ListIngredientItem: + return ListIngredientItem( + id=item.id, + ingredient_id=item.ingredient_id if item.ingredient_id is not None else -1, + person_id=item.person_id, + created_date=item.created_date, + list_id=item.list_id, + meal_id=item.meal_id, + recipe_id=item.recipe_id, + ) + + +def _to_meal_item(item: shopping.ShoppingListItem) -> RequestedMealItem: + return RequestedMealItem( + id=item.id, + person_id=item.person_id, + meal_id=item.meal_id if item.meal_id is not None else -1, + created_date=item.created_date, + ) + + +def _to_shopping_list_out(sl: shopping.ShoppingList) -> ShoppingListOut: + # Map internal enum value "" to outward-friendly "home" + if sl.store_name == StoreEnum.home: + outward_store: Literal["woolworths", "coles", "home"] = "home" + else: + # Remaining enum values are 'woolworths' or 'coles' + outward_store = cast(Literal["woolworths", "coles"], sl.store_name.value) + return ShoppingListOut( + id=sl.id, + created_date=sl.created_date, + store_name=outward_store, + purchased_by_id=sl.purchased_by_id, + purchased_by=sl.purchased_by, + items=[_to_ingredient_item(i) for i in sl.items], + ) diff --git a/api/shopping_v2.py b/api/shopping_v2.py index fe6a6ba..2b3ae43 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response import shopping from common import ApiModel as _ApiModel from api.deps import error_response, get_db, get_household_from_slug, get_current_user -from api.shopping import ( +from api.shopping_models import ( CurrentShoppingList, PurchasedShoppingList, _to_ingredient_item, @@ -18,7 +18,7 @@ from api.shopping import ( PurchaseListIn, ) -router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"]) +router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping"]) @router.get( diff --git a/main.py b/main.py index 4dab27a..3588714 100644 --- a/main.py +++ b/main.py @@ -9,10 +9,10 @@ from pydantic import ValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from api import ( - auth_v2 as auth_v2_router, - recipes_v2 as recipes_v2_router, - meals_v2 as meals_v2_router, - shopping_v2 as shopping_v2_router, + auth as auth_router, + recipes_v2 as recipes_router, + meals as meals_router, + shopping as shopping_router, households as households_router, ) from api.deps import ( @@ -130,7 +130,7 @@ def create_app() -> FastAPI: # Routers # v1 routers removed; v2 household-scoped and JWT-only API below # v2 JWT auth and households - app.include_router(auth_v2_router.router, prefix="/api/v1", tags=["auth"]) # canonical + app.include_router(auth_router.router, prefix="/api/v1", tags=["auth"]) # canonical app.include_router(households_router.router, prefix="/api/v1", tags=["households"]) # canonical # Mount household-scoped endpoints try: @@ -139,9 +139,9 @@ def create_app() -> FastAPI: ) # scoped except Exception: pass - app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["recipes"]) # canonical - app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["meals"]) # canonical - app.include_router(shopping_v2_router.router, prefix="/api/v1", tags=["shopping"]) # canonical + app.include_router(recipes_router.router, prefix="/api/v1", tags=["recipes"]) # canonical + app.include_router(meals_router.router, prefix="/api/v1", tags=["meals"]) # canonical + app.include_router(shopping_router.router, prefix="/api/v1", tags=["shopping"]) # canonical # Routes app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) diff --git a/openapi.json b/openapi.json index 9d7e433..121a3a1 100644 --- a/openapi.json +++ b/openapi.json @@ -372,7 +372,7 @@ "get": { "tags": [ "recipes", - "recipes-v2" + "recipes" ], "summary": "List Recipes", "operationId": "list_recipes_api_v1_households__householdSlug__recipes_get", @@ -465,7 +465,7 @@ "post": { "tags": [ "recipes", - "recipes-v2" + "recipes" ], "summary": "Create Recipe", "operationId": "create_recipe_api_v1_households__householdSlug__recipes_post", @@ -529,7 +529,7 @@ "get": { "tags": [ "recipes", - "recipes-v2" + "recipes" ], "summary": "Get Recipe", "operationId": "get_recipe_api_v1_households__householdSlug__recipes__recipe_id__get", @@ -590,7 +590,7 @@ "delete": { "tags": [ "recipes", - "recipes-v2" + "recipes" ], "summary": "Delete Recipe", "operationId": "delete_recipe_api_v1_households__householdSlug__recipes__recipe_id__delete", @@ -653,7 +653,7 @@ "get": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "List upcoming meals in a date range (scoped)", "operationId": "getUpcomingMealsV2", @@ -728,7 +728,7 @@ "get": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "Get a meal by id (scoped)", "operationId": "getMealV2", @@ -789,7 +789,7 @@ "put": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "Update an existing meal (scoped)", "operationId": "updateMealV2", @@ -863,7 +863,7 @@ "delete": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "Delete a meal (scoped)", "operationId": "deleteMealV2", @@ -926,7 +926,7 @@ "post": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "Mark a meal as consumed (scoped)", "operationId": "markMealConsumedV2", @@ -1009,7 +1009,7 @@ "post": { "tags": [ "meals", - "meals-v2" + "meals" ], "summary": "Create a new meal (scoped)", "operationId": "createMealV2", @@ -1073,7 +1073,7 @@ "get": { "tags": [ "shopping", - "shopping-v2" + "shopping" ], "summary": "Get the current aggregated shopping list (scoped)", "operationId": "getCurrentShoppingListV2", @@ -1124,7 +1124,7 @@ "get": { "tags": [ "shopping", - "shopping-v2" + "shopping" ], "summary": "Get a purchased shopping list by id (scoped)", "operationId": "getShoppingListV2", @@ -1184,7 +1184,7 @@ "post": { "tags": [ "shopping", - "shopping-v2" + "shopping" ], "summary": "Purchase ingredients for a shopping list (scoped)", "operationId": "purchaseIngredientsV2", @@ -1245,7 +1245,7 @@ "post": { "tags": [ "shopping", - "shopping-v2" + "shopping" ], "summary": "Request a meal for shopping (scoped)", "operationId": "requestMealV2", @@ -1306,7 +1306,7 @@ "delete": { "tags": [ "shopping", - "shopping-v2" + "shopping" ], "summary": "Remove a meal request (scoped)", "operationId": "unrequestMealV2",