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:
parent
4e3b3a77dc
commit
a973e5ce57
10 changed files with 186 additions and 769 deletions
57
api/auth.py
57
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,)
|
||||
|
|
|
|||
225
api/meals.py
225
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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
394
api/shopping.py
394
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)
|
||||
|
|
|
|||
130
api/shopping_models.py
Normal file
130
api/shopping_models.py
Normal 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],
|
||||
)
|
||||
|
|
@ -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
16
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)
|
||||
|
|
|
|||
30
openapi.json
30
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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue