Add create-meta-recipe endpoint with nested slots/options/bases

- POST /api/create-meta-recipe/ creates full meta-recipe in one call
- PUT /api/create-meta-recipe/ updates existing (requires id)
- Auto-resolves ingredients by name/alias, creates new if not found
- Returns full nested response with _created_ingredients list
- New writable serializers: MetaRecipeWriteSerializer, SlotWriteSerializer, etc.
This commit is contained in:
Caine
2026-04-02 17:51:12 +01:00
parent 27f91fb877
commit 189d72b797
4 changed files with 2465 additions and 0 deletions
+159
View File
@@ -24,6 +24,7 @@ from .serializers import (
IngredientSerializer,
PantryItemSerializer,
MetaRecipeSerializer,
MetaRecipeWriteSerializer,
SlotSerializer,
SlotOptionSerializer,
MetaRecipeBaseSerializer,
@@ -129,6 +130,164 @@ class ShoppingListItemViewSet(viewsets.ModelViewSet):
filterset_fields = ["checked"]
# --- Create/Update Meta-Recipe (nested) ---
@api_view(["POST", "PUT"])
@permission_classes([IsAuthenticated])
def create_meta_recipe(request):
"""
Create or update a full meta-recipe with slots, options, and base ingredients in one call.
POST creates new. PUT updates (requires "id" in body).
Body:
{
"name": "Baked Pasta",
"method": "1. Boil pasta\\n2. Mix in tin with veg + sauce\\n3. Oven 25-30 mins",
"prep_time_mins": 10,
"cook_time_mins": 30,
"default_servings": 2,
"gear_needed": "baking tray",
"tags": ["easy", "one-tray"],
"slots": [
{
"name": "pasta",
"required": true,
"max_choices": 1,
"options": [
{"ingredient_name": "gnocchi", "quantity_per_serving": "250", "unit": "g"},
{"ingredient_name": "orzo", "quantity_per_serving": "100", "unit": "g"}
]
}
],
"base_ingredients": [
{"ingredient_name": "olive oil", "quantity_per_serving": "1", "unit": "splash"}
]
}
Ingredients are looked up by name (case-insensitive, checks aliases).
If not found, they are auto-created. Pass "tags" in an option to tag new ingredients.
"""
serializer = MetaRecipeWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if request.method == "PUT":
meta_id = request.data.get("id")
if not meta_id:
return Response({"error": "PUT requires 'id' field"}, status=status.HTTP_400_BAD_REQUEST)
try:
meta = MetaRecipe.objects.get(id=meta_id)
except MetaRecipe.DoesNotExist:
return Response({"error": f"MetaRecipe {meta_id} not found"}, status=status.HTTP_404_NOT_FOUND)
# Update fields
meta.name = data["name"]
meta.method = data["method"]
meta.prep_time_mins = data.get("prep_time_mins")
meta.cook_time_mins = data.get("cook_time_mins")
meta.default_servings = data.get("default_servings", 2)
meta.gear_needed = data.get("gear_needed", "")
meta.tags = data.get("tags", [])
meta.save()
# Clear old slots and bases for rebuild
meta.slots.all().delete()
meta.base_ingredients.all().delete()
else:
meta = MetaRecipe.objects.create(
name=data["name"],
method=data["method"],
prep_time_mins=data.get("prep_time_mins"),
cook_time_mins=data.get("cook_time_mins"),
default_servings=data.get("default_servings", 2),
gear_needed=data.get("gear_needed", ""),
tags=data.get("tags", []),
)
created_ingredients = []
# Create slots + options
for slot_data in data.get("slots", []):
slot = Slot.objects.create(
meta_recipe=meta,
name=slot_data["name"],
required=slot_data.get("required", True),
max_choices=slot_data.get("max_choices", 1),
)
for opt_data in slot_data.get("options", []):
ingredient, was_created = _resolve_ingredient(
opt_data.get("ingredient_id"),
opt_data["ingredient_name"],
opt_data.get("default_unit", opt_data["unit"]),
opt_data.get("tags", []),
opt_data.get("shelf_life_days"),
)
if was_created:
created_ingredients.append(ingredient.name)
SlotOption.objects.create(
slot=slot,
ingredient=ingredient,
quantity_per_serving=opt_data["quantity_per_serving"],
unit=opt_data["unit"],
notes=opt_data.get("notes", ""),
)
# Create base ingredients
for base_data in data.get("base_ingredients", []):
ingredient, _ = _resolve_ingredient(
base_data.get("ingredient_id"),
base_data["ingredient_name"],
base_data.get("default_unit", base_data["unit"]),
)
MetaRecipeBase.objects.create(
meta_recipe=meta,
ingredient=ingredient,
quantity_per_serving=base_data["quantity_per_serving"],
unit=base_data["unit"],
)
# Return the full created recipe
meta.refresh_from_db()
result = MetaRecipeSerializer(
MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).get(id=meta.id)
).data
result["_created_ingredients"] = created_ingredients
return Response(result, status=status.HTTP_201_CREATED if request.method == "POST" else status.HTTP_200_OK)
def _resolve_ingredient(ingredient_id, name, default_unit="items", tag_names=None, shelf_life_days=None):
"""Find or create an ingredient by ID or name. Returns (ingredient, was_created)."""
if ingredient_id:
try:
return Ingredient.objects.get(id=ingredient_id), False
except Ingredient.DoesNotExist:
pass
# Look up by name/alias
found = _find_ingredient(name)
if found:
return found, False
# Create new
ingredient = Ingredient.objects.create(
name=name.lower(),
default_unit=default_unit,
shelf_life_days=shelf_life_days,
)
# Add tags if provided
if tag_names:
for tag_name in tag_names:
tag, _ = Tag.objects.get_or_create(name=tag_name.lower())
ingredient.tags.add(tag)
return ingredient, True
# --- What Can I Cook? ---