feat(meals): replace Person with MemberRef (displayName) in v2 Meal DTOs; add Argon2 password hashing

This commit is contained in:
jableader 2025-11-01 16:58:34 +11:00
parent 87e3dba6f8
commit 8fd780ee17
6 changed files with 413 additions and 251 deletions

View file

@ -15,6 +15,14 @@ from common import ApiModel
from security import JwtConfig, create_jwt from security import JwtConfig, create_jwt
from settings import settings from settings import settings
from users import repository as users_db from users import repository as users_db
# Prefer Argon2 for new passwords; keep PBKDF2 verify for backward compatibility
try:
from argon2 import PasswordHasher
_ph: PasswordHasher | None = PasswordHasher()
except Exception: # pragma: no cover - optional dependency in some environments
_ph = None
from users.models import User from users.models import User
router = APIRouter(prefix="/auth", tags=["auth-v2"]) router = APIRouter(prefix="/auth", tags=["auth-v2"])
@ -38,17 +46,32 @@ class TokenResponse(ApiModel):
PBKDF2_ALG = "pbkdf2_sha256" PBKDF2_ALG = "pbkdf2_sha256"
PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed PBKDF2_ITER = 390000 # kept for verifying older hashes
SALT_BYTES = 16 SALT_BYTES = 16
def _hash_pw(pw: str) -> str: def _hash_pw(pw: str) -> str:
"""Hash a password.
Uses Argon2 when available; falls back to PBKDF2 for environments without argon2-cffi.
"""
if _ph is not None:
return _ph.hash(pw)
# Fallback
salt = os.urandom(SALT_BYTES) salt = os.urandom(SALT_BYTES)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER) dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, PBKDF2_ITER)
return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}" return f"{PBKDF2_ALG}${PBKDF2_ITER}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
def _verify_pw(pw: str, stored: str) -> bool: def _verify_pw(pw: str, stored: str) -> bool:
"""Verify password against either Argon2 or PBKDF2 stored hashes."""
# Try Argon2 first
if _ph is not None and stored.startswith("$argon2"):
try:
return _ph.verify(stored, pw)
except Exception:
return False
# PBKDF2 fallback
try: try:
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3) alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3)
if alg != PBKDF2_ALG: if alg != PBKDF2_ALG:
@ -57,7 +80,6 @@ def _verify_pw(pw: str, stored: str) -> bool:
salt = base64.b64decode(salt_b64) salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64) expected = base64.b64decode(hash_b64)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters) dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters)
# constant-time compare
return hmac.compare_digest(dk, expected) return hmac.compare_digest(dk, expected)
except Exception: except Exception:
return False return False

View file

