summaryrefslogtreecommitdiff
path: root/kitchen
diff options
context:
space:
mode:
authorCaine <caine@jihakuz.xyz>2026-04-02 16:32:00 +0100
committerCaine <caine@jihakuz.xyz>2026-04-02 16:32:00 +0100
commit487bf469795d70fb2bfdbee882d00f0c5e726a9a (patch)
tree6fbd7810d57282710b9b32a301d221868580d8df /kitchen
parent5fd445087281dff65dfc158795834aef2cddaf1b (diff)
Add DRF serializers, viewsets, what-can-i-cook endpoint, log-cook with pantry deduction
Diffstat (limited to 'kitchen')
-rw-r--r--kitchen/serializers.py114
-rw-r--r--kitchen/views.py418
2 files changed, 530 insertions, 2 deletions
diff --git a/kitchen/serializers.py b/kitchen/serializers.py
new file mode 100644
index 0000000..43eb9a7
--- /dev/null
+++ b/kitchen/serializers.py
@@ -0,0 +1,114 @@
+from rest_framework import serializers
+from .models import (
+ Tag,
+ Ingredient,
+ PantryItem,
+ MetaRecipe,
+ Slot,
+ SlotOption,
+ MetaRecipeBase,
+ Recipe,
+ RecipeIngredient,
+ CookLog,
+ ShoppingListItem,
+)
+
+
+class TagSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Tag
+ fields = "__all__"
+
+
+class IngredientSerializer(serializers.ModelSerializer):
+ tags = TagSerializer(many=True, read_only=True)
+ tag_ids = serializers.PrimaryKeyRelatedField(
+ queryset=Tag.objects.all(), many=True, write_only=True, source="tags", required=False
+ )
+
+ class Meta:
+ model = Ingredient
+ fields = "__all__"
+
+
+class PantryItemSerializer(serializers.ModelSerializer):
+ ingredient_name = serializers.CharField(source="ingredient.name", read_only=True)
+
+ class Meta:
+ model = PantryItem
+ fields = "__all__"
+
+
+# --- Meta-Recipe nested serializers ---
+
+
+class SlotOptionSerializer(serializers.ModelSerializer):
+ ingredient_name = serializers.CharField(source="ingredient.name", read_only=True)
+
+ class Meta:
+ model = SlotOption
+ fields = "__all__"
+
+
+class SlotSerializer(serializers.ModelSerializer):
+ options = SlotOptionSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Slot
+ fields = "__all__"
+
+
+class MetaRecipeBaseSerializer(serializers.ModelSerializer):
+ ingredient_name = serializers.CharField(source="ingredient.name", read_only=True)
+
+ class Meta:
+ model = MetaRecipeBase
+ fields = "__all__"
+
+
+class MetaRecipeSerializer(serializers.ModelSerializer):
+ slots = SlotSerializer(many=True, read_only=True)
+ base_ingredients = MetaRecipeBaseSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = MetaRecipe
+ fields = "__all__"
+
+
+# --- Fixed Recipe nested serializers ---
+
+
+class RecipeIngredientSerializer(serializers.ModelSerializer):
+ ingredient_name = serializers.CharField(source="ingredient.name", read_only=True)
+
+ class Meta:
+ model = RecipeIngredient
+ fields = "__all__"
+
+
+class RecipeSerializer(serializers.ModelSerializer):
+ ingredients = RecipeIngredientSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Recipe
+ fields = "__all__"
+
+
+# --- Cook Log & Shopping ---
+
+
+class CookLogSerializer(serializers.ModelSerializer):
+ meta_recipe_name = serializers.CharField(source="meta_recipe.name", read_only=True, default=None)
+ recipe_name = serializers.CharField(source="recipe.name", read_only=True, default=None)
+
+ class Meta:
+ model = CookLog
+ fields = "__all__"
+
+
+class ShoppingListItemSerializer(serializers.ModelSerializer):
+ ingredient_name = serializers.CharField(source="ingredient.name", read_only=True, default=None)
+
+ class Meta:
+ model = ShoppingListItem
+ fields = "__all__"
diff --git a/kitchen/views.py b/kitchen/views.py
index 91ea44a..7521e50 100644
--- a/kitchen/views.py
+++ b/kitchen/views.py
@@ -1,3 +1,417 @@
-from django.shortcuts import render
+from decimal import Decimal
+from datetime import date
-# Create your views here.
+from rest_framework import viewsets, status
+from rest_framework.decorators import api_view, permission_classes, action
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from .models import (
+ Tag,
+ Ingredient,
+ PantryItem,
+ MetaRecipe,
+ Slot,
+ SlotOption,
+ MetaRecipeBase,
+ Recipe,
+ RecipeIngredient,
+ CookLog,
+ ShoppingListItem,
+)
+from .serializers import (
+ TagSerializer,
+ IngredientSerializer,
+ PantryItemSerializer,
+ MetaRecipeSerializer,
+ SlotSerializer,
+ SlotOptionSerializer,
+ MetaRecipeBaseSerializer,
+ RecipeSerializer,
+ RecipeIngredientSerializer,
+ CookLogSerializer,
+ ShoppingListItemSerializer,
+)
+
+
+# --- Standard CRUD ViewSets ---
+
+
+class TagViewSet(viewsets.ModelViewSet):
+ queryset = Tag.objects.all()
+ serializer_class = TagSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class IngredientViewSet(viewsets.ModelViewSet):
+ queryset = Ingredient.objects.all()
+ serializer_class = IngredientSerializer
+ permission_classes = [IsAuthenticated]
+ search_fields = ["name", "aliases"]
+
+
+class PantryItemViewSet(viewsets.ModelViewSet):
+ queryset = PantryItem.objects.select_related("ingredient").all()
+ serializer_class = PantryItemSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = ["location", "is_staple"]
+
+ @action(detail=False, methods=["get"])
+ def expiring(self, request):
+ """Items expiring within 3 days."""
+ from datetime import timedelta
+
+ cutoff = date.today() + timedelta(days=3)
+ items = self.queryset.filter(
+ expiry_date__isnull=False,
+ expiry_date__lte=cutoff,
+ quantity__gt=0,
+ )
+ serializer = self.get_serializer(items, many=True)
+ return Response(serializer.data)
+
+ @action(detail=False, methods=["get"])
+ def restock(self, request):
+ """Staple items that need restocking (quantity = 0)."""
+ items = self.queryset.filter(is_staple=True, quantity=0)
+ serializer = self.get_serializer(items, many=True)
+ return Response(serializer.data)
+
+
+class MetaRecipeViewSet(viewsets.ModelViewSet):
+ queryset = MetaRecipe.objects.prefetch_related(
+ "slots__options__ingredient", "base_ingredients__ingredient"
+ ).all()
+ serializer_class = MetaRecipeSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class SlotViewSet(viewsets.ModelViewSet):
+ queryset = Slot.objects.prefetch_related("options__ingredient").all()
+ serializer_class = SlotSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class SlotOptionViewSet(viewsets.ModelViewSet):
+ queryset = SlotOption.objects.select_related("ingredient").all()
+ serializer_class = SlotOptionSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class MetaRecipeBaseViewSet(viewsets.ModelViewSet):
+ queryset = MetaRecipeBase.objects.select_related("ingredient").all()
+ serializer_class = MetaRecipeBaseSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class RecipeViewSet(viewsets.ModelViewSet):
+ queryset = Recipe.objects.prefetch_related("ingredients__ingredient").all()
+ serializer_class = RecipeSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class RecipeIngredientViewSet(viewsets.ModelViewSet):
+ queryset = RecipeIngredient.objects.select_related("ingredient").all()
+ serializer_class = RecipeIngredientSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class CookLogViewSet(viewsets.ModelViewSet):
+ queryset = CookLog.objects.select_related("meta_recipe", "recipe").all()
+ serializer_class = CookLogSerializer
+ permission_classes = [IsAuthenticated]
+
+
+class ShoppingListItemViewSet(viewsets.ModelViewSet):
+ queryset = ShoppingListItem.objects.select_related("ingredient").all()
+ serializer_class = ShoppingListItemSerializer
+ permission_classes = [IsAuthenticated]
+ filterset_fields = ["checked"]
+
+
+# --- What Can I Cook? ---
+
+
+@api_view(["GET"])
+@permission_classes([IsAuthenticated])
+def what_can_i_cook(request):
+ """
+ Match pantry contents against meta-recipes and fixed recipes.
+
+ Returns recipes sorted by feasibility:
+ - ✅ All required slots can be filled from pantry
+ - ⚠️ Most slots filled, some missing (with details)
+ - ❌ Not enough ingredients
+
+ Also checks expiry warnings on matched ingredients.
+ """
+ servings = int(request.query_params.get("servings", 2))
+
+ # Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
+ pantry = {}
+ for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
+ if item.ingredient_id not in pantry:
+ pantry[item.ingredient_id] = []
+ pantry[item.ingredient_id].append({
+ "quantity": item.quantity,
+ "unit": item.unit,
+ "location": item.location,
+ "expiry_date": item.expiry_date,
+ "is_expired": item.expiry_date and item.expiry_date < date.today(),
+ "expiring_soon": (
+ item.expiry_date
+ and not (item.expiry_date < date.today())
+ and (item.expiry_date - date.today()).days <= 2
+ ),
+ })
+
+ def get_pantry_total(ingredient_id):
+ """Total quantity available across all locations."""
+ if ingredient_id not in pantry:
+ return Decimal("0")
+ return sum(p["quantity"] for p in pantry[ingredient_id])
+
+ def get_pantry_warnings(ingredient_id):
+ """Get expiry warnings for an ingredient."""
+ warnings = []
+ if ingredient_id in pantry:
+ for p in pantry[ingredient_id]:
+ if p["is_expired"]:
+ warnings.append(f"EXPIRED in {p['location']} (exp {p['expiry_date']})")
+ elif p["expiring_soon"]:
+ warnings.append(f"expiring soon in {p['location']} (exp {p['expiry_date']})")
+ return warnings
+
+ results = []
+
+ # Check meta-recipes
+ for meta in MetaRecipe.objects.prefetch_related(
+ "slots__options__ingredient", "base_ingredients__ingredient"
+ ).all():
+ recipe_result = {
+ "type": "meta_recipe",
+ "id": meta.id,
+ "name": meta.name,
+ "slots": [],
+ "base_missing": [],
+ "warnings": [],
+ "feasible": True,
+ }
+
+ # Check base ingredients
+ for base in meta.base_ingredients.all():
+ needed = base.quantity_per_serving * servings
+ available = get_pantry_total(base.ingredient_id)
+ warnings = get_pantry_warnings(base.ingredient_id)
+
+ if available < needed:
+ if base.ingredient.is_staple_ingredient:
+ # Staples like oil/salt — assume always available even if tracking says 0
+ pass
+ else:
+ recipe_result["base_missing"].append({
+ "ingredient": base.ingredient.name,
+ "needed": f"{needed} {base.unit}",
+ "have": f"{available} {base.unit}" if available > 0 else "none",
+ })
+ if warnings:
+ recipe_result["warnings"].extend(
+ [f"{base.ingredient.name}: {w}" for w in warnings]
+ )
+
+ # Check each slot
+ for slot in meta.slots.all():
+ slot_result = {
+ "name": slot.name,
+ "required": slot.required,
+ "max_choices": slot.max_choices,
+ "available_options": [],
+ "missing_options": [],
+ }
+
+ for option in slot.options.all():
+ needed = option.quantity_per_serving * servings
+ available = get_pantry_total(option.ingredient_id)
+ warnings = get_pantry_warnings(option.ingredient_id)
+
+ option_info = {
+ "ingredient": option.ingredient.name,
+ "needed": f"{needed} {option.unit}",
+ "have": f"{available} {option.unit}",
+ "notes": option.notes,
+ }
+
+ if warnings:
+ option_info["warnings"] = warnings
+
+ if available >= needed:
+ slot_result["available_options"].append(option_info)
+ else:
+ slot_result["missing_options"].append(option_info)
+
+ # Is this slot satisfied?
+ if slot.required and not slot_result["available_options"]:
+ recipe_result["feasible"] = False
+
+ recipe_result["slots"].append(slot_result)
+
+ # Determine status
+ if recipe_result["feasible"] and not recipe_result["base_missing"]:
+ recipe_result["status"] = "ready"
+ recipe_result["emoji"] = "✅"
+ elif recipe_result["feasible"]:
+ recipe_result["status"] = "partial"
+ recipe_result["emoji"] = "⚠️"
+ else:
+ recipe_result["status"] = "missing"
+ recipe_result["emoji"] = "❌"
+
+ results.append(recipe_result)
+
+ # Check fixed recipes
+ for recipe in Recipe.objects.prefetch_related("ingredients__ingredient").all():
+ recipe_result = {
+ "type": "recipe",
+ "id": recipe.id,
+ "name": recipe.name,
+ "missing": [],
+ "warnings": [],
+ "feasible": True,
+ }
+
+ for ri in recipe.ingredients.all():
+ needed = ri.quantity * (servings / recipe.servings)
+ available = get_pantry_total(ri.ingredient_id)
+ warnings = get_pantry_warnings(ri.ingredient_id)
+
+ if available < needed and not ri.optional:
+ recipe_result["feasible"] = False
+ recipe_result["missing"].append({
+ "ingredient": ri.ingredient.name,
+ "needed": f"{needed} {ri.unit}",
+ "have": f"{available} {ri.unit}" if available > 0 else "none",
+ })
+
+ if warnings:
+ recipe_result["warnings"].extend(
+ [f"{ri.ingredient.name}: {w}" for w in warnings]
+ )
+
+ recipe_result["status"] = "ready" if recipe_result["feasible"] else "missing"
+ recipe_result["emoji"] = "✅" if recipe_result["feasible"] else "❌"
+ results.append(recipe_result)
+
+ # Sort: ready first, then partial, then missing
+ order = {"ready": 0, "partial": 1, "missing": 2}
+ results.sort(key=lambda r: order.get(r["status"], 3))
+
+ return Response({
+ "servings": servings,
+ "pantry_items": PantryItem.objects.filter(quantity__gt=0).count(),
+ "results": results,
+ })
+
+
+@api_view(["POST"])
+@permission_classes([IsAuthenticated])
+def log_cook(request):
+ """
+ Log a meal that was cooked. Optionally deducts ingredients from pantry.
+
+ Body:
+ {
+ "meta_recipe_id": 1, // or "recipe_id": 1
+ "slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
+ "servings": 2,
+ "notes": "added extra garlic",
+ "deduct": true // auto-deduct from pantry
+ }
+ """
+ meta_recipe_id = request.data.get("meta_recipe_id")
+ recipe_id = request.data.get("recipe_id")
+ slot_choices = request.data.get("slot_choices", {})
+ servings = int(request.data.get("servings", 2))
+ notes = request.data.get("notes", "")
+ deduct = request.data.get("deduct", False)
+
+ if not meta_recipe_id and not recipe_id:
+ return Response(
+ {"error": "Must provide meta_recipe_id or recipe_id"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Create cook log
+ log = CookLog.objects.create(
+ meta_recipe_id=meta_recipe_id,
+ recipe_id=recipe_id,
+ slot_choices=slot_choices,
+ servings=servings,
+ notes=notes,
+ )
+
+ deducted = []
+
+ if deduct and meta_recipe_id:
+ meta = MetaRecipe.objects.prefetch_related(
+ "slots__options__ingredient", "base_ingredients__ingredient"
+ ).get(id=meta_recipe_id)
+
+ # Deduct base ingredients
+ for base in meta.base_ingredients.all():
+ amount = base.quantity_per_serving * servings
+ deducted += _deduct_ingredient(base.ingredient, amount, base.unit)
+
+ # Deduct slot choices
+ for slot_name, ingredient_name in slot_choices.items():
+ try:
+ slot = meta.slots.get(name=slot_name)
+ option = slot.options.get(ingredient__name=ingredient_name)
+ amount = option.quantity_per_serving * servings
+ deducted += _deduct_ingredient(option.ingredient, amount, option.unit)
+ except (Slot.DoesNotExist, SlotOption.DoesNotExist):
+ pass
+
+ elif deduct and recipe_id:
+ recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
+ for ri in recipe.ingredients.all():
+ amount = ri.quantity * (servings / recipe.servings)
+ deducted += _deduct_ingredient(ri.ingredient, amount, ri.unit)
+
+ return Response({
+ "cook_log_id": log.id,
+ "deducted": deducted,
+ }, status=status.HTTP_201_CREATED)
+
+
+def _deduct_ingredient(ingredient, amount, unit):
+ """Deduct an amount from pantry, preferring fridge items first (use oldest first)."""
+ remaining = Decimal(str(amount))
+ deducted = []
+
+ # Prefer fridge (expires first), then cupboard, then freezer
+ items = PantryItem.objects.filter(
+ ingredient=ingredient, quantity__gt=0
+ ).order_by(
+ # Fridge first, then cupboard, then freezer
+ # Within same location, earliest expiry first
+ "expiry_date",
+ )
+
+ for item in items:
+ if remaining <= 0:
+ break
+
+ take = min(item.quantity, remaining)
+ item.quantity -= take
+ item.save(update_fields=["quantity"])
+ remaining -= take
+
+ deducted.append({
+ "ingredient": ingredient.name,
+ "amount": str(take),
+ "unit": unit,
+ "from": item.location,
+ "remaining_in_pantry": str(item.quantity),
+ })
+
+ return deducted