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