diff options
| author | Caine <caine@jihakuz.xyz> | 2026-04-02 17:51:12 +0100 |
|---|---|---|
| committer | Caine <caine@jihakuz.xyz> | 2026-04-02 17:51:12 +0100 |
| commit | 946c7de20cab78a47edbeae8fa65fe86a51511dd (patch) | |
| tree | 5e8c76be65259062a36eef736f1fcfd8ffd72580 /kitchen | |
| parent | 27f91fb877f4502804c932a28546ab6c745cc103 (diff) | |
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.
Diffstat (limited to 'kitchen')
| -rw-r--r-- | kitchen/serializers.py | 41 | ||||
| -rw-r--r-- | kitchen/urls.py | 1 | ||||
| -rw-r--r-- | kitchen/views.py | 159 |
3 files changed, 201 insertions, 0 deletions
diff --git a/kitchen/serializers.py b/kitchen/serializers.py index 43eb9a7..0de5220 100644 --- a/kitchen/serializers.py +++ b/kitchen/serializers.py @@ -75,6 +75,47 @@ class MetaRecipeSerializer(serializers.ModelSerializer): 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 --- diff --git a/kitchen/urls.py b/kitchen/urls.py index 2eabc81..ff3745f 100644 --- a/kitchen/urls.py +++ b/kitchen/urls.py @@ -22,4 +22,5 @@ urlpatterns = [ 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("import-recipe/", views.import_recipe_url, name="import-recipe"), + path("create-meta-recipe/", views.create_meta_recipe, name="create-meta-recipe"), ] 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? --- |
