Add DRF serializers, viewsets, what-can-i-cook endpoint, log-cook with pantry deduction
This commit is contained in:
+416
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user