from decimal import Decimal from datetime import date, timedelta 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: # Check if this ingredient is tracked as a staple in the pantry is_staple = PantryItem.objects.filter( ingredient=base.ingredient, is_staple=True ).exists() if is_staple: # Staples like oil/salt — assume always available 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 # --- Bulk Pantry Add (Photo Intake) --- @api_view(["POST"]) @permission_classes([IsAuthenticated]) def bulk_pantry_add(request): """ Add multiple items to pantry at once. Designed for photo intake workflow. Body: { "items": [ {"ingredient_name": "eggs", "quantity": 6, "unit": "items", "location": "fridge"}, {"ingredient_name": "pork mince", "quantity": 500, "unit": "g", "location": "fridge"}, {"ingredient_name": "frozen stir fry veg", "quantity": 1, "unit": "bags", "location": "freezer"} ] } Looks up ingredients by name (case-insensitive, checks aliases too). If an ingredient doesn't exist, creates it with sensible defaults. If the item already exists in that location, adds to the quantity. """ items = request.data.get("items", []) if not items: return Response({"error": "No items provided"}, status=status.HTTP_400_BAD_REQUEST) results = [] for item_data in items: name = item_data.get("ingredient_name", "").strip() quantity = Decimal(str(item_data.get("quantity", 0))) unit = item_data.get("unit", "items") location = item_data.get("location", "fridge") expiry_days = item_data.get("expiry_days") # optional override if not name: results.append({"error": "Missing ingredient_name", "input": item_data}) continue # Look up ingredient by name or alias ingredient = _find_ingredient(name) if not ingredient: # Create new ingredient ingredient = Ingredient.objects.create( name=name.lower(), default_unit=unit, aliases=[name] if name.lower() != name else [], ) results.append({ "ingredient": ingredient.name, "action": "created_new_ingredient", "quantity": str(quantity), "unit": unit, "location": location, }) # Calculate expiry expiry_date = None if location == "fridge": if expiry_days: expiry_date = date.today() + timedelta(days=int(expiry_days)) elif ingredient.shelf_life_days: expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days) # Check if item already exists in this location existing = PantryItem.objects.filter( ingredient=ingredient, location=location, quantity__gt=0 ).first() if existing: existing.quantity += quantity if expiry_date: existing.expiry_date = expiry_date # refresh expiry with new stock existing.save() results.append({ "ingredient": ingredient.name, "action": "added_to_existing", "added": str(quantity), "new_total": str(existing.quantity), "unit": unit, "location": location, }) else: PantryItem.objects.create( ingredient=ingredient, quantity=quantity, unit=unit, location=location, expiry_date=expiry_date, is_staple=_is_known_staple(ingredient), ) results.append({ "ingredient": ingredient.name, "action": "created", "quantity": str(quantity), "unit": unit, "location": location, "expiry_date": str(expiry_date) if expiry_date else None, }) return Response({"added": len(results), "results": results}, status=status.HTTP_201_CREATED) def _find_ingredient(name): """Find ingredient by name or alias (case-insensitive).""" # Exact name match try: return Ingredient.objects.get(name__iexact=name) except Ingredient.DoesNotExist: pass # Search aliases (JSONField contains) for ingredient in Ingredient.objects.all(): if any(alias.lower() == name.lower() for alias in ingredient.aliases): return ingredient return None def _is_known_staple(ingredient): """Check if an ingredient should be marked as a staple.""" staple_names = {"onions", "frozen chips", "salt", "black pepper", "olive oil", "paprika", "garlic powder"} return ingredient.name.lower() in staple_names # --- Smart Shopping List Generation --- @api_view(["GET"]) @permission_classes([IsAuthenticated]) def generate_shopping_list(request): """ Generate a smart shopping list based on: 1. Staples that need restocking (quantity = 0) 2. Items expiring within 2 days (need replacing) 3. What's needed to cook specific meta-recipes (optional: ?recipes=1,2) 4. General low-stock items Query params: - recipes: comma-separated meta-recipe IDs to shop for - servings: servings per recipe (default 2) - add: if "true", adds items to the shopping list model """ recipe_ids = request.query_params.get("recipes", "") servings = int(request.query_params.get("servings", 2)) auto_add = request.query_params.get("add", "").lower() == "true" suggestions = [] # 1. Staples at zero for item in PantryItem.objects.filter(is_staple=True, quantity=0).select_related("ingredient"): suggestions.append({ "ingredient": item.ingredient.name, "reason": "restock staple", "priority": "high", "section": _get_section(item.ingredient), }) # 2. Items expiring soon (replace them) cutoff = date.today() + timedelta(days=2) for item in PantryItem.objects.filter( expiry_date__isnull=False, expiry_date__lte=cutoff, quantity__gt=0 ).select_related("ingredient"): suggestions.append({ "ingredient": item.ingredient.name, "reason": f"expiring {item.expiry_date} ({item.location})", "priority": "medium", "section": _get_section(item.ingredient), }) # 3. Recipe-specific needs if recipe_ids: try: ids = [int(x.strip()) for x in recipe_ids.split(",")] except ValueError: return Response({"error": "Invalid recipe IDs"}, status=status.HTTP_400_BAD_REQUEST) for meta in MetaRecipe.objects.filter(id__in=ids).prefetch_related( "slots__options__ingredient", "base_ingredients__ingredient" ): # Check base ingredients for base in meta.base_ingredients.all(): needed = base.quantity_per_serving * servings available = _get_pantry_total(base.ingredient_id) if available < needed: is_staple = PantryItem.objects.filter( ingredient=base.ingredient, is_staple=True ).exists() if not is_staple: suggestions.append({ "ingredient": base.ingredient.name, "reason": f"for {meta.name} ({needed} {base.unit} needed, have {available})", "quantity": str(needed - available), "unit": base.unit, "priority": "high", "section": _get_section(base.ingredient), }) # Check slots — suggest cheapest/preferred option per required slot for slot in meta.slots.all(): if not slot.required: continue # Check if ANY option is available any_available = False for option in slot.options.all(): needed = option.quantity_per_serving * servings available = _get_pantry_total(option.ingredient_id) if available >= needed: any_available = True break if not any_available: # Suggest the first option (could be smarter — prefer Tom's preferences) option = slot.options.first() if option: needed = option.quantity_per_serving * servings suggestions.append({ "ingredient": option.ingredient.name, "reason": f"for {meta.name} ({slot.name} slot)", "quantity": str(needed), "unit": option.unit, "priority": "high", "section": _get_section(option.ingredient), }) # Deduplicate by ingredient name seen = set() unique = [] for s in suggestions: if s["ingredient"] not in seen: seen.add(s["ingredient"]) unique.append(s) # Sort by section then priority section_order = {"protein": 0, "veg": 1, "carbs": 2, "dairy": 3, "other": 4} priority_order = {"high": 0, "medium": 1, "low": 2} unique.sort(key=lambda x: (section_order.get(x["section"], 5), priority_order.get(x["priority"], 3))) # Optionally add to shopping list model if auto_add: for s in unique: ingredient = _find_ingredient(s["ingredient"]) ShoppingListItem.objects.get_or_create( name=s["ingredient"], checked=False, defaults={ "ingredient": ingredient, "quantity": Decimal(s.get("quantity", "0")) if s.get("quantity") else None, "unit": s.get("unit", ""), "reason": s["reason"], }, ) return Response({ "count": len(unique), "suggestions": unique, "auto_added": auto_add, }) def _get_pantry_total(ingredient_id): """Get total quantity of an ingredient across all pantry locations.""" total = Decimal("0") for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0): total += item.quantity return total def _get_section(ingredient): """Determine shopping section from ingredient tags.""" tag_names = set(ingredient.tags.values_list("name", flat=True)) if "protein" in tag_names: return "protein" if "veg" in tag_names: return "veg" if "carb" in tag_names: return "carbs" if "dairy" in tag_names: return "dairy" return "other" # --- Recipe URL Import --- @api_view(["POST"]) @permission_classes([IsAuthenticated]) def import_recipe_url(request): """ Import a recipe from a URL using recipe-scrapers library. Body: { "url": "https://www.bbcgoodfood.com/recipes/sausage-traybake", "create": true // if false, just returns parsed data without saving } Supports 100+ sites including BBC Good Food, Allrecipes, Jamie Oliver, etc. """ from recipe_scrapers import scrape_html import requests as req url = request.data.get("url", "").strip() should_create = request.data.get("create", False) if not url: return Response({"error": "URL required"}, status=status.HTTP_400_BAD_REQUEST) try: # Fetch the page response = req.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=10) response.raise_for_status() # Parse with recipe-scrapers scraper = scrape_html(html=response.text, org_url=url) parsed = { "title": scraper.title(), "ingredients": scraper.ingredients(), "instructions": scraper.instructions(), "servings": _parse_servings(scraper.yields()), "prep_time": scraper.prep_time() if hasattr(scraper, 'prep_time') else None, "cook_time": scraper.cook_time() if hasattr(scraper, 'cook_time') else None, "total_time": scraper.total_time() if hasattr(scraper, 'total_time') else None, "image": scraper.image() if hasattr(scraper, 'image') else None, "source_url": url, } if should_create: # Create the recipe recipe = Recipe.objects.create( name=parsed["title"], method=parsed["instructions"], servings=parsed["servings"] or 2, prep_time_mins=parsed["prep_time"], cook_time_mins=parsed["cook_time"], source_url=url, ) # Try to match ingredients to existing ones ingredient_results = [] for ing_text in parsed["ingredients"]: ingredient_results.append({ "raw": ing_text, "status": "needs_review", "note": "Ingredient text imported raw — use admin or API to link to ingredient model", }) parsed["recipe_id"] = recipe.id parsed["ingredient_results"] = ingredient_results parsed["message"] = ( f"Recipe '{recipe.name}' created (id={recipe.id}). " f"Ingredients imported as raw text — they need linking to ingredient models " f"via admin or API. Caine can help parse and link them." ) return Response(parsed) except req.RequestException as e: return Response({"error": f"Failed to fetch URL: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: return Response({"error": f"Failed to parse recipe: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) def _parse_servings(yields_str): """Extract number from yields string like '4 servings'.""" if not yields_str: return None import re match = re.search(r'(\d+)', str(yields_str)) return int(match.group(1)) if match else None