diff options
| author | Caine <caine@jihakuz.xyz> | 2026-04-02 16:32:00 +0100 |
|---|---|---|
| committer | Caine <caine@jihakuz.xyz> | 2026-04-02 16:32:00 +0100 |
| commit | 487bf469795d70fb2bfdbee882d00f0c5e726a9a (patch) | |
| tree | 6fbd7810d57282710b9b32a301d221868580d8df | |
| parent | 5fd445087281dff65dfc158795834aef2cddaf1b (diff) | |
Add DRF serializers, viewsets, what-can-i-cook endpoint, log-cook with pantry deduction
| -rw-r--r-- | kitchen/serializers.py | 114 | ||||
| -rw-r--r-- | kitchen/views.py | 418 |
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 |
