From 946c7de20cab78a47edbeae8fa65fe86a51511dd Mon Sep 17 00:00:00 2001 From: Caine Date: Thu, 2 Apr 2026 17:51:12 +0100 Subject: 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. --- kitchen/views.py | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) (limited to 'kitchen/views.py') diff --git a/kitchen/views.py b/kitchen/views.py index 6f34333..3dfbf4b 100644 --- a/kitchen/views.py +++ b/kitchen/views.py @@ -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? --- -- cgit v1.2.3