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:
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,47 @@ class MetaRecipeSerializer(serializers.ModelSerializer):
|
|||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Writable nested meta-recipe ---
|
||||||
|
|
||||||
|
|
||||||
|
class SlotOptionWriteSerializer(serializers.Serializer):
|
||||||
|
ingredient_name = serializers.CharField()
|
||||||
|
ingredient_id = serializers.IntegerField(required=False)
|
||||||
|
quantity_per_serving = serializers.DecimalField(max_digits=8, decimal_places=2)
|
||||||
|
unit = serializers.CharField()
|
||||||
|
notes = serializers.CharField(required=False, default="", allow_blank=True)
|
||||||
|
# For auto-creating ingredients
|
||||||
|
default_unit = serializers.CharField(required=False)
|
||||||
|
tags = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||||
|
shelf_life_days = serializers.IntegerField(required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class SlotWriteSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField()
|
||||||
|
required = serializers.BooleanField(default=True)
|
||||||
|
max_choices = serializers.IntegerField(default=1)
|
||||||
|
options = SlotOptionWriteSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseIngredientWriteSerializer(serializers.Serializer):
|
||||||
|
ingredient_name = serializers.CharField()
|
||||||
|
ingredient_id = serializers.IntegerField(required=False)
|
||||||
|
quantity_per_serving = serializers.DecimalField(max_digits=8, decimal_places=2)
|
||||||
|
unit = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class MetaRecipeWriteSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField()
|
||||||
|
method = serializers.CharField()
|
||||||
|
prep_time_mins = serializers.IntegerField(required=False, allow_null=True, default=None)
|
||||||
|
cook_time_mins = serializers.IntegerField(required=False, allow_null=True, default=None)
|
||||||
|
default_servings = serializers.IntegerField(default=2)
|
||||||
|
gear_needed = serializers.CharField(required=False, default="", allow_blank=True)
|
||||||
|
tags = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||||
|
slots = SlotWriteSerializer(many=True, required=False, default=list)
|
||||||
|
base_ingredients = BaseIngredientWriteSerializer(many=True, required=False, default=list)
|
||||||
|
|
||||||
|
|
||||||
# --- Fixed Recipe nested serializers ---
|
# --- Fixed Recipe nested serializers ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ urlpatterns = [
|
|||||||
path("bulk-pantry-add/", views.bulk_pantry_add, name="bulk-pantry-add"),
|
path("bulk-pantry-add/", views.bulk_pantry_add, name="bulk-pantry-add"),
|
||||||
path("generate-shopping-list/", views.generate_shopping_list, name="generate-shopping-list"),
|
path("generate-shopping-list/", views.generate_shopping_list, name="generate-shopping-list"),
|
||||||
path("import-recipe/", views.import_recipe_url, name="import-recipe"),
|
path("import-recipe/", views.import_recipe_url, name="import-recipe"),
|
||||||
|
path("create-meta-recipe/", views.create_meta_recipe, name="create-meta-recipe"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .serializers import (
|
|||||||
IngredientSerializer,
|
IngredientSerializer,
|
||||||
PantryItemSerializer,
|
PantryItemSerializer,
|
||||||
MetaRecipeSerializer,
|
MetaRecipeSerializer,
|
||||||
|
MetaRecipeWriteSerializer,
|
||||||
SlotSerializer,
|
SlotSerializer,
|
||||||
SlotOptionSerializer,
|
SlotOptionSerializer,
|
||||||
MetaRecipeBaseSerializer,
|
MetaRecipeBaseSerializer,
|
||||||
@@ -129,6 +130,164 @@ class ShoppingListItemViewSet(viewsets.ModelViewSet):
|
|||||||
filterset_fields = ["checked"]
|
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? ---
|
# --- What Can I Cook? ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user