Removed redundant code from master and route to new submodules
This commit is contained in:
parent
f51c90f922
commit
a3c0a2701e
7 changed files with 52 additions and 609 deletions
|
|
@ -25,7 +25,7 @@ class LoginBody(ApiModel):
|
||||||
404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}
|
404: {"model": ProblemDetails, "description": "Person not found", "content": {"application/problem+json": {}}}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def login(data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> persons.Person | JSONResponse:
|
async def login(request: Request, data: LoginBody, conn: aiosqlite.Connection = Depends(get_db)) -> persons.Person | JSONResponse:
|
||||||
person = await persons.get_by_name(conn, data.username)
|
person = await persons.get_by_name(conn, data.username)
|
||||||
if not person:
|
if not person:
|
||||||
return error_response(request, 404, "Person not found")
|
return error_response(request, 404, "Person not found")
|
||||||
|
|
|
||||||
10
api/meals.py
10
api/meals.py
|
|
@ -37,7 +37,7 @@ async def get_upcoming_meals(
|
||||||
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
|
@router.get("/{meal_id}", response_model=meals.Meal, operation_id="getMeal", summary="Get a meal by id",
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||||
async def get_meal(
|
async def get_meal(
|
||||||
meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal:
|
) -> meals.Meal:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
|
|
@ -49,7 +49,7 @@ async def get_meal(
|
||||||
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
|
@router.post("", response_model=meals.Meal, operation_id="createMeal", summary="Create a new meal",
|
||||||
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
responses={400: {"model": ProblemDetails, "description": "Validation error", "content": {"application/problem+json": {}}}})
|
||||||
async def create_meal(
|
async def create_meal(
|
||||||
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal:
|
) -> meals.Meal:
|
||||||
validation_response = validate_meal(meal, request)
|
validation_response = validate_meal(meal, request)
|
||||||
if validation_response:
|
if validation_response:
|
||||||
|
|
@ -66,7 +66,7 @@ async def create_meal(
|
||||||
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}},
|
||||||
})
|
})
|
||||||
async def update_meal(
|
async def update_meal(
|
||||||
meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
meal_id: int, meal: meals.Meal, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> meals.Meal:
|
) -> meals.Meal:
|
||||||
if meal.id != meal_id:
|
if meal.id != meal_id:
|
||||||
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
||||||
|
|
@ -92,10 +92,10 @@ async def update_meal(
|
||||||
})
|
})
|
||||||
async def mark_consumed(
|
async def mark_consumed(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
|
request: Request,
|
||||||
consumed_date: Optional[datetime.datetime] = None,
|
consumed_date: Optional[datetime.datetime] = None,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
person: persons.Person = Depends(cookie_person),
|
person: persons.Person = Depends(cookie_person),
|
||||||
request: Request | None = None,
|
|
||||||
) -> meals.Meal:
|
) -> meals.Meal:
|
||||||
if consumed_date and not consumed_date.tzinfo:
|
if consumed_date and not consumed_date.tzinfo:
|
||||||
return error_response(request, 400, "Consumed date must include timezone")
|
return error_response(request, 400, "Consumed date must include timezone")
|
||||||
|
|
@ -115,9 +115,9 @@ async def mark_consumed(
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||||
async def delete_meal(
|
async def delete_meal(
|
||||||
meal_id: int,
|
meal_id: int,
|
||||||
|
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),
|
||||||
request: Request | None = None,
|
|
||||||
) -> meals.Meal:
|
) -> meals.Meal:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
import persons
|
import persons
|
||||||
from common import Page
|
from common import Page
|
||||||
|
|
@ -54,7 +54,6 @@ async def list_persons(
|
||||||
description="Maximum number of items to return (1-200).",
|
description="Maximum number of items to return (1-200).",
|
||||||
),
|
),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
request: Request | None = None,
|
|
||||||
) -> Page[persons.Person]:
|
) -> Page[persons.Person]:
|
||||||
# v1: DB-backed pagination
|
# v1: DB-backed pagination
|
||||||
last_id = None
|
last_id = None
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,12 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||||
import ingredients
|
import ingredients
|
||||||
import recipes
|
import recipes
|
||||||
import persons
|
import persons
|
||||||
from common import Page, ProblemDetails, ApiModel
|
from common import Page, ProblemDetails
|
||||||
from pydantic import Field
|
|
||||||
from api.deps import get_db, cookie_person, error_response
|
from api.deps import get_db, cookie_person, error_response
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||||
|
|
||||||
|
|
||||||
class ProductUrl(ApiModel):
|
|
||||||
url: str
|
|
||||||
tags: List[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/parse",
|
"/parse",
|
||||||
response_model=None,
|
response_model=None,
|
||||||
|
|
@ -35,7 +28,7 @@ class ProductUrl(ApiModel):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def parse_recipe_handler(
|
async def parse_recipe_handler(
|
||||||
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request | None = None
|
url: str, request: Request, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person)
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | ProblemDetails:
|
||||||
parsed = await recipes.parse_recipe(conn, person, url)
|
parsed = await recipes.parse_recipe(conn, person, url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
|
|
@ -119,6 +112,7 @@ async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[reci
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
|
request: Request,
|
||||||
q: Optional[str] = Query(
|
q: Optional[str] = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
||||||
|
|
@ -134,7 +128,6 @@ async def list_recipes(
|
||||||
description="Maximum number of items to return (1-200).",
|
description="Maximum number of items to return (1-200).",
|
||||||
),
|
),
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
request: Request | None = None,
|
|
||||||
) -> Page[recipes.Recipe]:
|
) -> Page[recipes.Recipe]:
|
||||||
last_id = None
|
last_id = None
|
||||||
if cursor:
|
if cursor:
|
||||||
|
|
@ -183,7 +176,7 @@ async def list_recipes(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_recipe(
|
async def get_recipe(
|
||||||
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
recipe_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | ProblemDetails:
|
||||||
r = await load_full_recipe(conn, recipe_id)
|
r = await load_full_recipe(conn, recipe_id)
|
||||||
if not r:
|
if not r:
|
||||||
|
|
@ -207,9 +200,9 @@ async def get_recipe(
|
||||||
)
|
)
|
||||||
async def create_recipe(
|
async def create_recipe(
|
||||||
recipe: recipes.Recipe,
|
recipe: recipes.Recipe,
|
||||||
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
request: Request | None = None,
|
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | ProblemDetails:
|
||||||
if not recipe.ingredients:
|
if not recipe.ingredients:
|
||||||
return error_response(request, 400, "Recipe must have at least one ingredient")
|
return error_response(request, 400, "Recipe must have at least one ingredient")
|
||||||
|
|
@ -248,9 +241,9 @@ async def create_recipe(
|
||||||
)
|
)
|
||||||
async def delete_recipe(
|
async def delete_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
|
request: Request,
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
user: persons.Person = Depends(cookie_person),
|
user: persons.Person = Depends(cookie_person),
|
||||||
request: Request | None = None,
|
|
||||||
) -> recipes.Recipe | ProblemDetails:
|
) -> recipes.Recipe | ProblemDetails:
|
||||||
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from api.deps import get_db, cookie_person, error_response
|
||||||
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||||
|
|
||||||
from common import ApiModel, Field
|
from common import ApiModel, Field
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
|
|
@ -81,7 +80,7 @@ class PurchasedShoppingList(ApiModel):
|
||||||
|
|
||||||
@router.get("/{list_id}", response_model=PurchasedShoppingList, operation_id="getShoppingList", summary="Get a purchased shopping list by id",
|
@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": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Shopping list not found", "content": {"application/problem+json": {}}}})
|
||||||
async def get_shopping_list(list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None) -> PurchasedShoppingList | ProblemDetails:
|
async def get_shopping_list(list_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> PurchasedShoppingList | ProblemDetails:
|
||||||
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
||||||
if not shopping_list:
|
if not shopping_list:
|
||||||
return error_response(request, 404, "Shopping list not found")
|
return error_response(request, 404, "Shopping list not found")
|
||||||
|
|
@ -149,7 +148,7 @@ class MealIdWrapper(ApiModel):
|
||||||
|
|
||||||
@router.post("/current/meals/me", response_model=None, operation_id="requestMeal", summary="Request a meal for shopping",
|
@router.post("/current/meals/me", response_model=None, operation_id="requestMeal", summary="Request a meal for shopping",
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||||
async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request | None = None) -> shopping.ShoppingListItem | ProblemDetails:
|
async def request_meal(r: MealIdWrapper, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> shopping.ShoppingListItem | ProblemDetails:
|
||||||
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")
|
||||||
|
|
@ -161,7 +160,7 @@ async def request_meal(r: MealIdWrapper, conn: aiosqlite.Connection = Depends(ge
|
||||||
|
|
||||||
@router.delete("/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", summary="Remove a meal request",
|
@router.delete("/current/meals/{meal_id}", response_model=None, operation_id="unrequestMeal", summary="Remove a meal request",
|
||||||
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
responses={404: {"model": ProblemDetails, "description": "Meal not found", "content": {"application/problem+json": {}}}})
|
||||||
async def unrequest_meal(meal_id: int, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person), request: Request | None = None) -> dict | ProblemDetails:
|
async def unrequest_meal(meal_id: int, request: Request, conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)) -> dict | ProblemDetails:
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
||||||
if not meal:
|
if not meal:
|
||||||
return error_response(request, 404, "Meal not found")
|
return error_response(request, 404, "Meal not found")
|
||||||
|
|
@ -171,63 +170,4 @@ async def unrequest_meal(meal_id: int, conn: aiosqlite.Connection = Depends(get_
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", operation_id="getCurrentShoppingList", summary="Get the current aggregated shopping list")
|
# Removed duplicate placeholder endpoints left over from earlier scaffolding
|
||||||
async def get_current_shopping_list(
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
):
|
|
||||||
raise NotImplementedError("get_current_shopping_list extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{list_id}", 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, conn: aiosqlite.Connection = Depends(get_db), request: Request | None = None
|
|
||||||
):
|
|
||||||
raise NotImplementedError("get_shopping_list extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", operation_id="purchaseIngredients", summary="Purchase ingredients for a shopping list")
|
|
||||||
async def purchase_ingredients(
|
|
||||||
shopping_list: shopping.ShoppingList,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
):
|
|
||||||
raise NotImplementedError("purchase_ingredients extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@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]:
|
|
||||||
raise NotImplementedError("get_my_shopping_list extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@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]:
|
|
||||||
raise NotImplementedError("sync_my_shopping_list extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/current/meals/{meal_id}", 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(
|
|
||||||
meal_id: int,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request | None = None,
|
|
||||||
):
|
|
||||||
raise NotImplementedError("request_meal extraction pending")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/current/meals/{meal_id}", 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,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request | None = None,
|
|
||||||
):
|
|
||||||
raise NotImplementedError("unrequest_meal extraction pending")
|
|
||||||
|
|
|
||||||
548
main.py
548
main.py
|
|
@ -1,20 +1,16 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
from typing import Annotated, Dict, List, Optional, Any
|
from typing import Annotated, Dict, List, Optional, Any
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request
|
from fastapi import Depends, FastAPI, APIRouter, Request
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import Field
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import ingredients
|
|
||||||
import meals
|
|
||||||
import persons
|
|
||||||
import products
|
import products
|
||||||
import recipes
|
|
||||||
import shopping
|
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
from common import ProblemDetails, Page, ApiModel
|
from common import ProblemDetails, Page, ApiModel
|
||||||
|
|
@ -29,7 +25,22 @@ class CamelCaseRoute(APIRoute):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API")
|
@asynccontextmanager
|
||||||
|
async def app_lifespan(app: FastAPI):
|
||||||
|
client = None
|
||||||
|
if not settings.prod:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(base_url=settings.frontend_dev_url)
|
||||||
|
app.state.proxy_client = client
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API", lifespan=app_lifespan)
|
||||||
api_v1 = APIRouter(route_class=CamelCaseRoute)
|
api_v1 = APIRouter(route_class=CamelCaseRoute)
|
||||||
DATABASE_PATH = settings.database_path
|
DATABASE_PATH = settings.database_path
|
||||||
|
|
||||||
|
|
@ -127,530 +138,21 @@ async def create_product(
|
||||||
return await products.get_or_create(conn, url.url, url.tags)
|
return await products.get_or_create(conn, url.url, url.tags)
|
||||||
|
|
||||||
|
|
||||||
async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]:
|
|
||||||
r = await recipes.find_recipe_by_id(conn, id)
|
|
||||||
if not r:
|
|
||||||
return None
|
|
||||||
|
|
||||||
r.ingredients = []
|
|
||||||
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
|
|
||||||
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)
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
from api import recipes as recipes_router # type: ignore
|
from api import recipes as recipes_router # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@api_v1.get(
|
|
||||||
"/meals/upcoming",
|
|
||||||
operation_id="getUpcomingMeals",
|
|
||||||
tags=["meals"],
|
|
||||||
summary="List upcoming meals in a date range",
|
|
||||||
)
|
|
||||||
async def get_upcoming_meals(
|
|
||||||
date_from: Annotated[datetime.datetime, Query(alias="from")],
|
|
||||||
to: datetime.datetime,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
) -> List[meals.Meal]:
|
|
||||||
result = []
|
|
||||||
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
|
|
||||||
await meals.load_recipes(conn, meal)
|
|
||||||
await meals.load_extra_ingredients(conn, meal)
|
|
||||||
await meals.load_participants(conn, meal)
|
|
||||||
result.append(meal)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.get(
|
|
||||||
"/meals/{meal_id}",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="getMeal",
|
|
||||||
tags=["meals"],
|
|
||||||
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, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
|
||||||
) -> meals.Meal | JSONResponse:
|
|
||||||
meal = await meals.find_meal_by_id(conn, meal_id)
|
|
||||||
if not meal:
|
|
||||||
return error_response(request, 404, "Meal not found")
|
|
||||||
|
|
||||||
return meal
|
|
||||||
|
|
||||||
|
|
||||||
def get_duplicates(items: List[meals.Person]) -> set[str]:
|
|
||||||
seen: set[int] = set()
|
|
||||||
duplicates: set[str] = set()
|
|
||||||
for item in items:
|
|
||||||
if item.id in seen:
|
|
||||||
duplicates.add(item.name)
|
|
||||||
seen.add(item.id)
|
|
||||||
return duplicates
|
|
||||||
|
|
||||||
|
|
||||||
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]:
|
|
||||||
if not meal.chefs:
|
|
||||||
return error_response(request, 400, "Meal must have at least one chef")
|
|
||||||
|
|
||||||
if not meal.cleanup:
|
|
||||||
return error_response(request, 400, "Meal must have at least one cleanup person")
|
|
||||||
|
|
||||||
if not meal.consumers:
|
|
||||||
return error_response(request, 400, "Meal must have at least one consumer")
|
|
||||||
|
|
||||||
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
|
||||||
return error_response(request, 400, "Meal must have at least one recipe or ingredient")
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.chefs)
|
|
||||||
if duplicates:
|
|
||||||
return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}')
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.cleanup)
|
|
||||||
if duplicates:
|
|
||||||
return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}')
|
|
||||||
|
|
||||||
duplicates = get_duplicates(meal.consumers)
|
|
||||||
if duplicates:
|
|
||||||
return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}')
|
|
||||||
|
|
||||||
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
|
||||||
if zero_servings:
|
|
||||||
return error_response(request, 400, "Recipe servings must be greater than 0")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/meals",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="createMeal",
|
|
||||||
tags=["meals"],
|
|
||||||
summary="Create a new meal",
|
|
||||||
responses={
|
|
||||||
400: {
|
|
||||||
"model": ProblemDetails,
|
|
||||||
"description": "Validation error",
|
|
||||||
"content": {"application/problem+json": {}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def create_meal(
|
|
||||||
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
|
||||||
) -> meals.Meal | JSONResponse:
|
|
||||||
validation_response = validate_meal(meal, request)
|
|
||||||
if validation_response:
|
|
||||||
return validation_response
|
|
||||||
|
|
||||||
await meals.insert_meal(conn, meal)
|
|
||||||
await conn.commit()
|
|
||||||
return meal
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.put(
|
|
||||||
"/meals/{meal_id}",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="updateMeal",
|
|
||||||
tags=["meals"],
|
|
||||||
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, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
|
||||||
) -> meals.Meal | JSONResponse:
|
|
||||||
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)
|
|
||||||
await conn.commit()
|
|
||||||
|
|
||||||
return await get_meal(meal_id, conn)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/meals/{meal_id}/consumed",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="markMealConsumed",
|
|
||||||
tags=["meals"],
|
|
||||||
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,
|
|
||||||
consumed_date: Optional[datetime.datetime] = None,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request = None,
|
|
||||||
) -> meals.Meal | JSONResponse:
|
|
||||||
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)
|
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
return meal
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.delete(
|
|
||||||
"/meals/{meal_id}",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="deleteMeal",
|
|
||||||
tags=["meals"],
|
|
||||||
summary="Delete a meal",
|
|
||||||
responses={
|
|
||||||
404: {
|
|
||||||
"model": ProblemDetails,
|
|
||||||
"description": "Meal not found",
|
|
||||||
"content": {"application/problem+json": {}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def delete_meal(
|
|
||||||
meal_id: int,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request = None,
|
|
||||||
) -> meals.Meal | JSONResponse:
|
|
||||||
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)
|
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
return meal
|
|
||||||
|
|
||||||
from api import meals as meals_router # type: ignore
|
from api import meals as meals_router # type: ignore
|
||||||
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
|
||||||
outstanding_items: List[shopping.ShoppingListItem]
|
|
||||||
requested_meals: List[shopping.ShoppingListItem]
|
|
||||||
purchased_items: List[shopping.ShoppingListItem] = Field(default_factory=list)
|
|
||||||
|
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
|
||||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
|
||||||
shopping_list_lookup: Dict[int, shopping.ShoppingList] = Field(default_factory=dict)
|
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.get(
|
|
||||||
"/shopping/current",
|
|
||||||
response_model=CurrentShoppingList,
|
|
||||||
operation_id="getCurrentShoppingList",
|
|
||||||
tags=["shopping"],
|
|
||||||
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}
|
|
||||||
|
|
||||||
shopping_list_lookup = {}
|
|
||||||
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:
|
|
||||||
shopping_list_lookup[list_id] = sl
|
|
||||||
|
|
||||||
# Add any additional items from shopping lists to the existing lookups
|
|
||||||
additional_items = [item for sl in shopping_list_lookup.values() for item in sl.items]
|
|
||||||
if additional_items:
|
|
||||||
await shopping.to_lookups(
|
|
||||||
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
|
||||||
)
|
|
||||||
|
|
||||||
return CurrentShoppingList(
|
|
||||||
outstanding_items=outstanding_requests,
|
|
||||||
requested_meals=meal_requests,
|
|
||||||
purchased_items=purchased_requests,
|
|
||||||
meals_lookup=meals_lookup,
|
|
||||||
shopping_list_lookup=shopping_list_lookup,
|
|
||||||
ingredients_lookup=ingredients_lookup,
|
|
||||||
recipes_lookup=recipes_lookup,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PurchasedShoppingList(ApiModel):
|
|
||||||
list: shopping.ShoppingList
|
|
||||||
meals_lookup: Dict[int, meals.Meal] = Field(default_factory=dict)
|
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient] = Field(default_factory=dict)
|
|
||||||
recipes_lookup: Dict[int, recipes.Recipe] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.get(
|
|
||||||
"/shopping/{list_id}",
|
|
||||||
response_model=PurchasedShoppingList,
|
|
||||||
operation_id="getShoppingList",
|
|
||||||
tags=["shopping"],
|
|
||||||
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, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
|
||||||
) -> PurchasedShoppingList | JSONResponse:
|
|
||||||
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=shopping_list,
|
|
||||||
meals_lookup=meals_lookup,
|
|
||||||
recipes_lookup=recipes_lookup,
|
|
||||||
ingredients_lookup=ingredients_lookup,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/shopping/",
|
|
||||||
operation_id="purchaseIngredients",
|
|
||||||
tags=["shopping"],
|
|
||||||
summary="Purchase ingredients for a shopping list",
|
|
||||||
)
|
|
||||||
async def purchase_ingredients(
|
|
||||||
shopping_list: shopping.ShoppingList,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
) -> PurchasedShoppingList:
|
|
||||||
shopping_list = shopping.ShoppingList(
|
|
||||||
purchased_by=person, items=shopping_list.items, store_name=shopping_list.store_name
|
|
||||||
)
|
|
||||||
|
|
||||||
await shopping.purchase(conn, shopping_list)
|
|
||||||
await conn.commit()
|
|
||||||
|
|
||||||
result = PurchasedShoppingList(list=shopping_list)
|
|
||||||
await shopping.to_lookups(
|
|
||||||
conn,
|
|
||||||
shopping_list.items,
|
|
||||||
result.meals_lookup,
|
|
||||||
result.recipes_lookup,
|
|
||||||
result.ingredients_lookup,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.get(
|
|
||||||
"/shopping/current/me/ingredients",
|
|
||||||
operation_id="getMyShoppingList",
|
|
||||||
tags=["shopping"],
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/shopping/current/me/ingredients",
|
|
||||||
operation_id="syncMyShoppingList",
|
|
||||||
tags=["shopping"],
|
|
||||||
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)
|
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
return await get_my_shopping_list(conn, person)
|
|
||||||
|
|
||||||
|
|
||||||
class MealIdWrapper(ApiModel):
|
|
||||||
meal_id: int
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/shopping/current/meals/me",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="requestMeal",
|
|
||||||
tags=["shopping"],
|
|
||||||
summary="Request a meal for shopping",
|
|
||||||
responses={
|
|
||||||
404: {
|
|
||||||
"model": ProblemDetails,
|
|
||||||
"description": "Meal not found",
|
|
||||||
"content": {"application/problem+json": {}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def request_meal(
|
|
||||||
r: MealIdWrapper,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request = None,
|
|
||||||
) -> shopping.ShoppingListItem | JSONResponse:
|
|
||||||
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)
|
|
||||||
await conn.commit()
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.delete(
|
|
||||||
"/shopping/current/meals/{meal_id}",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="unrequestMeal",
|
|
||||||
tags=["shopping"],
|
|
||||||
summary="Remove a meal request",
|
|
||||||
responses={
|
|
||||||
404: {
|
|
||||||
"model": ProblemDetails,
|
|
||||||
"description": "Meal not found",
|
|
||||||
"content": {"application/problem+json": {}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def unrequest_meal(
|
|
||||||
meal_id: int,
|
|
||||||
conn: aiosqlite.Connection = Depends(get_db),
|
|
||||||
person: persons.Person = Depends(cookie_person),
|
|
||||||
request: Request = None,
|
|
||||||
) -> dict | JSONResponse:
|
|
||||||
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 conn.commit()
|
|
||||||
return {}
|
|
||||||
|
|
||||||
from api import shopping as shopping_router # type: ignore
|
from api import shopping as shopping_router # type: ignore
|
||||||
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
|
|
||||||
from api import persons as persons_router # type: ignore
|
from api import persons as persons_router # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class LoginBody(ApiModel):
|
|
||||||
username: str
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/auth/login",
|
|
||||||
response_model=None,
|
|
||||||
operation_id="login",
|
|
||||||
tags=["auth"],
|
|
||||||
summary="Login and set user_id cookie",
|
|
||||||
responses={
|
|
||||||
404: {
|
|
||||||
"model": ProblemDetails,
|
|
||||||
"description": "Person not found",
|
|
||||||
"content": {"application/problem+json": {}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def login(
|
|
||||||
data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
|
||||||
) -> persons.Person | JSONResponse:
|
|
||||||
person = await persons.get_by_name(conn, data.username)
|
|
||||||
if not person:
|
|
||||||
return error_response(request, 404, "Person not found")
|
|
||||||
|
|
||||||
response = JSONResponse(content=jsonable_encoder(person))
|
|
||||||
response.set_cookie(key="user_id", value=str(person.id))
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.post(
|
|
||||||
"/auth/refresh",
|
|
||||||
operation_id="refresh",
|
|
||||||
tags=["auth"],
|
|
||||||
summary="Refresh current user from cookie",
|
|
||||||
)
|
|
||||||
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
|
||||||
return user
|
|
||||||
|
|
||||||
from api import auth as auth_router # type: ignore
|
from api import auth as auth_router # type: ignore
|
||||||
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
|
||||||
|
|
||||||
# RFC7807 Problem Details handlers
|
# RFC7807 Problem Details handlers
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
|
@ -707,7 +209,10 @@ async def request_validation_exc_handler(request: Request, exc: RequestValidatio
|
||||||
# Mount versioned API router
|
# Mount versioned API router
|
||||||
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
|
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
|
||||||
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
app.include_router(recipes_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(meals_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(shopping_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
app.include_router(persons_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
app.include_router(auth_router.router, prefix="/api/v1", tags=["v1"]) # extracted
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|
@ -715,21 +220,20 @@ async def healthz():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
if os.environ.get("DOOF_PROD", False):
|
if settings.prod:
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
||||||
else:
|
else:
|
||||||
# Proxy the request to the frontend development server
|
# Proxy the request to the frontend development server
|
||||||
import httpx
|
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
client = httpx.AsyncClient(base_url="http://localhost:8080/")
|
|
||||||
|
|
||||||
async def _reverse_proxy(request: Request):
|
async def _reverse_proxy(request: Request):
|
||||||
|
import httpx
|
||||||
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
||||||
|
client = app.state.proxy_client
|
||||||
rp_req = client.build_request(
|
rp_req = client.build_request(
|
||||||
request.method, url, headers=request.headers.raw, content=request.stream()
|
request.method, url, headers=request.headers.raw, content=request.stream()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ Acceptance criteria
|
||||||
- [x] api/shopping.py
|
- [x] api/shopping.py
|
||||||
- [x] api/auth.py
|
- [x] api/auth.py
|
||||||
- [x] Wire routers in main with minimal app code
|
- [x] Wire routers in main with minimal app code
|
||||||
- [ ] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed
|
- [x] Move dev reverse proxy setup into a lifespan handler and ensure httpx client is closed
|
||||||
- [x] Add /healthz endpoint (simple JSON: {"status": "ok"})
|
- [x] Add /healthz endpoint (simple JSON: {"status": "ok"})
|
||||||
- [x] Create api/deps module for get_db, cookie_person, and error_response
|
- [x] Create api/deps module for get_db, cookie_person, and error_response
|
||||||
- [x] Use settings.py (DOOF_DB) for DB path in main and deps
|
- [x] Use settings.py (DOOF_DB) for DB path in main and deps
|
||||||
|
|
@ -47,6 +47,13 @@ Acceptance criteria
|
||||||
- Reverse proxy cleaned up; no stray global clients
|
- Reverse proxy cleaned up; no stray global clients
|
||||||
- Health check available and excluded from auth/security
|
- Health check available and excluded from auth/security
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- Lifespan handler initializes a dev httpx.AsyncClient using settings.frontend_dev_url and stores it on app.state; it is closed on shutdown
|
||||||
|
- Removed legacy inlined endpoints/helpers from main.py after extraction
|
||||||
|
- Kept product creation endpoint in main pending Phase 4 move
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — API design and OpenAPI
|
## Phase 2 — API design and OpenAPI
|
||||||
|
|
@ -148,11 +155,11 @@ Note: We can adopt this structure gradually without moving DB code immediately;
|
||||||
- 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz
|
- 2025-10-18: Extracted recipes routes to api/recipes.py and wired router; added /healthz
|
||||||
- 2025-10-18: Extracted persons routes to api/persons.py and wired router
|
- 2025-10-18: Extracted persons routes to api/persons.py and wired router
|
||||||
- 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings
|
- 2025-10-18: Extracted meals, shopping, and auth routes; created api/deps and switched DB path to settings
|
||||||
|
- 2025-10-18: Moved dev reverse proxy to app lifespan; removed dead code from main.py; deduplicated models; tests all passing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next actions
|
## Next actions
|
||||||
- Phase 1: Move dev reverse proxy into a lifespan handler (startup/shutdown) and close AsyncClient cleanly
|
|
||||||
- Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location
|
- Phase 2: Add OpenAPI cookie security scheme and normalize 201 Created + Location
|
||||||
- Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
|
- Phase 3: Plan DB PRAGMAs and indexes; add transaction scoping per request
|
||||||
- Phase 5: Add fixtures for DB/auth and tests for health + 201 Location
|
- Phase 5: Add fixtures for DB/auth and tests for health + 201 Location
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue