Phase 2+3: bulk pantry add, smart shopping list, recipe URL import

This commit is contained in:
Caine
2026-04-02 16:50:56 +01:00
parent da6cd28c3a
commit 27f91fb877
2 changed files with 379 additions and 1 deletions
+376 -1
View File
@@ -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