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("", include(router.urls)),
|
||||||
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
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("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 decimal import Decimal
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import api_view, permission_classes, action
|
from rest_framework.decorators import api_view, permission_classes, action
|
||||||
@@ -419,3 +419,378 @@ def _deduct_ingredient(ingredient, amount, unit):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return deducted
|
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