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 settings import settings
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
router = APIRouter(prefix="/auth", tags=["auth-v2"])
@ -38,17 +46,32 @@ class TokenResponse(ApiModel):
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
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)
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()}"
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:
alg, iter_s, salt_b64, hash_b64 = stored.split("$", 3)
if alg != PBKDF2_ALG:
@ -57,7 +80,6 @@ def _verify_pw(pw: str, stored: str) -> bool:
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, iters)
# constant-time compare
return hmac.compare_digest(dk, expected)
except Exception:
return False

View file

@ -7,7 +7,9 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response
import meals
import persons
import shopping
import ingredients
from common import ProblemDetails, ApiModel
from api.deps import error_response
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"])
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):
consumed_date: Optional[datetime.datetime] = None
@ -29,7 +66,7 @@ async def get_upcoming_meals_scoped(
date_from: datetime.datetime = Query(..., alias="from"),
to: datetime.datetime = Query(...),
conn: aiosqlite.Connection = Depends(get_db),
) -> List[meals.Meal]:
) -> List[MealOut]:
hid = household["id"]
# Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped
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)}))
if not result:
return result
return []
# Load relateds similar to v1
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_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(
"/{meal_id}",
operation_id="getMealV2",
summary="Get a meal by id (scoped)",
response_model=meals.Meal,
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def get_meal_scoped(
@ -80,19 +136,33 @@ async def get_meal_scoped(
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
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(
"/{meal_id}/consumed",
operation_id="markMealConsumedV2",
summary="Mark a meal as consumed (scoped)",
response_model=meals.Meal,
response_model=MealOut,
responses={
400: {"model": ProblemDetails},
404: {"model": ProblemDetails},
@ -104,7 +174,7 @@ async def mark_meal_consumed_scoped(
body: Optional[MarkConsumedBody] = None,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
) -> MealOut | Response:
hid = household["id"]
consumed_date: Optional[datetime.datetime] = 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
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(
"",
operation_id="createMealV2",
summary="Create a new meal (scoped)",
response_model=meals.Meal,
response_model=MealOut,
responses={400: {"model": ProblemDetails}},
)
async def create_meal_scoped(
meal: meals.Meal,
meal: MealIn,
response: Response,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
) -> MealOut | Response:
# 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:
return error_response(request, 400, msg)
hid = household["id"]
await meals.insert_meal_scoped(conn, meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{meal.id}"
return meal
await meals.insert_meal_scoped(conn, domain_meal, hid)
response.headers["Location"] = f"/api/v1/households/{household['slug']}/meals/{domain_meal.id}"
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(
"/{meal_id}",
operation_id="updateMealV2",
summary="Update an existing meal (scoped)",
response_model=meals.Meal,
response_model=MealOut,
responses={400: {"model": ProblemDetails}, 404: {"model": ProblemDetails}},
)
async def update_meal_scoped(
meal_id: int,
meal: meals.Meal,
meal: MealIn,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
) -> MealOut | Response:
if meal.id != meal_id:
return error_response(request, 400, "Meal ID in URL does not match meal ID in body")
hid = household["id"]
existing = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not existing:
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:
return error_response(request, 400, msg)
await meals.update_meal(conn, meal)
await meals.update_meal(conn, domain_meal)
# 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(
"/{meal_id}",
operation_id="deleteMealV2",
summary="Delete a meal (scoped)",
response_model=meals.Meal,
response_model=MealOut,
responses={404: {"model": ProblemDetails}},
)
async def delete_meal_scoped(
@ -188,7 +335,7 @@ async def delete_meal_scoped(
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
) -> MealOut | Response:
hid = household["id"]
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
@ -202,4 +349,18 @@ async def delete_meal_scoped(
# Fallback: remove regardless of household (legacy cleanup)
await shopping.remove_request(conn, person=None, meal=meal)
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": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
},
"title": "Response Getupcomingmealsv2"
}
@ -758,7 +758,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
}
}
}
@ -818,7 +818,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Input"
"$ref": "#/components/schemas/MealIn"
}
}
}
@ -829,7 +829,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
}
}
}
@ -893,7 +893,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
}
}
}
@ -973,7 +973,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
}
}
}
@ -1029,7 +1029,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Input"
"$ref": "#/components/schemas/MealIn"
}
}
}
@ -1040,7 +1040,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/MealOut"
}
}
}
@ -1444,7 +1444,7 @@
},
"mealsLookup": {
"additionalProperties": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/Meal"
},
"type": "object",
"title": "Mealslookup"
@ -1458,7 +1458,7 @@
},
"recipesLookup": {
"additionalProperties": {
"$ref": "#/components/schemas/Recipe-Output"
"$ref": "#/components/schemas/Recipe"
},
"type": "object",
"title": "Recipeslookup"
@ -1799,7 +1799,7 @@
"type": "object",
"title": "MarkConsumedBody"
},
"Meal-Input": {
"Meal": {
"properties": {
"id": {
"type": "integer",
@ -1846,85 +1846,7 @@
},
"recipes": {
"items": {
"$ref": "#/components/schemas/MealRecipe-Input"
},
"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"
"$ref": "#/components/schemas/MealRecipe"
},
"type": "array",
"title": "Recipes"
@ -1968,7 +1890,161 @@
],
"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": {
"mealId": {
"type": "integer",
@ -1985,7 +2061,7 @@
"recipe": {
"anyOf": [
{
"$ref": "#/components/schemas/Recipe-Input"
"$ref": "#/components/schemas/Recipe"
},
{
"type": "null"
@ -2001,7 +2077,7 @@
],
"title": "MealRecipe"
},
"MealRecipe-Output": {
"MealRecipeIn": {
"properties": {
"mealId": {
"type": "integer",
@ -2014,16 +2090,6 @@
"servings": {
"type": "number",
"title": "Servings"
},
"recipe": {
"anyOf": [
{
"$ref": "#/components/schemas/Recipe-Output"
},
{
"type": "null"
}
]
}
},
"type": "object",
@ -2032,7 +2098,25 @@
"recipeId",
"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": {
"properties": {
@ -2244,7 +2328,7 @@
},
"mealsLookup": {
"additionalProperties": {
"$ref": "#/components/schemas/Meal-Output"
"$ref": "#/components/schemas/Meal"
},
"type": "object",
"title": "Mealslookup"
@ -2258,7 +2342,7 @@
},
"recipesLookup": {
"additionalProperties": {
"$ref": "#/components/schemas/Recipe-Output"
"$ref": "#/components/schemas/Recipe"
},
"type": "object",
"title": "Recipeslookup"
@ -2273,113 +2357,7 @@
],
"title": "PurchasedShoppingList"
},
"Recipe-Input": {
"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": {
"Recipe": {
"properties": {
"id": {
"type": "integer",

View file

@ -4,3 +4,4 @@ httpx==0.27.2
ingredient-parser-nlp==1.1.2
beautifulsoup4==4.12.3
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):
# 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

View file

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