418 lines
14 KiB
Python
418 lines
14 KiB
Python
from decimal import Decimal
|
|
from datetime import date
|
|
|
|
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
|