2024-01-13 07:42:23 +00:00
|
|
|
import datetime
|
2025-10-18 03:26:42 +00:00
|
|
|
import os
|
2025-10-18 05:44:36 +00:00
|
|
|
from typing import Annotated, Dict, List, Optional, Any
|
2024-01-08 00:45:22 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
import aiosqlite
|
2025-10-18 05:44:36 +00:00
|
|
|
from fastapi import Cookie, Depends, FastAPI, Query, APIRouter, Request
|
2024-04-25 02:03:30 +00:00
|
|
|
from fastapi.encoders import jsonable_encoder
|
2025-10-18 03:26:42 +00:00
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
from pydantic import BaseModel, Field
|
2024-01-07 00:33:40 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
import db
|
|
|
|
|
import ingredients
|
|
|
|
|
import meals
|
|
|
|
|
import persons
|
|
|
|
|
import products
|
|
|
|
|
import recipes
|
|
|
|
|
import shopping
|
2024-01-07 00:33:40 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
from fastapi.routing import APIRoute
|
|
|
|
|
from common import ProblemDetails, Page, ApiModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CamelCaseRoute(APIRoute):
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
kwargs.setdefault("response_model_by_alias", True)
|
|
|
|
|
kwargs.setdefault("response_model_exclude_none", True)
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(title="Doof API", version="1.0.0", description="Doof Backend API")
|
|
|
|
|
api_v1 = APIRouter(route_class=CamelCaseRoute)
|
2025-10-18 03:26:42 +00:00
|
|
|
DATABASE_PATH = os.environ.get("DOOF_DB", "./data/doof.sqlite")
|
2024-01-07 00:33:40 +00:00
|
|
|
|
2024-01-08 00:38:17 +00:00
|
|
|
# Dependency to create SQLite connection
|
|
|
|
|
async def get_db():
|
2024-09-21 01:22:30 +00:00
|
|
|
sql_db = await db.connect(DATABASE_PATH)
|
2024-01-08 00:38:17 +00:00
|
|
|
try:
|
|
|
|
|
yield sql_db
|
|
|
|
|
finally:
|
2024-01-13 01:54:04 +00:00
|
|
|
await sql_db.close()
|
2024-01-08 00:38:17 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
|
|
|
|
async def cookie_person(
|
|
|
|
|
user_id: Annotated[int, Cookie(alias="user_id")], conn: aiosqlite.Connection = Depends(get_db)
|
|
|
|
|
) -> Optional[persons.Person]:
|
2024-04-25 04:57:39 +00:00
|
|
|
return await persons.get_by_id(conn, user_id)
|
2024-04-25 02:03:30 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
|
|
|
|
body = ProblemDetails(
|
|
|
|
|
title=message,
|
|
|
|
|
status=status_code,
|
|
|
|
|
type=f"https://httpstatuses.com/{status_code}",
|
|
|
|
|
instance=str(request.url) if request else None,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=body.model_dump(by_alias=True),
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# OpenAPI reusable responses for ProblemDetails
|
|
|
|
|
def _extend_openapi_with_problem_responses(app: FastAPI) -> None:
|
|
|
|
|
# Attach a custom openapi generation that injects reusable responses
|
|
|
|
|
original_openapi = app.openapi
|
|
|
|
|
|
|
|
|
|
def custom_openapi():
|
|
|
|
|
spec = original_openapi()
|
|
|
|
|
components = spec.setdefault("components", {})
|
|
|
|
|
responses = components.setdefault("responses", {})
|
|
|
|
|
# Standard ProblemDetails responses
|
|
|
|
|
responses.setdefault(
|
|
|
|
|
"Problem400",
|
|
|
|
|
{
|
|
|
|
|
"description": "Bad Request",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/problem+json": {},
|
|
|
|
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
responses.setdefault(
|
|
|
|
|
"Problem404",
|
|
|
|
|
{
|
|
|
|
|
"description": "Not Found",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/problem+json": {},
|
|
|
|
|
"application/json": {"schema": {"$ref": "#/components/schemas/ProblemDetails"}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
responses.setdefault(
|
|
|
|
|
"Problem422",
|
|
|
|
|
{
|
|
|
|
|
"description": "Validation Error",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/problem+json": {
|
|
|
|
|
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
|
|
|
|
|
},
|
|
|
|
|
# Some clients may still expect FastAPI's default error; keep schema available
|
|
|
|
|
"application/json": {
|
|
|
|
|
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
# Normalize v1 responses to reference reusable ProblemDetails where appropriate
|
|
|
|
|
paths = spec.get("paths", {})
|
|
|
|
|
for path, ops in paths.items():
|
|
|
|
|
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(ops, dict):
|
|
|
|
|
continue
|
|
|
|
|
for method, op in ops.items():
|
|
|
|
|
if not isinstance(op, dict):
|
|
|
|
|
continue
|
|
|
|
|
resp = op.get("responses")
|
|
|
|
|
if not isinstance(resp, dict):
|
|
|
|
|
continue
|
|
|
|
|
# Map 400/404 to reusable references; ensure 422 exists
|
|
|
|
|
if "400" in resp:
|
|
|
|
|
resp["400"] = {"$ref": "#/components/responses/Problem400"}
|
|
|
|
|
if "404" in resp:
|
|
|
|
|
resp["404"] = {"$ref": "#/components/responses/Problem404"}
|
|
|
|
|
# Only add 422 if not already present
|
|
|
|
|
if "422" not in resp:
|
|
|
|
|
resp["422"] = {"$ref": "#/components/responses/Problem422"}
|
|
|
|
|
return spec
|
|
|
|
|
|
|
|
|
|
app.openapi = custom_openapi # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_extend_openapi_with_problem_responses(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1.get(
|
|
|
|
|
"/recipes/parse",
|
|
|
|
|
response_model=None,
|
|
|
|
|
operation_id="parseRecipe",
|
|
|
|
|
tags=["recipes"],
|
|
|
|
|
summary="Parse a recipe from a URL",
|
|
|
|
|
responses={
|
|
|
|
|
400: {
|
|
|
|
|
"model": ProblemDetails,
|
|
|
|
|
"description": "Recipe not found",
|
|
|
|
|
"content": {"application/problem+json": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def parse_recipe_handler(
|
2025-10-18 05:44:36 +00:00
|
|
|
url: str, conn: aiosqlite.Connection = Depends(get_db), person=Depends(cookie_person), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> recipes.Recipe | JSONResponse:
|
2024-05-19 11:22:30 +00:00
|
|
|
parsed = await recipes.parse_recipe(conn, person, url)
|
2024-01-13 05:40:10 +00:00
|
|
|
if not parsed:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Recipe not found")
|
2024-01-13 05:40:10 +00:00
|
|
|
return parsed
|
2024-01-08 00:45:22 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/recipes/ingredients/parse",
|
|
|
|
|
operation_id="parseIngredients",
|
|
|
|
|
tags=["ingredients"],
|
|
|
|
|
summary="Parse raw ingredient lines",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def parse_ingredients(
|
|
|
|
|
lines: Annotated[List[str], Query(alias="ingredients", title="Array of ingredients to parse")],
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
) -> List[ingredients.Ingredient]:
|
|
|
|
|
|
2024-05-21 12:12:09 +00:00
|
|
|
had_links = False
|
|
|
|
|
result = []
|
|
|
|
|
for line in lines:
|
|
|
|
|
ingredient = await ingredients.parse_ingredient_from_link(conn, line)
|
|
|
|
|
if ingredient:
|
|
|
|
|
result.append(ingredient)
|
|
|
|
|
had_links = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
ingredient = ingredients.parse_ingredient_from_nlp(line)
|
|
|
|
|
if ingredient:
|
|
|
|
|
result.append(ingredient)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if had_links:
|
|
|
|
|
await conn.commit()
|
|
|
|
|
|
2024-01-17 08:36:54 +00:00
|
|
|
await ingredients.match_existing_products(conn, result)
|
|
|
|
|
return result
|
2024-01-08 00:38:17 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
class ProductUrl(ApiModel):
|
2024-01-13 01:54:04 +00:00
|
|
|
url: str
|
2025-10-18 03:26:42 +00:00
|
|
|
tags: List[str] = Field(default_factory=list)
|
|
|
|
|
|
2024-01-13 01:54:04 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.post(
|
|
|
|
|
"/products",
|
|
|
|
|
operation_id="createProduct",
|
|
|
|
|
tags=["products"],
|
|
|
|
|
summary="Create or fetch a product from a URL",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def create_product(
|
|
|
|
|
url: ProductUrl, conn: aiosqlite.Connection = Depends(get_db)
|
|
|
|
|
) -> Optional[products.Product]:
|
2024-01-13 07:44:48 +00:00
|
|
|
return await products.get_or_create(conn, url.url, url.tags)
|
2024-01-13 05:40:10 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
|
|
|
|
async def load_full_recipe(conn: aiosqlite.Connection, id: int) -> Optional[recipes.Recipe]:
|
2024-01-13 07:44:48 +00:00
|
|
|
r = await recipes.find_recipe_by_id(conn, id)
|
2024-01-13 06:59:35 +00:00
|
|
|
if not r:
|
2024-01-13 07:42:23 +00:00
|
|
|
return None
|
2024-01-13 06:59:35 +00:00
|
|
|
|
|
|
|
|
r.ingredients = []
|
2024-01-17 07:21:16 +00:00
|
|
|
async for ingredient in ingredients.find_ingredients_by_recipe_id(conn, id):
|
2024-01-13 06:59:35 +00:00
|
|
|
r.ingredients.append(ingredient)
|
2025-10-18 03:26:42 +00:00
|
|
|
|
|
|
|
|
if r.created_by_id is not None:
|
|
|
|
|
r.created_by = await persons.get_by_id(conn, r.created_by_id)
|
2024-04-25 04:57:39 +00:00
|
|
|
|
2024-01-13 06:59:35 +00:00
|
|
|
return r
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/recipes",
|
|
|
|
|
operation_id="listRecipes",
|
|
|
|
|
response_model=Page[recipes.Recipe],
|
|
|
|
|
tags=["recipes"],
|
|
|
|
|
summary="List recipes (paginated)",
|
|
|
|
|
responses={
|
|
|
|
|
200: {
|
|
|
|
|
"description": "A page of recipes",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/json": {
|
|
|
|
|
"example": {
|
|
|
|
|
"items": [
|
|
|
|
|
{
|
|
|
|
|
"id": 1,
|
|
|
|
|
"name": "Example Recipe",
|
|
|
|
|
"link": "https://example.com/recipes/1",
|
|
|
|
|
"serves": 4,
|
|
|
|
|
"imageUrls": [],
|
|
|
|
|
"ingredients": []
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"nextCursor": "2",
|
|
|
|
|
"prevCursor": "0",
|
|
|
|
|
"total": 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_recipes(
|
2025-10-18 05:44:36 +00:00
|
|
|
q: Optional[str] = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Optional case-insensitive name filter (matches recipe name with SQL LIKE).",
|
|
|
|
|
),
|
|
|
|
|
cursor: Optional[str] = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
|
|
|
|
),
|
|
|
|
|
limit: int = Query(
|
|
|
|
|
50,
|
|
|
|
|
ge=1,
|
|
|
|
|
le=200,
|
|
|
|
|
description="Maximum number of items to return (1-200).",
|
|
|
|
|
),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
request: Request = None,
|
|
|
|
|
) -> List[recipes.Recipe] | Page[recipes.Recipe]:
|
|
|
|
|
# v1: DB-backed pagination using limit+1 strategy
|
|
|
|
|
last_id = None
|
|
|
|
|
if cursor:
|
|
|
|
|
try:
|
|
|
|
|
last_id = int(cursor)
|
|
|
|
|
except ValueError:
|
|
|
|
|
last_id = None
|
|
|
|
|
|
|
|
|
|
fetch_limit = limit + 1
|
|
|
|
|
paged: List[recipes.Recipe] = []
|
2024-01-13 11:37:39 +00:00
|
|
|
if q:
|
2025-10-18 05:44:36 +00:00
|
|
|
async for r in recipes.find_recipes_by_name_paged(conn, q, last_id, fetch_limit):
|
|
|
|
|
paged.append(r)
|
2024-01-13 11:37:39 +00:00
|
|
|
else:
|
2025-10-18 05:44:36 +00:00
|
|
|
async for r in recipes.get_all_paged(conn, last_id, fetch_limit):
|
|
|
|
|
paged.append(r)
|
|
|
|
|
|
|
|
|
|
has_more = len(paged) > limit
|
|
|
|
|
items = paged[:limit]
|
|
|
|
|
# load ingredients for items
|
|
|
|
|
for r in items:
|
|
|
|
|
r.ingredients = []
|
|
|
|
|
async for ing in ingredients.find_ingredients_by_recipe_id(conn, r.id):
|
|
|
|
|
r.ingredients.append(ing)
|
|
|
|
|
next_cursor = str(items[-1].id) if has_more and items else None
|
|
|
|
|
# Compute prevCursor via DB helper
|
|
|
|
|
prev_cursor: Optional[str] = None
|
|
|
|
|
if items:
|
|
|
|
|
first_id = items[0].id
|
|
|
|
|
prev_cursor = await recipes.compute_prev_cursor(conn, first_id, limit, q)
|
|
|
|
|
total = await (recipes.count_by_name(conn, q) if q else recipes.count_all(conn))
|
|
|
|
|
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1.get(
|
|
|
|
|
"/recipes/{recipe_id}",
|
|
|
|
|
response_model=None,
|
|
|
|
|
operation_id="getRecipe",
|
|
|
|
|
tags=["recipes"],
|
|
|
|
|
summary="Get a single recipe",
|
|
|
|
|
responses={
|
|
|
|
|
404: {
|
|
|
|
|
"model": ProblemDetails,
|
|
|
|
|
"description": "Recipe not found",
|
|
|
|
|
"content": {"application/problem+json": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_recipe(
|
2025-10-18 05:44:36 +00:00
|
|
|
recipe_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> recipes.Recipe | JSONResponse:
|
2024-01-13 07:42:23 +00:00
|
|
|
r = await load_full_recipe(conn, recipe_id)
|
|
|
|
|
if not r:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Recipe not found")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-01-13 07:42:23 +00:00
|
|
|
return r
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.post(
|
|
|
|
|
"/recipes",
|
|
|
|
|
response_model=None,
|
|
|
|
|
operation_id="createRecipe",
|
|
|
|
|
tags=["recipes"],
|
|
|
|
|
summary="Create a new recipe (versioning semantics applied)",
|
|
|
|
|
responses={
|
|
|
|
|
400: {
|
|
|
|
|
"model": ProblemDetails,
|
|
|
|
|
"description": "Validation error",
|
|
|
|
|
"content": {"application/problem+json": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def create_recipe(
|
|
|
|
|
recipe: recipes.Recipe,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
user: persons.Person = Depends(cookie_person),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> recipes.Recipe | JSONResponse:
|
2024-05-20 10:09:57 +00:00
|
|
|
if not recipe.ingredients:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Recipe must have at least one ingredient")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-20 10:09:57 +00:00
|
|
|
if recipe.id >= 0:
|
|
|
|
|
await recipes.hide_recipe(conn, recipe.id, user)
|
|
|
|
|
recipe.based_on_recipe = recipe.id
|
|
|
|
|
recipe.id = 0
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-20 10:09:57 +00:00
|
|
|
recipe.created_by_id = user.id
|
|
|
|
|
await recipes.insert_recipe(conn, recipe)
|
|
|
|
|
for ingredient in recipe.ingredients:
|
|
|
|
|
ingredient.recipe_id = recipe.id
|
2025-07-28 23:23:09 +00:00
|
|
|
if ingredient.product:
|
|
|
|
|
ingredient.product_id = ingredient.product.id
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-01-17 08:36:54 +00:00
|
|
|
await ingredients.insert_ingredient(conn, ingredient)
|
2024-04-28 03:57:02 +00:00
|
|
|
|
2024-01-13 05:40:10 +00:00
|
|
|
await conn.commit()
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-20 10:09:57 +00:00
|
|
|
return recipe
|
2024-01-13 07:42:23 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.delete(
|
|
|
|
|
"/recipes/{recipe_id}",
|
|
|
|
|
response_model=None,
|
|
|
|
|
operation_id="deleteRecipe",
|
|
|
|
|
tags=["recipes"],
|
|
|
|
|
summary="Soft-delete (hide) a recipe",
|
|
|
|
|
responses={
|
|
|
|
|
404: {
|
|
|
|
|
"model": ProblemDetails,
|
|
|
|
|
"description": "Recipe not found",
|
|
|
|
|
"content": {"application/problem+json": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def delete_recipe(
|
|
|
|
|
recipe_id: int,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
user: persons.Person = Depends(cookie_person),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> recipes.Recipe | JSONResponse:
|
2024-05-02 12:25:11 +00:00
|
|
|
recipe = await recipes.find_recipe_by_id(conn, recipe_id)
|
|
|
|
|
if not recipe:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Recipe not found")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-02 12:25:11 +00:00
|
|
|
await recipes.hide_recipe(conn, recipe_id, user)
|
|
|
|
|
await conn.commit()
|
|
|
|
|
return recipe
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/meals/upcoming",
|
|
|
|
|
operation_id="getUpcomingMeals",
|
|
|
|
|
tags=["meals"],
|
|
|
|
|
summary="List upcoming meals in a date range",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
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]:
|
2024-01-13 07:42:23 +00:00
|
|
|
result = []
|
2024-05-25 02:33:41 +00:00
|
|
|
async for meal in meals.find_upcoming_meals_by_date_range(conn, date_from, to):
|
2024-01-17 11:43:16 +00:00
|
|
|
await meals.load_recipes(conn, meal)
|
|
|
|
|
await meals.load_extra_ingredients(conn, meal)
|
2024-04-30 11:21:59 +00:00
|
|
|
await meals.load_participants(conn, meal)
|
2024-01-13 07:42:23 +00:00
|
|
|
result.append(meal)
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_meal(
|
2025-10-18 05:44:36 +00:00
|
|
|
meal_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> meals.Meal | JSONResponse:
|
2024-01-17 10:39:48 +00:00
|
|
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
|
|
|
|
if not meal:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2024-01-17 10:39:48 +00:00
|
|
|
|
|
|
|
|
return meal
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-04 05:06:48 +00:00
|
|
|
def get_duplicates(items: List[meals.Person]) -> set[str]:
|
2025-10-18 03:26:42 +00:00
|
|
|
seen: set[int] = set()
|
|
|
|
|
duplicates: set[str] = set()
|
2024-05-04 05:06:48 +00:00
|
|
|
for item in items:
|
|
|
|
|
if item.id in seen:
|
|
|
|
|
duplicates.add(item.name)
|
|
|
|
|
seen.add(item.id)
|
|
|
|
|
return duplicates
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
def validate_meal(meal: meals.Meal, request: Optional[Request] = None) -> Optional[JSONResponse]:
|
2024-04-25 04:57:39 +00:00
|
|
|
if not meal.chefs:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Meal must have at least one chef")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-01-13 07:42:23 +00:00
|
|
|
if not meal.cleanup:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Meal must have at least one cleanup person")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-01-13 07:42:23 +00:00
|
|
|
if not meal.consumers:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Meal must have at least one consumer")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-01-17 07:21:16 +00:00
|
|
|
if len(meal.recipes) == 0 and len(meal.extra_ingredients) == 0:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Meal must have at least one recipe or ingredient")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-04 05:06:48 +00:00
|
|
|
duplicates = get_duplicates(meal.chefs)
|
|
|
|
|
if duplicates:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, f'Duplicate chef: {", ".join(duplicates)}')
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-04 05:06:48 +00:00
|
|
|
duplicates = get_duplicates(meal.cleanup)
|
|
|
|
|
if duplicates:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, f'Duplicate cleanup person: {", ".join(duplicates)}')
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-04 05:06:48 +00:00
|
|
|
duplicates = get_duplicates(meal.consumers)
|
|
|
|
|
if duplicates:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, f'Duplicate consumer: {", ".join(duplicates)}')
|
2024-09-28 04:54:54 +00:00
|
|
|
|
|
|
|
|
zero_servings = [r for r in meal.recipes if r.servings == 0]
|
|
|
|
|
if zero_servings:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Recipe servings must be greater than 0")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-04 05:06:48 +00:00
|
|
|
return None
|
2024-09-21 00:34:13 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def create_meal(
|
2025-10-18 05:44:36 +00:00
|
|
|
meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> meals.Meal | JSONResponse:
|
2025-10-18 05:44:36 +00:00
|
|
|
validation_response = validate_meal(meal, request)
|
2024-05-04 05:06:48 +00:00
|
|
|
if validation_response:
|
|
|
|
|
return validation_response
|
2024-01-17 07:21:16 +00:00
|
|
|
|
2024-01-13 07:42:23 +00:00
|
|
|
await meals.insert_meal(conn, meal)
|
|
|
|
|
await conn.commit()
|
2024-01-13 08:44:07 +00:00
|
|
|
return meal
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def update_meal(
|
2025-10-18 05:44:36 +00:00
|
|
|
meal_id: int, meal: meals.Meal, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> meals.Meal | JSONResponse:
|
2024-05-02 11:20:52 +00:00
|
|
|
if meal.id != meal_id:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-02 11:20:52 +00:00
|
|
|
existing = await meals.find_meal_by_id(conn, meal_id)
|
|
|
|
|
if not existing:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
validation_response = validate_meal(meal, request)
|
2024-05-04 05:06:48 +00:00
|
|
|
if validation_response:
|
|
|
|
|
return validation_response
|
2024-05-02 11:20:52 +00:00
|
|
|
|
|
|
|
|
await meals.update_meal(conn, meal)
|
|
|
|
|
await conn.commit()
|
|
|
|
|
|
|
|
|
|
return await get_meal(meal_id, conn)
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
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),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> meals.Meal | JSONResponse:
|
2024-10-14 05:59:05 +00:00
|
|
|
if consumed_date and not consumed_date.tzinfo:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 400, "Consumed date must include timezone")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-27 11:55:47 +00:00
|
|
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
2024-05-25 02:33:41 +00:00
|
|
|
if not meal:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2024-05-25 02:33:41 +00:00
|
|
|
|
2024-10-14 05:59:05 +00:00
|
|
|
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
|
2025-07-28 23:23:09 +00:00
|
|
|
await shopping.remove_request(conn, person, meal=meal)
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-05-25 02:33:41 +00:00
|
|
|
await conn.commit()
|
|
|
|
|
return meal
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def delete_meal(
|
|
|
|
|
meal_id: int,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
person: persons.Person = Depends(cookie_person),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> meals.Meal | JSONResponse:
|
2024-01-17 11:43:16 +00:00
|
|
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
|
|
|
|
if not meal:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2024-01-17 11:43:16 +00:00
|
|
|
|
2025-07-28 23:23:09 +00:00
|
|
|
await shopping.remove_request(conn, person, meal=meal)
|
2024-01-17 11:43:16 +00:00
|
|
|
await meals.delete_meal(conn, meal.id)
|
2024-10-13 08:31:37 +00:00
|
|
|
|
2024-01-17 11:43:16 +00:00
|
|
|
await conn.commit()
|
|
|
|
|
return meal
|
2024-01-17 10:39:48 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
class CurrentShoppingList(ApiModel):
|
2025-07-28 23:23:09 +00:00
|
|
|
outstanding_items: List[shopping.ShoppingListItem]
|
|
|
|
|
requested_meals: List[shopping.ShoppingListItem]
|
2025-10-18 03:26:42 +00:00
|
|
|
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)
|
2025-07-28 23:23:09 +00:00
|
|
|
|
2024-05-18 07:05:01 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/shopping/current",
|
|
|
|
|
response_model=CurrentShoppingList,
|
|
|
|
|
operation_id="getCurrentShoppingList",
|
|
|
|
|
tags=["shopping"],
|
|
|
|
|
summary="Get the current aggregated shopping list",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
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)
|
2025-07-28 23:23:09 +00:00
|
|
|
other_shopping_list_ids = {item.list_id for item in purchased_requests}
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
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
|
2025-07-28 23:23:09 +00:00
|
|
|
|
2025-07-30 12:05:29 +00:00
|
|
|
# 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:
|
2025-10-18 03:26:42 +00:00
|
|
|
await shopping.to_lookups(
|
|
|
|
|
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
|
|
|
|
)
|
2025-07-28 23:23:09 +00:00
|
|
|
|
|
|
|
|
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,
|
2025-10-18 03:26:42 +00:00
|
|
|
recipes_lookup=recipes_lookup,
|
2025-07-28 23:23:09 +00:00
|
|
|
)
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
class PurchasedShoppingList(ApiModel):
|
2025-07-28 23:23:09 +00:00
|
|
|
list: shopping.ShoppingList
|
2025-10-18 03:26:42 +00:00
|
|
|
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)
|
|
|
|
|
|
2024-05-19 03:43:06 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_shopping_list(
|
2025-10-18 05:44:36 +00:00
|
|
|
list_id: int, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> PurchasedShoppingList | JSONResponse:
|
2025-07-28 23:23:09 +00:00
|
|
|
shopping_list = await shopping.load_shopping_list(conn, list_id)
|
2025-07-30 12:05:29 +00:00
|
|
|
if not shopping_list:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Shopping list not found")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2024-05-19 03:43:06 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.post(
|
|
|
|
|
"/shopping/",
|
|
|
|
|
operation_id="purchaseIngredients",
|
|
|
|
|
tags=["shopping"],
|
|
|
|
|
summary="Purchase ingredients for a shopping list",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
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
|
|
|
|
|
)
|
2025-07-28 23:23:09 +00:00
|
|
|
|
|
|
|
|
await shopping.purchase(conn, shopping_list)
|
2024-05-19 03:43:06 +00:00
|
|
|
await conn.commit()
|
2025-07-28 23:23:09 +00:00
|
|
|
|
|
|
|
|
result = PurchasedShoppingList(list=shopping_list)
|
2025-10-18 03:26:42 +00:00
|
|
|
await shopping.to_lookups(
|
|
|
|
|
conn,
|
|
|
|
|
shopping_list.items,
|
|
|
|
|
result.meals_lookup,
|
|
|
|
|
result.recipes_lookup,
|
|
|
|
|
result.ingredients_lookup,
|
|
|
|
|
)
|
2025-07-28 23:23:09 +00:00
|
|
|
return result
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/shopping/current/me/ingredients",
|
|
|
|
|
operation_id="getMyShoppingList",
|
|
|
|
|
tags=["shopping"],
|
|
|
|
|
summary="Get my outstanding ingredient requests",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_my_shopping_list(
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db), person: persons.Person = Depends(cookie_person)
|
|
|
|
|
) -> List[ingredients.Ingredient]:
|
2025-07-30 12:05:29 +00:00
|
|
|
return await shopping.get_persons_requests(conn, person.id)
|
2024-05-18 07:05:01 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.post(
|
|
|
|
|
"/shopping/current/me/ingredients",
|
|
|
|
|
operation_id="syncMyShoppingList",
|
|
|
|
|
tags=["shopping"],
|
|
|
|
|
summary="Sync my outstanding ingredient requests",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
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]:
|
2025-07-28 23:23:09 +00:00
|
|
|
def isMatching(a: ingredients.Ingredient, b: ingredients.Ingredient) -> bool:
|
|
|
|
|
return a.id == b.id or a.line == b.line
|
|
|
|
|
|
2025-07-30 12:05:29 +00:00
|
|
|
my_shopping_list = await shopping.get_persons_requests(conn, person.id)
|
2025-07-28 23:23:09 +00:00
|
|
|
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)
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-07-28 23:23:09 +00:00
|
|
|
for r in to_add:
|
|
|
|
|
if r.id < 0:
|
|
|
|
|
await ingredients.insert_ingredient(conn, r)
|
|
|
|
|
await shopping.request(conn, person, ingredient=r)
|
|
|
|
|
|
2024-05-18 07:05:01 +00:00
|
|
|
await conn.commit()
|
2025-07-28 23:23:09 +00:00
|
|
|
return await get_my_shopping_list(conn, person)
|
2024-05-18 07:05:01 +00:00
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
class MealIdWrapper(ApiModel):
|
2024-05-18 07:05:01 +00:00
|
|
|
meal_id: int
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def request_meal(
|
|
|
|
|
r: MealIdWrapper,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
person: persons.Person = Depends(cookie_person),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> shopping.ShoppingListItem | JSONResponse:
|
2024-05-18 07:05:01 +00:00
|
|
|
meal = await meals.find_meal_by_id(conn, r.meal_id)
|
|
|
|
|
if not meal:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2025-07-28 23:23:09 +00:00
|
|
|
|
|
|
|
|
response = await shopping.request(conn, person, meal=meal)
|
2024-05-18 07:05:01 +00:00
|
|
|
await conn.commit()
|
|
|
|
|
return response
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def unrequest_meal(
|
|
|
|
|
meal_id: int,
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
person: persons.Person = Depends(cookie_person),
|
2025-10-18 05:44:36 +00:00
|
|
|
request: Request = None,
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> dict | JSONResponse:
|
2024-05-18 07:05:01 +00:00
|
|
|
meal = await meals.find_meal_by_id(conn, meal_id)
|
|
|
|
|
if not meal:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Meal not found")
|
2024-05-18 07:05:01 +00:00
|
|
|
|
2025-07-28 23:23:09 +00:00
|
|
|
await shopping.remove_request(conn, person, meal=meal)
|
2024-05-18 07:05:01 +00:00
|
|
|
await conn.commit()
|
|
|
|
|
return {}
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.get(
|
|
|
|
|
"/persons",
|
|
|
|
|
operation_id="listPersons",
|
|
|
|
|
response_model=Page[persons.Person],
|
|
|
|
|
tags=["persons"],
|
|
|
|
|
summary="List persons (paginated)",
|
|
|
|
|
responses={
|
|
|
|
|
200: {
|
|
|
|
|
"description": "A page of persons",
|
|
|
|
|
"content": {
|
|
|
|
|
"application/json": {
|
|
|
|
|
"example": {
|
|
|
|
|
"items": [
|
|
|
|
|
{
|
|
|
|
|
"id": 1,
|
|
|
|
|
"name": "Ada Lovelace"
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"nextCursor": "2",
|
|
|
|
|
"prevCursor": "0",
|
|
|
|
|
"total": 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def get_persons(
|
2025-10-18 05:44:36 +00:00
|
|
|
q: Optional[str] = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Optional case-insensitive name filter (applied after page read; may be pushed to SQL in the future).",
|
|
|
|
|
),
|
|
|
|
|
cursor: Optional[str] = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Opaque cursor for pagination. Pass the value returned in nextCursor to fetch the next page.",
|
|
|
|
|
),
|
|
|
|
|
limit: int = Query(
|
|
|
|
|
50,
|
|
|
|
|
ge=1,
|
|
|
|
|
le=200,
|
|
|
|
|
description="Maximum number of items to return (1-200).",
|
|
|
|
|
),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
request: Request = None,
|
|
|
|
|
) -> List[persons.Person] | Page[persons.Person]:
|
|
|
|
|
# v1: DB-backed pagination
|
|
|
|
|
last_id = None
|
|
|
|
|
if cursor:
|
|
|
|
|
try:
|
|
|
|
|
last_id = int(cursor)
|
|
|
|
|
except ValueError:
|
|
|
|
|
last_id = None
|
|
|
|
|
|
|
|
|
|
fetch_limit = limit + 1
|
|
|
|
|
paged: List[persons.Person] = []
|
|
|
|
|
if q:
|
|
|
|
|
async for p in persons.search_by_name_paged(conn, q, last_id, fetch_limit):
|
|
|
|
|
paged.append(p)
|
|
|
|
|
else:
|
|
|
|
|
async for p in persons.get_all_paged(conn, last_id, fetch_limit):
|
|
|
|
|
paged.append(p)
|
|
|
|
|
|
|
|
|
|
has_more = len(paged) > limit
|
|
|
|
|
items = paged[:limit]
|
|
|
|
|
next_cursor = str(items[-1].id) if has_more and items else None
|
|
|
|
|
# Compute prevCursor via DB helper
|
|
|
|
|
prev_cursor: Optional[str] = None
|
|
|
|
|
if items:
|
|
|
|
|
first_id = items[0].id
|
|
|
|
|
prev_cursor = await persons.compute_prev_cursor(conn, first_id, limit, q)
|
|
|
|
|
total = await (persons.count_by_name(conn, q) if q else persons.count_all(conn))
|
|
|
|
|
return Page(items=items, nextCursor=next_cursor, prevCursor=prev_cursor, total=total)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@api_v1.post(
|
|
|
|
|
"/persons",
|
|
|
|
|
operation_id="createPerson",
|
|
|
|
|
tags=["persons"],
|
|
|
|
|
summary="Create a person",
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def create_person(
|
|
|
|
|
person: persons.Person, conn: aiosqlite.Connection = Depends(get_db)
|
|
|
|
|
) -> persons.Person:
|
2024-04-28 03:37:01 +00:00
|
|
|
await persons.insert_person(conn, person)
|
2024-01-13 08:44:07 +00:00
|
|
|
await conn.commit()
|
2024-04-25 02:03:30 +00:00
|
|
|
return person
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
class LoginBody(ApiModel):
|
2024-04-25 02:03:30 +00:00
|
|
|
username: str
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@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": {}},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-18 03:26:42 +00:00
|
|
|
async def login(
|
2025-10-18 05:44:36 +00:00
|
|
|
data: LoginBody, conn: aiosqlite.Connection = Depends(get_db), request: Request = None
|
2025-10-18 03:26:42 +00:00
|
|
|
) -> persons.Person | JSONResponse:
|
2024-04-25 02:03:30 +00:00
|
|
|
person = await persons.get_by_name(conn, data.username)
|
|
|
|
|
if not person:
|
2025-10-18 05:44:36 +00:00
|
|
|
return error_response(request, 404, "Person not found")
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-04-25 02:03:30 +00:00
|
|
|
response = JSONResponse(content=jsonable_encoder(person))
|
2025-10-18 03:26:42 +00:00
|
|
|
response.set_cookie(key="user_id", value=str(person.id))
|
2024-04-28 02:15:19 +00:00
|
|
|
return response
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
@api_v1.post(
|
|
|
|
|
"/auth/refresh",
|
|
|
|
|
operation_id="refresh",
|
|
|
|
|
tags=["auth"],
|
|
|
|
|
summary="Refresh current user from cookie",
|
|
|
|
|
)
|
2024-04-28 02:15:19 +00:00
|
|
|
async def current_user(user: persons.Person = Depends(cookie_person)) -> persons.Person:
|
2024-09-21 00:34:13 +00:00
|
|
|
return user
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2025-10-18 05:44:36 +00:00
|
|
|
# RFC7807 Problem Details handlers
|
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
|
|
|
async def http_exc_handler(request: Request, exc: StarletteHTTPException):
|
|
|
|
|
body = ProblemDetails(
|
|
|
|
|
title=str(exc.detail) if exc.detail else "HTTP Error",
|
|
|
|
|
status=exc.status_code,
|
|
|
|
|
type=f"https://httpstatuses.com/{exc.status_code}",
|
|
|
|
|
instance=str(request.url),
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=body.model_dump(by_alias=True),
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(ValidationError)
|
|
|
|
|
async def validation_exc_handler(request: Request, exc: ValidationError):
|
|
|
|
|
errors: Dict[str, Any] = {}
|
|
|
|
|
for e in exc.errors():
|
|
|
|
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
|
|
|
|
errors.setdefault(loc, []).append(e.get("msg"))
|
|
|
|
|
body = ProblemDetails(
|
|
|
|
|
title="Validation Error",
|
|
|
|
|
status=422,
|
|
|
|
|
type="https://datatracker.ietf.org/doc/html/rfc7807",
|
|
|
|
|
instance=str(request.url),
|
|
|
|
|
errors=errors,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
|
|
|
async def request_validation_exc_handler(request: Request, exc: RequestValidationError):
|
|
|
|
|
errors: Dict[str, Any] = {}
|
|
|
|
|
for e in exc.errors():
|
|
|
|
|
loc = ".".join([str(p) for p in e.get("loc", [])])
|
|
|
|
|
errors.setdefault(loc, []).append(e.get("msg"))
|
|
|
|
|
body = ProblemDetails(
|
|
|
|
|
title="Validation Error",
|
|
|
|
|
status=422,
|
|
|
|
|
type="https://datatracker.ietf.org/doc/html/rfc7807",
|
|
|
|
|
instance=str(request.url),
|
|
|
|
|
errors=errors,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=body.model_dump(by_alias=True), status_code=422, media_type="application/problem+json"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Mount versioned API router
|
|
|
|
|
app.include_router(api_v1, prefix="/api/v1", tags=["v1"])
|
|
|
|
|
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
if os.environ.get("DOOF_PROD", False):
|
2024-09-21 00:34:13 +00:00
|
|
|
from fastapi.staticfiles import StaticFiles
|
2025-10-18 03:26:42 +00:00
|
|
|
|
2024-09-21 00:34:13 +00:00
|
|
|
app.mount("/", StaticFiles(directory="./front-dist", html=True), name="front-dist")
|
|
|
|
|
else:
|
|
|
|
|
# Proxy the request to the frontend development server
|
2025-10-18 03:26:42 +00:00
|
|
|
import httpx
|
|
|
|
|
from starlette.background import BackgroundTask
|
2024-09-21 00:34:13 +00:00
|
|
|
from starlette.requests import Request
|
|
|
|
|
from starlette.responses import StreamingResponse
|
|
|
|
|
|
|
|
|
|
client = httpx.AsyncClient(base_url="http://localhost:8080/")
|
|
|
|
|
|
|
|
|
|
async def _reverse_proxy(request: Request):
|
2025-10-18 03:26:42 +00:00
|
|
|
url = httpx.URL(path=request.url.path, query=request.url.query.encode("utf-8"))
|
|
|
|
|
rp_req = client.build_request(
|
|
|
|
|
request.method, url, headers=request.headers.raw, content=request.stream()
|
|
|
|
|
)
|
2024-09-21 00:34:13 +00:00
|
|
|
rp_resp = await client.send(rp_req, stream=True)
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
rp_resp.aiter_raw(),
|
|
|
|
|
status_code=rp_resp.status_code,
|
|
|
|
|
headers=rp_resp.headers,
|
|
|
|
|
background=BackgroundTask(rp_resp.aclose),
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-18 03:26:42 +00:00
|
|
|
app.add_route("/{path:path}", _reverse_proxy, ["GET", "POST"])
|