Add DRF serializers, viewsets, what-can-i-cook endpoint, log-cook with pantry deduction

This commit is contained in:
Caine
2026-04-02 16:32:00 +01:00
parent 5fd4450872
commit 487bf46979
2 changed files with 530 additions and 2 deletions
+114
View File
@@ -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__"
+416 -2
View File
@@ -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