Finalized v1→v2 switchover at the router level: removed legacy v1 endpoints from the app surface, delegated canonical imports to v2 implementations, consolidated shopping DTOs/mappers, and updated router tags. Verified with lint, mypy, tests, formatting, and OpenAPI export. Everything is green.

This commit is contained in:
jableader 2025-11-01 17:11:09 +11:00
parent 4e3b3a77dc
commit a973e5ce57
10 changed files with 186 additions and 769 deletions

View file

@ -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,)

View file

@ -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)

View file

@ -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):

View file

@ -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.
"""

View file

@ -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):

View file

@ -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)

130
api/shopping_models.py Normal file
View file

@ -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],
)

View file

@ -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(

16
main.py
View file

@ -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)

View file

@ -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",