@ -7,7 +7,9 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
import meals import meals
import persons
import shopping import shopping
import ingredients
from common import ProblemDetails, ApiModel from common import ProblemDetails, ApiModel
from api.deps import error_response from api.deps import error_response
from api.deps import get_db, get_household_from_slug from api.deps import get_db, get_household_from_slug
@ -15,6 +17,41 @@ from api.deps import get_db, get_household_from_slug
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"])
class MemberRef(ApiModel):
id: int
# Align outward schema to users/household members; use displayName
display_name: str
class MealRecipeIn(ApiModel):
meal_id: int
recipe_id: int
servings: float
class MealIn(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[MealRecipeIn] = []
extra_ingredients: List[ingredients.Ingredient] = []
class MealOut(ApiModel):
id: int = -1
suggested_date: datetime.datetime
consumed_date: Optional[datetime.datetime] = None
chefs: List[MemberRef]
cleanup: List[MemberRef]
consumers: List[MemberRef]
recipes: List[meals.MealRecipe]
extra_ingredients: List[ingredients.Ingredient]
purchase_date: Optional[datetime.datetime] = None
class MarkConsumedBody(ApiModel): class MarkConsumedBody(ApiModel):
consumed_date: Optional[datetime.datetime] = None consumed_date: Optional[datetime.datetime] = None
@ -29,7 +66,7 @@ async def get_upcoming_meals_scoped(
date_from: datetime.datetime = Query(..., alias="from"), date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...), to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]: ) -> List[MealOut]:
hid = household["id"] hid = household["id"]
# Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped # Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped
try: try:
@ -57,7 +94,7 @@ async def get_upcoming_meals_scoped(
result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)})) result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)}))
if not result: if not result:
return result return []
# Load relateds similar to v1 # Load relateds similar to v1
await meals.bulk_load_participants(conn, result) await meals.bulk_load_participants(conn, result)
@ -65,14 +102,33 @@ async def get_upcoming_meals_scoped(
await meals.load_recipes(conn, meal) await meals.load_recipes(conn, meal)
await meals.load_extra_ingredients(conn, meal) await meals.load_extra_ingredients(conn, meal)
return result # Map domain Meal -> outward MealOut
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
out: List[MealOut] = []
for m in result:
out.append(
MealOut(
id=m.id,
suggested_date=m.suggested_date,
consumed_date=m.consumed_date,
chefs=[_to_member(p) for p in m.chefs],
cleanup=[_to_member(p) for p in m.cleanup],
consumers=[_to_member(p) for p in m.consumers],
recipes=m.recipes,
extra_ingredients=m.extra_ingredients,
purchase_date=m.purchase_date,
)
)
return out
@router.get( @router.get(
"/{meal_id}", "/{meal_id}",
operation_id="getMealV2", operation_id="getMealV2",
summary="Get a meal by id (scoped)", summary="Get a meal by id (scoped)",
response_model=meals.Meal, response_model=MealOut,
responses={404: {"model": ProblemDetails}}, responses={404: {"model": ProblemDetails}},
) )
async def get_meal_scoped( async def get_meal_scoped(
@ -80,19 +136,33 @@ async def get_meal_scoped(
request: Request, request: Request,
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response: ) -> MealOut | Response:
hid = household["id"] hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal: if not meal:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
return meal
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post( @router.post(
"/{meal_id}/consumed", "/{meal_id}/consumed",
operation_id="markMealConsumedV2", operation_id="markMealConsumedV2",
summary="Mark a meal as consumed (scoped)", summary="Mark a meal as consumed (scoped)",
response_model=meals.Meal, response_model=MealOut,
responses={ responses={
400: {"model": ProblemDetails}, 400: {"model": ProblemDetails},
404: {"model": ProblemDetails}, 404: {"model": ProblemDetails},
@ -104,7 +174,7 @@ async def mark_meal_consumed_scoped(
body: Optional[MarkConsumedBody] = None, body: Optional[MarkConsumedBody] = None,
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response: ) -> MealOut | Response:
hid = household["id"] hid = household["id"]
consumed_date: Optional[datetime.datetime] = None consumed_date: Optional[datetime.datetime] = None
if body is not None: if body is not None:
@ -121,66 +191,143 @@ async def mark_meal_consumed_scoped(
# Clear any outstanding meal request entries for this meal # Clear any outstanding meal request entries for this meal
await shopping.remove_request(conn, person=None, meal=meal) await shopping.remove_request(conn, person=None, meal=meal)
return meal def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)
@router.post( @router.post(
"", "",
operation_id="createMealV2", operation_id="createMealV2",
summary="Create a new meal (scoped)", summary="Create a new meal (scoped)",
response_model=meals.Meal, response_model=MealOut,
responses={400: {"model": ProblemDetails}}, responses={400: {"model": ProblemDetails}},
) )
async def create_meal_scoped( async def create_meal_scoped(
meal: meals.Meal, meal: MealIn,
response: Response, response: Response,
request: Request, request: Request,
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response: ) -> MealOut | Response:
# Validate using existing service logic # Validate using existing service logic
msg = meals.validate_meal(meal) # Map MealIn -> domain Meal
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg: if msg:
return error_response(request, 400, msg) return error_response(request, 400, msg)
hid = household["id"] hid = household["id"]
await meals.insert_meal_scoped(conn, meal, hid) await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{meal.id}" response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
return meal
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=domain_meal.id,
suggested_date=domain_meal.suggested_date,
consumed_date=domain_meal.consumed_date,
chefs=[_to_member(p) for p in domain_meal.chefs],
cleanup=[_to_member(p) for p in domain_meal.cleanup],
consumers=[_to_member(p) for p in domain_meal.consumers],
recipes=domain_meal.recipes,
extra_ingredients=domain_meal.extra_ingredients,
purchase_date=domain_meal.purchase_date,
)
@router.put( @router.put(
"/{meal_id}", "/{meal_id}",
operation_id="updateMealV2", operation_id="updateMealV2",
summary="Update an existing meal (scoped)", summary="Update an existing meal (scoped)",
response_model=meals.Meal, response_model=MealOut,
responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}}, responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}},
) )
async def update_meal_scoped( async def update_meal_scoped(
meal_id: int, meal_id: int,
meal: meals.Meal, meal: MealIn,
request: Request, request: Request,
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response: ) -> MealOut | Response:
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")
hid = household["id"] hid = household["id"]
existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid) existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not existing: if not existing:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
msg = meals.validate_meal(meal)
def _from_member(m: MemberRef) -> persons.Person:
return persons.Person(id=m.id, name=m.display_name)
domain_meal = meals.Meal(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_from_member(p) for p in meal.chefs],
cleanup=[_from_member(p) for p in meal.cleanup],
consumers=[_from_member(p) for p in meal.consumers],
recipes=[
meals.MealRecipe(meal_id=r.meal_id, recipe_id=r.recipe_id, servings=r.servings)
for r in meal.recipes
],
extra_ingredients=list(meal.extra_ingredients),
)
msg = meals.validate_meal(domain_meal)
if msg: if msg:
return error_response(request, 400, msg) return error_response(request, 400, msg)
await meals.update_meal(conn, meal) await meals.update_meal(conn, domain_meal)
# Return updated state # Return updated state
return await get_meal_scoped(meal_id, request, household, conn) updated = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
assert updated is not None
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=updated.id,
suggested_date=updated.suggested_date,
consumed_date=updated.consumed_date,
chefs=[_to_member(p) for p in updated.chefs],
cleanup=[_to_member(p) for p in updated.cleanup],
consumers=[_to_member(p) for p in updated.consumers],
recipes=updated.recipes,
extra_ingredients=updated.extra_ingredients,
purchase_date=updated.purchase_date,
)
@router.delete( @router.delete(
"/{meal_id}", "/{meal_id}",
operation_id="deleteMealV2", operation_id="deleteMealV2",
summary="Delete a meal (scoped)", summary="Delete a meal (scoped)",
response_model=meals.Meal, response_model=MealOut,
responses={404: {"model": ProblemDetails}}, responses={404: {"model": ProblemDetails}},
) )
async def delete_meal_scoped( async def delete_meal_scoped(
@ -188,7 +335,7 @@ async def delete_meal_scoped(
request: Request, request: Request,
household=Depends(get_household_from_slug), household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db), conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response: ) -> MealOut | Response:
hid = household["id"] hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal: if not meal:
@ -202,4 +349,18 @@ async def delete_meal_scoped(
# Fallback: remove regardless of household (legacy cleanup) # Fallback: remove regardless of household (legacy cleanup)
await shopping.remove_request(conn, person=None, meal=meal) await shopping.remove_request(conn, person=None, meal=meal)
await meals.delete_meal(conn, meal.id) await meals.delete_meal(conn, meal.id)
return meal
def _to_member(p: persons.Person) -> MemberRef:
return MemberRef(id=p.id, display_name=p.name)
return MealOut(
id=meal.id,
suggested_date=meal.suggested_date,
consumed_date=meal.consumed_date,
chefs=[_to_member(p) for p in meal.chefs],
cleanup=[_to_member(p) for p in meal.cleanup],
consumers=[_to_member(p) for p in meal.consumers],
recipes=meal.recipes,
extra_ingredients=meal.extra_ingredients,
purchase_date=meal.purchase_date,
)

View file

@ -696,7 +696,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
}, },
"title": "Response Getupcomingmealsv2" "title": "Response Getupcomingmealsv2"
} }
@ -758,7 +758,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
} }
} }
} }
@ -818,7 +818,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Input" "$ref": "#/components/schemas/MealIn"
} }
} }
} }
@ -829,7 +829,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
} }
} }
} }
@ -893,7 +893,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
} }
} }
} }
@ -973,7 +973,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
} }
} }
} }
@ -1029,7 +1029,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Input" "$ref": "#/components/schemas/MealIn"
} }
} }
} }
@ -1040,7 +1040,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/MealOut"
} }
} }
} }
@ -1444,7 +1444,7 @@
}, },
"mealsLookup": { "mealsLookup": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/Meal"
}, },
"type": "object", "type": "object",
"title": "Mealslookup" "title": "Mealslookup"
@ -1458,7 +1458,7 @@
}, },
"recipesLookup": { "recipesLookup": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/Recipe-Output" "$ref": "#/components/schemas/Recipe"
}, },
"type": "object", "type": "object",
"title": "Recipeslookup" "title": "Recipeslookup"
@ -1799,7 +1799,7 @@
"type": "object", "type": "object",
"title": "MarkConsumedBody" "title": "MarkConsumedBody"
}, },
"Meal-Input": { "Meal": {
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",
@ -1846,85 +1846,7 @@
}, },
"recipes": { "recipes": {
"items": { "items": {
"$ref": "#/components/schemas/MealRecipe-Input" "$ref": "#/components/schemas/MealRecipe"
},
"type": "array",
"title": "Recipes"
},
"extraIngredients": {
"items": {
"$ref": "#/components/schemas/Ingredient"
},
"type": "array",
"title": "Extraingredients"
},
"purchaseDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Purchasedate"
}
},
"type": "object",
"required": [
"suggestedDate"
],
"title": "Meal"
},
"Meal-Output": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"suggestedDate": {
"type": "string",
"format": "date-time",
"title": "Suggesteddate"
},
"consumedDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Consumeddate"
},
"chefs": {
"items": {
"$ref": "#/components/schemas/Person"
},
"type": "array",
"title": "Chefs"
},
"cleanup": {
"items": {
"$ref": "#/components/schemas/Person"
},
"type": "array",
"title": "Cleanup"
},
"consumers": {
"items": {
"$ref": "#/components/schemas/Person"
},
"type": "array",
"title": "Consumers"
},
"recipes": {
"items": {
"$ref": "#/components/schemas/MealRecipe-Output"
}, },
"type": "array", "type": "array",
"title": "Recipes" "title": "Recipes"
@ -1968,7 +1890,161 @@
], ],
"title": "MealIdWrapper" "title": "MealIdWrapper"
}, },
"MealRecipe-Input": { "MealIn": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"suggestedDate": {
"type": "string",
"format": "date-time",
"title": "Suggesteddate"
},
"consumedDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Consumeddate"
},
"chefs": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Chefs"
},
"cleanup": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Cleanup"
},
"consumers": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Consumers"
},
"recipes": {
"items": {
"$ref": "#/components/schemas/MealRecipeIn"
},
"type": "array",
"title": "Recipes",
"default": []
},
"extraIngredients": {
"items": {
"$ref": "#/components/schemas/Ingredient"
},
"type": "array",
"title": "Extraingredients",
"default": []
}
},
"type": "object",
"required": [
"suggestedDate",
"chefs",
"cleanup",
"consumers"
],
"title": "MealIn"
},
"MealOut": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"suggestedDate": {
"type": "string",
"format": "date-time",
"title": "Suggesteddate"
},
"consumedDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Consumeddate"
},
"chefs": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Chefs"
},
"cleanup": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Cleanup"
},
"consumers": {
"items": {
"$ref": "#/components/schemas/MemberRef"
},
"type": "array",
"title": "Consumers"
},
"recipes": {
"items": {
"$ref": "#/components/schemas/MealRecipe"
},
"type": "array",
"title": "Recipes"
},
"extraIngredients": {
"items": {
"$ref": "#/components/schemas/Ingredient"
},
"type": "array",
"title": "Extraingredients"
},
"purchaseDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Purchasedate"
}
},
"type": "object",
"required": [
"suggestedDate",
"chefs",
"cleanup",
"consumers",
"recipes",
"extraIngredients"
],
"title": "MealOut"
},
"MealRecipe": {
"properties": { "properties": {
"mealId": { "mealId": {
"type": "integer", "type": "integer",
@ -1985,7 +2061,7 @@
"recipe": { "recipe": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/Recipe-Input" "$ref": "#/components/schemas/Recipe"
}, },
{ {
"type": "null" "type": "null"
@ -2001,7 +2077,7 @@
], ],
"title": "MealRecipe" "title": "MealRecipe"
}, },
"MealRecipe-Output": { "MealRecipeIn": {
"properties": { "properties": {
"mealId": { "mealId": {
"type": "integer", "type": "integer",
@ -2014,16 +2090,6 @@
"servings": { "servings": {
"type": "number", "type": "number",
"title": "Servings" "title": "Servings"
},
"recipe": {
"anyOf": [
{
"$ref": "#/components/schemas/Recipe-Output"
},
{
"type": "null"
}
]
} }
}, },
"type": "object", "type": "object",
@ -2032,7 +2098,25 @@
"recipeId", "recipeId",
"servings" "servings"
], ],
"title": "MealRecipe" "title": "MealRecipeIn"
},
"MemberRef": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"displayName": {
"type": "string",
"title": "Displayname"
}
},
"type": "object",
"required": [
"id",
"displayName"
],
"title": "MemberRef"
}, },
"Ok": { "Ok": {
"properties": { "properties": {
@ -2244,7 +2328,7 @@
}, },
"mealsLookup": { "mealsLookup": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/Meal-Output" "$ref": "#/components/schemas/Meal"
}, },
"type": "object", "type": "object",
"title": "Mealslookup" "title": "Mealslookup"
@ -2258,7 +2342,7 @@
}, },
"recipesLookup": { "recipesLookup": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/Recipe-Output" "$ref": "#/components/schemas/Recipe"
}, },
"type": "object", "type": "object",
"title": "Recipeslookup" "title": "Recipeslookup"
@ -2273,113 +2357,7 @@
], ],
"title": "PurchasedShoppingList" "title": "PurchasedShoppingList"
}, },
"Recipe-Input": { "Recipe": {
"properties": {
"id": {
"type": "integer",
"title": "Id",
"default": -1
},
"name": {
"type": "string",
"title": "Name"
},
"link": {
"type": "string",
"title": "Link"
},
"serves": {
"type": "integer",
"title": "Serves"
},
"imageUrls": {
"items": {
"type": "string"
},
"type": "array",
"title": "Imageurls"
},
"ingredients": {
"items": {
"$ref": "#/components/schemas/Ingredient"
},
"type": "array",
"title": "Ingredients"
},
"basedOnRecipe": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Basedonrecipe"
},
"dateCreated": {
"type": "string",
"format": "date-time",
"title": "Datecreated"
},
"createdById": {
"type": "integer",
"title": "Createdbyid"
},
"createdBy": {
"anyOf": [
{
"$ref": "#/components/schemas/Person"
},
{
"type": "null"
}
]
},
"dateHidden": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Datehidden"
},
"hiddenById": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Hiddenbyid"
},
"hiddenBy": {
"anyOf": [
{
"$ref": "#/components/schemas/Person"
},
{
"type": "null"
}
]
}
},
"type": "object",
"required": [
"name",
"link",
"serves",
"createdById"
],
"title": "Recipe"
},
"Recipe-Output": {
"properties": { "properties": {
"id": { "id": {
"type": "integer", "type": "integer",

View file

@ -3,4 +3,5 @@ pydantic==2.9.2
httpx==0.27.2 httpx==0.27.2
ingredient-parser-nlp==1.1.2 ingredient-parser-nlp==1.1.2
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
aiosqlite==0.20.0 aiosqlite==0.20.0
argon2-cffi==23.1.0

View file

@ -57,7 +57,7 @@ class TestHealthAndLocationHeaders(unittest.IsolatedAsyncioTestCase):
def test_location_headers_on_create(self): def test_location_headers_on_create(self):
# Use the registered user id placeholder for v2 meal participants # Use the registered user id placeholder for v2 meal participants
person = {"id": 1, "name": "Loc"} person = {"id": 1, "displayName": "Loc"}
# Skip recipe endpoint complexity here; covered by other tests # Skip recipe endpoint complexity here; covered by other tests

View file

@ -42,9 +42,9 @@ class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase):
# Create a meal with suggested date and one extra ingredient # Create a meal with suggested date and one extra ingredient
body = { body = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(), "suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 1, "name": "A"}], "chefs": [{"id": 1, "displayName": "A"}],
"cleanup": [{"id": 1, "name": "A"}], "cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "name": "A"}], "consumers": [{"id": 1, "displayName": "A"}],
"recipes": [], "recipes": [],
"extraIngredients": [ "extraIngredients": [
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
@ -80,9 +80,9 @@ class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase):
# Base valid body # Base valid body
base = { base = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(), "suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 1, "name": "A"}], "chefs": [{"id": 1, "displayName": "A"}],
"cleanup": [{"id": 1, "name": "A"}], "cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "name": "A"}], "consumers": [{"id": 1, "displayName": "A"}],
"recipes": [], "recipes": [],
"extraIngredients": [ "extraIngredients": [
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}
@ -139,9 +139,9 @@ class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase):
# Create a valid meal first # Create a valid meal first
body = { body = {
"suggestedDate": datetime.datetime.now().astimezone().isoformat(), "suggestedDate": datetime.datetime.now().astimezone().isoformat(),
"chefs": [{"id": 1, "name": "A"}], "chefs": [{"id": 1, "displayName": "A"}],
"cleanup": [{"id": 1, "name": "A"}], "cleanup": [{"id": 1, "displayName": "A"}],
"consumers": [{"id": 1, "name": "A"}], "consumers": [{"id": 1, "displayName": "A"}],
"recipes": [], "recipes": [],
"extraIngredients": [ "extraIngredients": [
{"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""}