summaryrefslogtreecommitdiff
path: root/kitchen/views.py
diff options
context:
space:
mode:
authorCaine <caine@jihakuz.xyz>2026-04-02 16:50:56 +0100
committerCaine <caine@jihakuz.xyz>2026-04-02 16:50:56 +0100
commit27f91fb877f4502804c932a28546ab6c745cc103 (patch)
treecd13b07daf2e27e5ef8778b4637f35ea7b11a29f /kitchen/views.py
parentda6cd28c3a8b4830fba3e1232e97cb2ae141ed2e (diff)
Phase 2+3: bulk pantry add, smart shopping list, recipe URL import
Diffstat (limited to 'kitchen/views.py')
-rw-r--r--kitchen/views.py377
1 files changed, 376 insertions, 1 deletions
diff --git a/kitchen/views.py b/kitchen/views.py
index 58685ab..6f34333 100644
--- a/kitchen/views.py
+++ b/kitchen/views.py
@@ -1,5 +1,5 @@
from decimal import Decimal
-from datetime import date
+from datetime import date, timedelta
from rest_framework import viewsets, status
from rest_framework.decorators import api_view, permission_classes, action
@@ -419,3 +419,378 @@ def _deduct_ingredient(ingredient, amount, unit):
})
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