Phase 2+3: bulk pantry add, smart shopping list, recipe URL import
This commit is contained in:
@@ -19,4 +19,7 @@ urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
||||
path("log-cook/", views.log_cook, name="log-cook"),
|
||||
path("bulk-pantry-add/", views.bulk_pantry_add, name="bulk-pantry-add"),
|
||||
path("generate-shopping-list/", views.generate_shopping_list, name="generate-shopping-list"),
|
||||
path("import-recipe/", views.import_recipe_url, name="import-recipe"),
|
||||
]
|
||||
|
||||
+376
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user