from __future__ import annotations from datetime import datetime from enum import Enum from typing import Dict, List, Literal, cast import aiosqlite from fastapi import APIRouter, Depends, Request, Response 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 # 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