Compare commits
No commits in common. "0be2d1a2b080c99ce2b4abf6b1e86dfe54e16d85" and "7b6f4e2a3b38ca5e873c8345b48f53b62cebb511" have entirely different histories.
0be2d1a2b0
...
7b6f4e2a3b
12 changed files with 206 additions and 870 deletions
27
api/deps.py
27
api/deps.py
|
|
@ -1,9 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import Annotated, AsyncGenerator, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import Cookie, Depends, HTTPException, Request
|
from fastapi import Cookie, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
import db
|
import db
|
||||||
|
|
@ -38,31 +38,12 @@ async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
||||||
|
|
||||||
|
|
||||||
async def cookie_person(
|
async def cookie_person(
|
||||||
user_id: int = Cookie(..., alias="user_id"),
|
user_id: Optional[int] = Cookie(None, alias="user_id"),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
) -> persons.Person:
|
|
||||||
"""Return the authenticated user from the user_id cookie or raise 401.
|
|
||||||
|
|
||||||
When the cookie is missing, FastAPI will raise 422 (validation error).
|
|
||||||
"""
|
|
||||||
person = await persons.get_by_id(conn, user_id)
|
|
||||||
if not person:
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
return person
|
|
||||||
|
|
||||||
|
|
||||||
async def cookie_person_optional(
|
|
||||||
user_id: Optional[int] = Cookie(default=None, alias="user_id"),
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
) -> Optional[persons.Person]:
|
) -> Optional[persons.Person]:
|
||||||
"""Return the authenticated user if cookie present; otherwise None.
|
|
||||||
|
|
||||||
Use for endpoints that want to return 401 for missing auth themselves.
|
|
||||||
"""
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return None
|
return None
|
||||||
person = await persons.get_by_id(conn, user_id)
|
return await persons.get_by_id(conn, user_id)
|
||||||
return person
|
|
||||||
|
|
||||||
|
|
||||||
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
||||||
|
|
|
||||||
27
api/meals.py
27
api/meals.py
|
|
@ -7,29 +7,14 @@ import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
import meals
|
import meals
|
||||||
import ingredients
|
|
||||||
import persons
|
import persons
|
||||||
import shopping
|
import shopping
|
||||||
from api.deps import cookie_person, error_response, get_db
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from common import ProblemDetails, ApiModel, Field
|
from common import ProblemDetails
|
||||||
|
|
||||||
router = APIRouter(prefix="/meals", tags=["meals"])
|
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(
|
@router.get(
|
||||||
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
|
"/upcoming", operation_id="getUpcomingMeals", summary="List upcoming meals in a date range"
|
||||||
)
|
)
|
||||||
|
|
@ -59,7 +44,7 @@ async def get_upcoming_meals(
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=MealOut,
|
response_model=meals.Meal,
|
||||||
operation_id="getMeal",
|
operation_id="getMeal",
|
||||||
summary="Get a meal by id",
|
summary="Get a meal by id",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -82,7 +67,7 @@ async def get_meal(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=MealOut,
|
response_model=meals.Meal,
|
||||||
operation_id="createMeal",
|
operation_id="createMeal",
|
||||||
summary="Create a new meal",
|
summary="Create a new meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -110,7 +95,7 @@ async def create_meal(
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=MealOut,
|
response_model=meals.Meal,
|
||||||
operation_id="updateMeal",
|
operation_id="updateMeal",
|
||||||
summary="Update an existing meal",
|
summary="Update an existing meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -148,7 +133,7 @@ async def update_meal(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{meal_id}/consumed",
|
"/{meal_id}/consumed",
|
||||||
response_model=MealOut,
|
response_model=meals.Meal,
|
||||||
operation_id="markMealConsumed",
|
operation_id="markMealConsumed",
|
||||||
summary="Mark a meal as consumed",
|
summary="Mark a meal as consumed",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -186,7 +171,7 @@ async def mark_consumed(
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{meal_id}",
|
"/{meal_id}",
|
||||||
response_model=MealOut,
|
response_model=meals.Meal,
|
||||||
operation_id="deleteMeal",
|
operation_id="deleteMeal",
|
||||||
summary="Delete a meal",
|
summary="Delete a meal",
|
||||||
responses={
|
responses={
|
||||||
|
|
|
||||||
|
|
@ -103,43 +103,8 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||||
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
if not any(isinstance(s, dict) and "cookieAuth" in s for s in security):
|
||||||
security.append({"cookieAuth": []})
|
security.append({"cookieAuth": []})
|
||||||
|
|
||||||
# Ensure the cookie parameter is documented as required integer (non-null)
|
|
||||||
params = op.get("parameters")
|
|
||||||
if isinstance(params, list):
|
|
||||||
for p in params:
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
continue
|
|
||||||
if p.get("in") == "cookie" and p.get("name") == "user_id":
|
|
||||||
p["required"] = True
|
|
||||||
schema = p.setdefault("schema", {})
|
|
||||||
if isinstance(schema, dict):
|
|
||||||
schema.clear()
|
|
||||||
schema.update({"type": "integer", "title": "User Id"})
|
|
||||||
|
|
||||||
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
|
# Keep endpoint-specific schemas driven by route declarations only (no forced overrides)
|
||||||
|
|
||||||
# Normalize outward-facing shopping list storeName enum to avoid empty-string value
|
|
||||||
schemas = components.setdefault("schemas", {})
|
|
||||||
# Define outward-only enum for store names
|
|
||||||
schemas.setdefault(
|
|
||||||
"StoreNameOut",
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["woolworths", "coles", "home"],
|
|
||||||
"title": "StoreNameOut",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Replace any storeName prop that points to StoreEnum (which includes "") with outward StoreNameOut
|
|
||||||
for schema in schemas.values():
|
|
||||||
if not isinstance(schema, dict):
|
|
||||||
continue
|
|
||||||
props = schema.get("properties")
|
|
||||||
if not isinstance(props, dict):
|
|
||||||
continue
|
|
||||||
store = props.get("storeName")
|
|
||||||
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
|
|
||||||
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
|
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
|
|
||||||
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
# Rebind app.openapi to our generator (FastAPI supports this pattern). Mypy needs a narrow ignore here.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,22 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
|
|
||||||
import ingredients as ingredients_mod
|
import ingredients
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
from api.deps import cookie_person, error_response, get_db
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from common import Page, ProblemDetails, ApiModel, Field
|
from common import Page, ProblemDetails
|
||||||
|
|
||||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||||
|
|
||||||
|
|
||||||
# Outward DTO with required arrays in the schema
|
|
||||||
class RecipeOut(ApiModel):
|
|
||||||
id: int = -1
|
|
||||||
name: str
|
|
||||||
link: str
|
|
||||||
serves: int
|
|
||||||
image_urls: List[str] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
|
||||||
ingredients: List[ingredients_mod.Ingredient] = Field(
|
|
||||||
min_length=0, json_schema_extra={"minItems": 0}
|
|
||||||
)
|
|
||||||
based_on_recipe: Optional[int] = None
|
|
||||||
date_created: datetime.datetime
|
|
||||||
created_by_id: int
|
|
||||||
created_by: Optional[persons.Person] = None
|
|
||||||
date_hidden: Optional[datetime.datetime] = None
|
|
||||||
hidden_by_id: Optional[int] = None
|
|
||||||
hidden_by: Optional[persons.Person] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/parse",
|
"/parse",
|
||||||
response_model=RecipeOut,
|
response_model=recipes.Recipe,
|
||||||
operation_id="parseRecipe",
|
operation_id="parseRecipe",
|
||||||
summary="Parse a recipe from a URL",
|
summary="Parse a recipe from a URL",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -67,17 +47,17 @@ async def parse_recipe_handler(
|
||||||
async def parse_ingredients(
|
async def parse_ingredients(
|
||||||
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
lines: List[str] = Query(alias="ingredients", title="Array of ingredients to parse"),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
) -> List[ingredients_mod.Ingredient]:
|
) -> List[ingredients.Ingredient]:
|
||||||
had_links = False
|
had_links = False
|
||||||
result: List[ingredients_mod.Ingredient] = []
|
result = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
ingredient = await ingredients_mod.parse_ingredient_from_link(conn, line)
|
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
|
||||||
if ingredient:
|
if ingredient:
|
||||||
result.append(ingredient)
|
result.append(ingredient)
|
||||||
had_links = True
|
had_links = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ingredient = ingredients_mod.parse_ingredient_from_nlp(line)
|
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
||||||
if ingredient:
|
if ingredient:
|
||||||
result.append(ingredient)
|
result.append(ingredient)
|
||||||
continue
|
continue
|
||||||
|
|
@ -86,7 +66,7 @@ async def parse_ingredients(
|
||||||
# Transaction will commit at end of request
|
# Transaction will commit at end of request
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await ingredients_mod.match_existing_products(conn, result)
|
await ingredients.match_existing_products(conn, result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,9 +76,10 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
return None
|
return None
|
||||||
|
|
||||||
r.ingredients = []
|
r.ingredients = []
|
||||||
async for ingredient in ingredients_mod.find_ingredients_by_recipe_id(conn, id):
|
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
|
||||||
r.ingredients.append(ingredient)
|
r.ingredients.append(ingredient)
|
||||||
|
|
||||||
|
if r.created_by_id is not None:
|
||||||
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|
@ -107,7 +88,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
operation_id="listRecipes",
|
operation_id="listRecipes",
|
||||||
response_model=Page[RecipeOut],
|
response_model=Page[recipes.Recipe],
|
||||||
summary="List recipes (paginated)",
|
summary="List recipes (paginated)",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
|
|
@ -173,7 +154,7 @@ async def list_recipes(
|
||||||
# Batch-load ingredients for the page to avoid N+1 queries
|
# Batch-load ingredients for the page to avoid N+1 queries
|
||||||
if items:
|
if items:
|
||||||
recipe_ids = [r.id for r in items]
|
recipe_ids = [r.id for r in items]
|
||||||
by_recipe = await ingredients_mod.find_ingredients_by_recipe_ids(conn, recipe_ids)
|
by_recipe = await ingredients.find_ingredients_by_recipe_ids(conn, recipe_ids)
|
||||||
for r in items:
|
for r in items:
|
||||||
r.ingredients = by_recipe.get(r.id, [])
|
r.ingredients = by_recipe.get(r.id, [])
|
||||||
next_cursor = str(items[-1].id) if has_more and items else None
|
next_cursor = str(items[-1].id) if has_more and items else None
|
||||||
|
|
@ -188,7 +169,7 @@ async def list_recipes(
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{recipe_id}",
|
"/{recipe_id}",
|
||||||
response_model=RecipeOut,
|
response_model=recipes.Recipe,
|
||||||
operation_id="getRecipe",
|
operation_id="getRecipe",
|
||||||
summary="Get a single recipe",
|
summary="Get a single recipe",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -211,7 +192,7 @@ async def get_recipe(
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=RecipeOut,
|
response_model=recipes.Recipe,
|
||||||
operation_id="createRecipe",
|
operation_id="createRecipe",
|
||||||
summary="Create a new recipe (versioning semantics applied)",
|
summary="Create a new recipe (versioning semantics applied)",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -243,7 +224,8 @@ async def create_recipe(
|
||||||
ingredient.recipe_id = recipe.id
|
ingredient.recipe_id = recipe.id
|
||||||
if ingredient.product:
|
if ingredient.product:
|
||||||
ingredient.product_id = ingredient.product.id
|
ingredient.product_id = ingredient.product.id
|
||||||
await ingredients_mod.insert_ingredient(conn, ingredient)
|
|
||||||
|
await ingredients.insert_ingredient(conn, ingredient)
|
||||||
|
|
||||||
# Transaction will commit at end of request
|
# Transaction will commit at end of request
|
||||||
# Set Location to the new resource
|
# Set Location to the new resource
|
||||||
|
|
|
||||||
187
api/shopping.py
187
api/shopping.py
|
|
@ -1,8 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from typing import Dict, List
|
||||||
from enum import Enum
|
|
||||||
from typing import Dict, List, Literal
|
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
@ -12,112 +10,21 @@ import meals
|
||||||
import persons
|
import persons
|
||||||
import recipes
|
import recipes
|
||||||
import shopping
|
import shopping
|
||||||
from shopping.models import StoreEnum
|
|
||||||
from api.deps import cookie_person, error_response, get_db
|
from api.deps import cookie_person, error_response, get_db
|
||||||
from common import ApiModel, Field, ProblemDetails
|
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"])
|
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
outstanding_items: List[shopping.ShoppingListItem]
|
||||||
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
requested_meals: List[shopping.ShoppingListItem]
|
||||||
# Make all collections required to avoid undefined/null semantics in clients
|
purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list)
|
||||||
purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
|
||||||
|
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
||||||
meals_lookup: Dict[int, meals.Meal]
|
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
||||||
shopping_list_lookup: Dict[int, ShoppingListOut]
|
shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict)
|
||||||
recipes_lookup: Dict[int, recipes.Recipe]
|
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
outward_store = "home" if sl.store_name == StoreEnum.home else 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(
|
@router.get(
|
||||||
|
|
@ -139,30 +46,24 @@ async def get_current_shopping_list(
|
||||||
) = await shopping.get_outstanding_requests(conn)
|
) = await shopping.get_outstanding_requests(conn)
|
||||||
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
||||||
|
|
||||||
# Load full lists for additional lookups
|
shopping_list_lookup = {}
|
||||||
other_lists_domain: Dict[int, shopping.ShoppingList] = {}
|
|
||||||
for list_id in other_shopping_list_ids:
|
for list_id in other_shopping_list_ids:
|
||||||
if list_id is not None:
|
if list_id is not None:
|
||||||
sl = await shopping.load_shopping_list(conn, list_id)
|
sl = await shopping.load_shopping_list(conn, list_id)
|
||||||
if sl is not None:
|
if sl is not None:
|
||||||
other_lists_domain[list_id] = sl
|
shopping_list_lookup[list_id] = sl
|
||||||
|
|
||||||
# Add any additional items from shopping lists to the existing lookups
|
# 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]
|
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
|
||||||
if additional_items:
|
if additional_items:
|
||||||
await shopping.to_lookups(
|
await shopping.to_lookups(
|
||||||
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
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(
|
return CurrentShoppingList(
|
||||||
outstanding_items=[_to_ingredient_item(i) for i in outstanding_requests],
|
outstanding_items=outstanding_requests,
|
||||||
requested_meals=[_to_meal_item(i) for i in meal_requests],
|
requested_meals=meal_requests,
|
||||||
purchased_items=[_to_ingredient_item(i) for i in purchased_requests],
|
purchased_items=purchased_requests,
|
||||||
meals_lookup=meals_lookup,
|
meals_lookup=meals_lookup,
|
||||||
shopping_list_lookup=shopping_list_lookup,
|
shopping_list_lookup=shopping_list_lookup,
|
||||||
ingredients_lookup=ingredients_lookup,
|
ingredients_lookup=ingredients_lookup,
|
||||||
|
|
@ -171,11 +72,10 @@ async def get_current_shopping_list(
|
||||||
|
|
||||||
|
|
||||||
class PurchasedShoppingList(ApiModel):
|
class PurchasedShoppingList(ApiModel):
|
||||||
list: ShoppingListOut
|
list: shopping.ShoppingList
|
||||||
# Lookup maps are required to be present (may be empty)
|
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
||||||
meals_lookup: Dict[int, meals.Meal]
|
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
||||||
recipes_lookup: Dict[int, recipes.Recipe]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -202,7 +102,7 @@ async def get_shopping_list(
|
||||||
conn, shopping_list.items
|
conn, shopping_list.items
|
||||||
)
|
)
|
||||||
return PurchasedShoppingList(
|
return PurchasedShoppingList(
|
||||||
list=_to_shopping_list_out(shopping_list),
|
list=shopping_list,
|
||||||
meals_lookup=meals_lookup,
|
meals_lookup=meals_lookup,
|
||||||
recipes_lookup=recipes_lookup,
|
recipes_lookup=recipes_lookup,
|
||||||
ingredients_lookup=ingredients_lookup,
|
ingredients_lookup=ingredients_lookup,
|
||||||
|
|
@ -228,7 +128,7 @@ async def get_shopping_list(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def purchase_ingredients(
|
async def purchase_ingredients(
|
||||||
shopping_list: PurchaseListIn,
|
shopping_list: shopping.ShoppingList,
|
||||||
request: Request,
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
|
|
@ -237,41 +137,24 @@ async def purchase_ingredients(
|
||||||
if not person:
|
if not person:
|
||||||
return error_response(request, 401, "Unauthorized")
|
return error_response(request, 401, "Unauthorized")
|
||||||
|
|
||||||
# Map outward input DTO to domain model
|
# Attach purchaser to ensure purchased_by_id is set via BaseLinkedModel
|
||||||
domain_items: List[shopping.ShoppingListItem] = []
|
shopping_list = shopping.ShoppingList(
|
||||||
for it in shopping_list.items:
|
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
|
||||||
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:
|
try:
|
||||||
await shopping.purchase(conn, domain_list)
|
await shopping.purchase(conn, shopping_list)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Map domain validation errors to a proper Problem Details response
|
# Map domain validation errors to a proper Problem Details response
|
||||||
return error_response(request, 400, str(e))
|
return error_response(request, 400, str(e))
|
||||||
# Build lookup maps and construct the outward response with required collections
|
result = PurchasedShoppingList(list=shopping_list)
|
||||||
meals_lookup: Dict[int, meals.Meal]
|
await shopping.to_lookups(
|
||||||
recipes_lookup: Dict[int, recipes.Recipe]
|
conn,
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
shopping_list.items,
|
||||||
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
|
result.meals_lookup,
|
||||||
conn, domain_list.items
|
result.recipes_lookup,
|
||||||
)
|
result.ingredients_lookup,
|
||||||
return PurchasedShoppingList(
|
|
||||||
list=_to_shopping_list_out(domain_list),
|
|
||||||
meals_lookup=meals_lookup,
|
|
||||||
recipes_lookup=recipes_lookup,
|
|
||||||
ingredients_lookup=ingredients_lookup,
|
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -319,7 +202,7 @@ class MealIdWrapper(ApiModel):
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/current/meals/me",
|
"/current/meals/me",
|
||||||
response_model=RequestedMealItem,
|
response_model=shopping.ShoppingListItem,
|
||||||
operation_id="requestMeal",
|
operation_id="requestMeal",
|
||||||
summary="Request a meal for shopping",
|
summary="Request a meal for shopping",
|
||||||
responses={
|
responses={
|
||||||
|
|
@ -335,13 +218,13 @@ async def request_meal(
|
||||||
request: Request,
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
) -> RequestedMealItem | Response:
|
) -> shopping.ShoppingListItem | Response:
|
||||||
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
||||||
response = await shopping.request(conn, person, meal=meal)
|
response = await shopping.request(conn, person, meal=meal)
|
||||||
return _to_meal_item(response)
|
return response
|
||||||
|
|
||||||
|
|
||||||
class Ok(ApiModel):
|
class Ok(ApiModel):
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class Page(ApiModel, Generic[T]):
|
class Page(ApiModel, Generic[T]):
|
||||||
items: List[T] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
items: List[T]
|
||||||
next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
|
next_cursor: Optional[str] = Field(default=None, alias="nextCursor")
|
||||||
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
|
prev_cursor: Optional[str] = Field(default=None, alias="prevCursor")
|
||||||
total: int = Field(default=0, description="Total count")
|
total: Optional[int] = Field(default=None, description="Optional total count")
|
||||||
|
|
|
||||||
26
main.py
26
main.py
|
|
@ -91,32 +91,6 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
|
||||||
for e in exc.errors():
|
for e in exc.errors():
|
||||||
loc = ".".join([str(p) for p in e.get("loc", [])])
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
||||||
errors.setdefault(loc, []).append(e.get("msg"))
|
errors.setdefault(loc, []).append(e.get("msg"))
|
||||||
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
|
|
||||||
try:
|
|
||||||
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
|
|
||||||
except Exception:
|
|
||||||
is_shopping_post = False
|
|
||||||
if is_shopping_post:
|
|
||||||
if any(
|
|
||||||
isinstance(e.get("loc"), (list, tuple))
|
|
||||||
and len(e.get("loc")) >= 2
|
|
||||||
and e.get("loc")[0] == "cookie"
|
|
||||||
and e.get("loc")[1] == "user_id"
|
|
||||||
for e in exc.errors()
|
|
||||||
):
|
|
||||||
body = ProblemDetails(
|
|
||||||
title="Unauthorized",
|
|
||||||
status=401,
|
|
||||||
type="https://httpstatuses.com/401",
|
|
||||||
instance=str(request.url),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
|
||||||
content=body.model_dump(by_alias=True),
|
|
||||||
status_code=401,
|
|
||||||
media_type="application/problem+json",
|
|
||||||
)
|
|
||||||
|
|
||||||
body = ProblemDetails(
|
body = ProblemDetails(
|
||||||
title="Validation Error",
|
title="Validation Error",
|
||||||
status=422,
|
status=422,
|
||||||
|
|
|
||||||
585
openapi.json
585
openapi.json
|
|
@ -82,7 +82,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RecipeOut"
|
"$ref": "#/components/schemas/Recipe-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +224,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/Page_RecipeOut_"
|
"$ref": "#/components/schemas/Page_Recipe_"
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"items": [
|
"items": [
|
||||||
|
|
@ -290,7 +290,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RecipeOut"
|
"$ref": "#/components/schemas/Recipe-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,7 +341,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RecipeOut"
|
"$ref": "#/components/schemas/Recipe-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +503,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MealOut"
|
"$ref": "#/components/schemas/Meal-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +557,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MealOut"
|
"$ref": "#/components/schemas/Meal-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -613,7 +613,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MealOut"
|
"$ref": "#/components/schemas/Meal-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +663,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MealOut"
|
"$ref": "#/components/schemas/Meal-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -735,7 +735,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/MealOut"
|
"$ref": "#/components/schemas/Meal-Output"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -859,7 +859,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/PurchaseListIn"
|
"$ref": "#/components/schemas/ShoppingList"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1058,7 +1058,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RequestedMealItem"
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1397,26 +1397,23 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"outstandingItems": {
|
"outstandingItems": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Outstandingitems"
|
"title": "Outstandingitems"
|
||||||
},
|
},
|
||||||
"requestedMeals": {
|
"requestedMeals": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/RequestedMealItem"
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Requestedmeals"
|
"title": "Requestedmeals"
|
||||||
},
|
},
|
||||||
"purchasedItems": {
|
"purchasedItems": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Purchaseditems"
|
"title": "Purchaseditems"
|
||||||
},
|
},
|
||||||
"ingredientsLookup": {
|
"ingredientsLookup": {
|
||||||
|
|
@ -1435,7 +1432,7 @@
|
||||||
},
|
},
|
||||||
"shoppingListLookup": {
|
"shoppingListLookup": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/components/schemas/ShoppingListOut"
|
"$ref": "#/components/schemas/ShoppingList"
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Shoppinglistlookup"
|
"title": "Shoppinglistlookup"
|
||||||
|
|
@ -1451,12 +1448,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"outstandingItems",
|
"outstandingItems",
|
||||||
"requestedMeals",
|
"requestedMeals"
|
||||||
"purchasedItems",
|
|
||||||
"ingredientsLookup",
|
|
||||||
"mealsLookup",
|
|
||||||
"shoppingListLookup",
|
|
||||||
"recipesLookup"
|
|
||||||
],
|
],
|
||||||
"title": "CurrentShoppingList"
|
"title": "CurrentShoppingList"
|
||||||
},
|
},
|
||||||
|
|
@ -1583,129 +1575,6 @@
|
||||||
],
|
],
|
||||||
"title": "Ingredient"
|
"title": "Ingredient"
|
||||||
},
|
},
|
||||||
"IngredientPurchaseItemIn": {
|
|
||||||
"properties": {
|
|
||||||
"ingredientId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Ingredientid"
|
|
||||||
},
|
|
||||||
"personId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Personid"
|
|
||||||
},
|
|
||||||
"createdDate": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Createddate"
|
|
||||||
},
|
|
||||||
"mealId": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Mealid"
|
|
||||||
},
|
|
||||||
"recipeId": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Recipeid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"ingredientId",
|
|
||||||
"personId"
|
|
||||||
],
|
|
||||||
"title": "IngredientPurchaseItemIn"
|
|
||||||
},
|
|
||||||
"ListIngredientItem": {
|
|
||||||
"properties": {
|
|
||||||
"kind": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"ingredient"
|
|
||||||
],
|
|
||||||
"const": "ingredient",
|
|
||||||
"title": "Kind",
|
|
||||||
"default": "ingredient"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Id",
|
|
||||||
"default": -1
|
|
||||||
},
|
|
||||||
"ingredientId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Ingredientid"
|
|
||||||
},
|
|
||||||
"personId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Personid"
|
|
||||||
},
|
|
||||||
"createdDate": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Createddate"
|
|
||||||
},
|
|
||||||
"listId": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Listid"
|
|
||||||
},
|
|
||||||
"mealId": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Mealid"
|
|
||||||
},
|
|
||||||
"recipeId": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Recipeid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"ingredientId",
|
|
||||||
"personId",
|
|
||||||
"createdDate"
|
|
||||||
],
|
|
||||||
"title": "ListIngredientItem"
|
|
||||||
},
|
|
||||||
"LoginBody": {
|
"LoginBody": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"username": {
|
"username": {
|
||||||
|
|
@ -1888,94 +1757,6 @@
|
||||||
],
|
],
|
||||||
"title": "MealIdWrapper"
|
"title": "MealIdWrapper"
|
||||||
},
|
},
|
||||||
"MealOut": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Id",
|
|
||||||
"default": -1
|
|
||||||
},
|
|
||||||
"suggestedDate": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Suggesteddate"
|
|
||||||
},
|
|
||||||
"consumedDate": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Consumeddate"
|
|
||||||
},
|
|
||||||
"chefs": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Person"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Chefs"
|
|
||||||
},
|
|
||||||
"cleanup": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Person"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Cleanup"
|
|
||||||
},
|
|
||||||
"consumers": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Person"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Consumers"
|
|
||||||
},
|
|
||||||
"recipes": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/MealRecipe-Output"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Recipes"
|
|
||||||
},
|
|
||||||
"extraIngredients": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Ingredient"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Extraingredients"
|
|
||||||
},
|
|
||||||
"purchaseDate": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Purchasedate"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"suggestedDate",
|
|
||||||
"chefs",
|
|
||||||
"cleanup",
|
|
||||||
"consumers",
|
|
||||||
"recipes",
|
|
||||||
"extraIngredients"
|
|
||||||
],
|
|
||||||
"title": "MealOut"
|
|
||||||
},
|
|
||||||
"MealRecipe-Input": {
|
"MealRecipe-Input": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"mealId": {
|
"mealId": {
|
||||||
|
|
@ -2060,7 +1841,6 @@
|
||||||
"$ref": "#/components/schemas/Person"
|
"$ref": "#/components/schemas/Person"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
},
|
},
|
||||||
"nextCursor": {
|
"nextCursor": {
|
||||||
|
|
@ -2086,10 +1866,16 @@
|
||||||
"title": "Prevcursor"
|
"title": "Prevcursor"
|
||||||
},
|
},
|
||||||
"total": {
|
"total": {
|
||||||
"type": "integer",
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Total",
|
"title": "Total",
|
||||||
"description": "Total count",
|
"description": "Optional total count"
|
||||||
"default": 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -2098,14 +1884,13 @@
|
||||||
],
|
],
|
||||||
"title": "Page[Person]"
|
"title": "Page[Person]"
|
||||||
},
|
},
|
||||||
"Page_RecipeOut_": {
|
"Page_Recipe_": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"items": {
|
"items": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/RecipeOut"
|
"$ref": "#/components/schemas/Recipe-Output"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
},
|
},
|
||||||
"nextCursor": {
|
"nextCursor": {
|
||||||
|
|
@ -2131,17 +1916,23 @@
|
||||||
"title": "Prevcursor"
|
"title": "Prevcursor"
|
||||||
},
|
},
|
||||||
"total": {
|
"total": {
|
||||||
"type": "integer",
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Total",
|
"title": "Total",
|
||||||
"description": "Total count",
|
"description": "Optional total count"
|
||||||
"default": 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"items"
|
"items"
|
||||||
],
|
],
|
||||||
"title": "Page[RecipeOut]"
|
"title": "Page[Recipe]"
|
||||||
},
|
},
|
||||||
"Person": {
|
"Person": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -2255,6 +2046,17 @@
|
||||||
"imgLarge": {
|
"imgLarge": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Imglarge"
|
"title": "Imglarge"
|
||||||
|
},
|
||||||
|
"rawData": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Rawdata"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -2290,30 +2092,10 @@
|
||||||
],
|
],
|
||||||
"title": "ProductUrl"
|
"title": "ProductUrl"
|
||||||
},
|
},
|
||||||
"PurchaseListIn": {
|
|
||||||
"properties": {
|
|
||||||
"storeName": {
|
|
||||||
"$ref": "#/components/schemas/StoreNameOut"
|
|
||||||
},
|
|
||||||
"items": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/IngredientPurchaseItemIn"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"title": "Items"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"storeName",
|
|
||||||
"items"
|
|
||||||
],
|
|
||||||
"title": "PurchaseListIn"
|
|
||||||
},
|
|
||||||
"PurchasedShoppingList": {
|
"PurchasedShoppingList": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"list": {
|
"list": {
|
||||||
"$ref": "#/components/schemas/ShoppingListOut"
|
"$ref": "#/components/schemas/ShoppingList"
|
||||||
},
|
},
|
||||||
"mealsLookup": {
|
"mealsLookup": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
|
|
@ -2339,10 +2121,7 @@
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"list",
|
"list"
|
||||||
"mealsLookup",
|
|
||||||
"ingredientsLookup",
|
|
||||||
"recipesLookup"
|
|
||||||
],
|
],
|
||||||
"title": "PurchasedShoppingList"
|
"title": "PurchasedShoppingList"
|
||||||
},
|
},
|
||||||
|
|
@ -2396,7 +2175,14 @@
|
||||||
"title": "Datecreated"
|
"title": "Datecreated"
|
||||||
},
|
},
|
||||||
"createdById": {
|
"createdById": {
|
||||||
"type": "integer",
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Createdbyid"
|
"title": "Createdbyid"
|
||||||
},
|
},
|
||||||
"createdBy": {
|
"createdBy": {
|
||||||
|
|
@ -2502,7 +2288,14 @@
|
||||||
"title": "Datecreated"
|
"title": "Datecreated"
|
||||||
},
|
},
|
||||||
"createdById": {
|
"createdById": {
|
||||||
"type": "integer",
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Createdbyid"
|
"title": "Createdbyid"
|
||||||
},
|
},
|
||||||
"createdBy": {
|
"createdBy": {
|
||||||
|
|
@ -2558,178 +2351,26 @@
|
||||||
],
|
],
|
||||||
"title": "Recipe"
|
"title": "Recipe"
|
||||||
},
|
},
|
||||||
"RecipeOut": {
|
"ShoppingList": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"title": "Id",
|
"title": "Id",
|
||||||
"default": -1
|
"default": -1
|
||||||
},
|
},
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Name"
|
|
||||||
},
|
|
||||||
"link": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Link"
|
|
||||||
},
|
|
||||||
"serves": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Serves"
|
|
||||||
},
|
|
||||||
"imageUrls": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Imageurls"
|
|
||||||
},
|
|
||||||
"ingredients": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Ingredient"
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 0,
|
|
||||||
"title": "Ingredients"
|
|
||||||
},
|
|
||||||
"basedOnRecipe": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Basedonrecipe"
|
|
||||||
},
|
|
||||||
"dateCreated": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Datecreated"
|
|
||||||
},
|
|
||||||
"createdById": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Createdbyid"
|
|
||||||
},
|
|
||||||
"createdBy": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Person"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dateHidden": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Datehidden"
|
|
||||||
},
|
|
||||||
"hiddenById": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Hiddenbyid"
|
|
||||||
},
|
|
||||||
"hiddenBy": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Person"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"link",
|
|
||||||
"serves",
|
|
||||||
"imageUrls",
|
|
||||||
"ingredients",
|
|
||||||
"dateCreated",
|
|
||||||
"createdById"
|
|
||||||
],
|
|
||||||
"title": "RecipeOut"
|
|
||||||
},
|
|
||||||
"RequestedMealItem": {
|
|
||||||
"properties": {
|
|
||||||
"kind": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"requestedMeal"
|
|
||||||
],
|
|
||||||
"const": "requestedMeal",
|
|
||||||
"title": "Kind",
|
|
||||||
"default": "requestedMeal"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Id",
|
|
||||||
"default": -1
|
|
||||||
},
|
|
||||||
"personId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Personid"
|
|
||||||
},
|
|
||||||
"mealId": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Mealid"
|
|
||||||
},
|
|
||||||
"createdDate": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"title": "Createddate"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"personId",
|
|
||||||
"mealId",
|
|
||||||
"createdDate"
|
|
||||||
],
|
|
||||||
"title": "RequestedMealItem"
|
|
||||||
},
|
|
||||||
"ShoppingListOut": {
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "integer",
|
|
||||||
"title": "Id"
|
|
||||||
},
|
|
||||||
"createdDate": {
|
"createdDate": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"title": "Createddate"
|
"title": "Createddate"
|
||||||
},
|
},
|
||||||
"storeName": {
|
"storeName": {
|
||||||
"type": "string",
|
"$ref": "#/components/schemas/StoreEnum",
|
||||||
"enum": [
|
"default": ""
|
||||||
"woolworths",
|
|
||||||
"coles",
|
|
||||||
"home"
|
|
||||||
],
|
|
||||||
"title": "Storename"
|
|
||||||
},
|
},
|
||||||
"purchasedById": {
|
"purchasedById": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"title": "Purchasedbyid"
|
"title": "Purchasedbyid",
|
||||||
|
"default": -1
|
||||||
},
|
},
|
||||||
"purchasedBy": {
|
"purchasedBy": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|
@ -2743,22 +2384,79 @@
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/ListIngredientItem"
|
"$ref": "#/components/schemas/ShoppingListItem"
|
||||||
},
|
},
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 0,
|
|
||||||
"title": "Items"
|
"title": "Items"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"title": "ShoppingList"
|
||||||
"id",
|
},
|
||||||
"createdDate",
|
"ShoppingListItem": {
|
||||||
"storeName",
|
"properties": {
|
||||||
"purchasedById",
|
"id": {
|
||||||
"items"
|
"type": "integer",
|
||||||
|
"title": "Id",
|
||||||
|
"default": -1
|
||||||
|
},
|
||||||
|
"listId": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"title": "ShoppingListOut"
|
"title": "Listid"
|
||||||
|
},
|
||||||
|
"personId": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Personid",
|
||||||
|
"default": -1
|
||||||
|
},
|
||||||
|
"ingredientId": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Ingredientid"
|
||||||
|
},
|
||||||
|
"recipeId": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Recipeid"
|
||||||
|
},
|
||||||
|
"mealId": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Mealid"
|
||||||
|
},
|
||||||
|
"createdDate": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Createddate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "ShoppingListItem"
|
||||||
},
|
},
|
||||||
"StoreEnum": {
|
"StoreEnum": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -2801,15 +2499,6 @@
|
||||||
"type"
|
"type"
|
||||||
],
|
],
|
||||||
"title": "ValidationError"
|
"title": "ValidationError"
|
||||||
},
|
|
||||||
"StoreNameOut": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"woolworths",
|
|
||||||
"coles",
|
|
||||||
"home"
|
|
||||||
],
|
|
||||||
"title": "StoreNameOut"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar, List, Optional
|
from typing import ClassVar, List, Optional
|
||||||
|
|
||||||
from pydantic import PrivateAttr
|
|
||||||
|
|
||||||
from common import ApiModel
|
from common import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,13 +28,5 @@ class Product(ApiModel):
|
||||||
unit: str
|
unit: str
|
||||||
img_small: str
|
img_small: str
|
||||||
img_large: str
|
img_large: str
|
||||||
# Non-persisted field used in tests and insert helper (not part of public schema)
|
# Non-persisted field used in tests and insert helper
|
||||||
_raw_data: Optional[dict] = PrivateAttr(default=None)
|
raw_data: Optional[dict] = None
|
||||||
|
|
||||||
@property
|
|
||||||
def raw_data(self) -> Optional[dict]:
|
|
||||||
return self._raw_data
|
|
||||||
|
|
||||||
@raw_data.setter
|
|
||||||
def raw_data(self, value: Optional[dict]) -> None:
|
|
||||||
self._raw_data = value
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I"]
|
select = ["E", "F", "I"]
|
||||||
ignore = ["E203", "E501", "I001"]
|
ignore = ["E203", "E501"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/**.py" = [
|
"tests/**.py" = [
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class Recipe(ApiModel):
|
||||||
date_created: datetime.datetime = Field(
|
date_created: datetime.datetime = Field(
|
||||||
default_factory=lambda: datetime.datetime.now().astimezone()
|
default_factory=lambda: datetime.datetime.now().astimezone()
|
||||||
)
|
)
|
||||||
created_by_id: int
|
created_by_id: Optional[int]
|
||||||
created_by: Optional[Person] = None
|
created_by: Optional[Person] = None
|
||||||
|
|
||||||
date_hidden: Optional[datetime.datetime] = None
|
date_hidden: Optional[datetime.datetime] = None
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
# Tighten Public API Nullability
|
|
||||||
|
|
||||||
Make the external API more consistent and predictable by eliminating unnecessary nulls (nullable fields) in models and responses. This plan lists concrete, low-risk changes, their rationale, and exact files/lines to modify. Each task is checkable and includes verification steps.
|
|
||||||
|
|
||||||
Date: 2025-10-21
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
- Prefer non-nullable types where domain requires a value (DB constraints, logic always sets it).
|
|
||||||
- Keep optional only when a field truly may be absent by design (e.g., hiddenBy, prevCursor when first page).
|
|
||||||
- Preserve backward compatibility where feasible. When changing response shapes, update tests and OpenAPI examples.
|
|
||||||
- Pydantic already excludes None on serialization in some places; we still tighten model types to improve OpenAPI and client SDKs.
|
|
||||||
|
|
||||||
## Quick wins (low risk)
|
|
||||||
|
|
||||||
- [x] Page.total is non-nullable with default 0
|
|
||||||
- Why: Pagination always returns a number. Current `Optional[int]` leads to `null` in schema and potential nulls in responses.
|
|
||||||
- Change: in `common.py`, change `total: Optional[int]` to `total: int = Field(default=0, description="Total count")`.
|
|
||||||
- Verify:
|
|
||||||
- [ ] mypy/pyright/ruff pass.
|
|
||||||
- [ ] Tests for persons/recipes list remain green.
|
|
||||||
- [ ] OpenAPI shows `total` as `integer` (no anyOf null).
|
|
||||||
|
|
||||||
- [x] Recipe.created_by_id non-nullable
|
|
||||||
- Why: DB enforces NOT NULL and creation flow always sets it.
|
|
||||||
- Change: in `recipes/models.py` set `created_by_id: int` (remove Optional). Keep `created_by: Optional[Person]` (hydrated field).
|
|
||||||
- Knock-on: `api/recipes.load_full_recipe` can drop the `if r.created_by_id is not None` guard.
|
|
||||||
- Verify:
|
|
||||||
- [ ] All recipe-related tests green.
|
|
||||||
- [ ] OpenAPI for Recipe shows `createdById` required.
|
|
||||||
|
|
||||||
- [x] Product.raw_data excluded from public schema
|
|
||||||
- Why: Internal/testing helper currently typed as `Optional[dict]` -> visible as nullable in OpenAPI.
|
|
||||||
- Change: in `products/models.py` use a `PrivateAttr` (with a `raw_data` property) so it stays out of the schema without creating Input/Output variants.
|
|
||||||
- Verify:
|
|
||||||
- [ ] Product schema in OpenAPI does not include `rawData`.
|
|
||||||
- [ ] Tests referencing raw_data still pass (field remains available in code, excluded from schema/response).
|
|
||||||
|
|
||||||
## Shopping models and endpoints
|
|
||||||
|
|
||||||
`ShoppingListItem` currently represents two cases (ingredient request vs meal request), so several linking fields are nullable. We can reduce nulls in the public API by introducing outward-facing variants while keeping the DB model as-is.
|
|
||||||
|
|
||||||
- [ ] Optional: Introduce discriminated union for API returns (medium change)
|
|
||||||
- Rationale: Return `oneOf` in OpenAPI with variant-specific required fields; eliminates irrelevant nullable properties for each variant.
|
|
||||||
- Approach (sketch):
|
|
||||||
- Define `ListIngredientItem` and `RequestedMealItem` pydantic models with a `kind` discriminator.
|
|
||||||
- Update `api/shopping.py` response models (CurrentShoppingList and PurchasedShoppingList) to use `Union[ListIngredientItem, RequestedMealItem]` for item arrays.
|
|
||||||
- Conversion helpers in `shopping` module to map from `ShoppingListItem` DB model to the outward union.
|
|
||||||
- Verify:
|
|
||||||
- [ ] Update tests in `tests/test_shopping_api.py` to accept the new shape while preserving field meanings.
|
|
||||||
- [ ] OpenAPI shows `oneOf` for shopping list items.
|
|
||||||
|
|
||||||
- [ ] Tighten invariants without breaking shape (keep for now)
|
|
||||||
- Keep model but document invariants (only one of ingredient_id/meal_id required; recipe_id optional when meal request). Repository already validates; consider pydantic validators later.
|
|
||||||
|
|
||||||
## Persons and Recipes listings
|
|
||||||
|
|
||||||
- [ ] Ensure total is populated for Person and Recipe lists
|
|
||||||
- Already implemented in `api/persons.py` and `api/recipes.py` using repository `count_*` helpers. After making `Page.total` non-nullable, nothing else required.
|
|
||||||
|
|
||||||
## Authentication dependency
|
|
||||||
|
|
||||||
- [x] Provide strict non-null person dependency for protected endpoints
|
|
||||||
- Why: Many endpoints assume an authenticated user; typing as non-null simplifies signatures and docs.
|
|
||||||
- Change:
|
|
||||||
- Consolidated on a single dependency `cookie_person` (strict): `Cookie(..., alias="user_id")` and raises 401 if missing/unknown.
|
|
||||||
- Removed `require_cookie_person` and switched usages to `cookie_person`.
|
|
||||||
- Verify:
|
|
||||||
- [x] Endpoint signatures updated.
|
|
||||||
- [x] Unauthorized behavior covered by handlers; overall tests still pass.
|
|
||||||
|
|
||||||
## File-by-file checklist (edits)
|
|
||||||
|
|
||||||
- [x] `common.py`
|
|
||||||
- [x] Page.total -> `int = Field(default=0, ...)`
|
|
||||||
|
|
||||||
- [x] `recipes/models.py`
|
|
||||||
- [x] `created_by_id: int`
|
|
||||||
|
|
||||||
- [x] `api/recipes.py`
|
|
||||||
- [x] In `load_full_recipe`, set `r.created_by = await persons.get_by_id(conn, r.created_by_id)` unconditionally.
|
|
||||||
|
|
||||||
- [x] `products/models.py`
|
|
||||||
- [x] `raw_data` moved to `PrivateAttr` with property; kept out of schema.
|
|
||||||
|
|
||||||
- [x] `api/deps.py`
|
|
||||||
- [x] Added `require_cookie_person(...) -> persons.Person` that raises 401.
|
|
||||||
- [x] Updated protected endpoints to depend on `require_cookie_person`.
|
|
||||||
|
|
||||||
- [ ] (Optional) Shopping API union types
|
|
||||||
- [x] Add outward-facing union models and mapping helpers.
|
|
||||||
- [x] Update `api/shopping.py` response models to use union.
|
|
||||||
|
|
||||||
## Tests and validation
|
|
||||||
|
|
||||||
- [x] Run format/lint/typecheck
|
|
||||||
- make format && make lint && make typecheck
|
|
||||||
- [x] Run tests
|
|
||||||
- make test
|
|
||||||
- [x] Export OpenAPI and inspect schema
|
|
||||||
- make openapi (confirm: Recipe.createdById required; Product has single schema; Page.total non-nullable; cookie param required on protected ops.)
|
|
||||||
|
|
||||||
## Rollout notes
|
|
||||||
|
|
||||||
- API change in OpenAPI (non-null total; createdById required). Client SDKs generated from the spec may need re-gen. Runtime remains compatible because server fills these fields.
|
|
||||||
- Even without the optional union refactor, the quick wins remove several unnecessary nulls.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- OpenAPI no longer marks Page.total and Recipe.createdById as nullable.
|
|
||||||
- Product.rawData does not appear in the public schema.
|
|
||||||
- All tests pass; no runtime regressions.
|
|
||||||
- Optional: Shopping list items use `oneOf` variants.
|
|
||||||
Loading…
Reference in a new issue