Files
Tom FluxandClaude Opus 4.8 9efc4adcd4 Fix bulk-pantry-add crash on null unit (found in live MCP testing)
End-to-end testing (MCP client -> FastMCP -> Django) surfaced a 500:
adding an item with an explicit `unit: null` (as add_to_pantry sends
for items without a unit) hit a NOT NULL violation, because
`item_data.get("unit", "items")` returns None when the key is present.

- views.bulk_pantry_add: `item_data.get("unit") or "items"` — tolerate
  null/empty unit.
- mcp_server add_to_pantry: omit quantity/unit from the payload when
  unset, so the API applies its own defaults.
- test: bulk-add with unit/quantity = null returns 201 (25 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:24:09 +01:00

931 lines
33 KiB
Python

import re
from decimal import Decimal
from datetime import date, timedelta
from django.core.exceptions import ValidationError
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,
MetaRecipeWriteSerializer,
SlotSerializer,
SlotOptionSerializer,
MetaRecipeBaseSerializer,
RecipeSerializer,
RecipeIngredientSerializer,
CookLogSerializer,
ShoppingListItemSerializer,
)
from .helpers import (
find_ingredient as _find_ingredient,
get_pantry_total as _get_pantry_total,
get_section_key as _get_section,
)
# --- 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]
class PantryItemViewSet(viewsets.ModelViewSet):
queryset = PantryItem.objects.select_related("ingredient").all()
serializer_class = PantryItemSerializer
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["get"])
def expiring(self, request):
"""Items expiring within 3 days."""
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]
# --- Create/Update Meta-Recipe (nested) ---
@api_view(["POST", "PUT"])
@permission_classes([IsAuthenticated])
def create_meta_recipe(request):
"""
Create or update a full meta-recipe with slots, options, and base ingredients in one call.
POST creates new. PUT updates (requires "id" in body).
Body:
{
"name": "Baked Pasta",
"method": "1. Boil pasta\\n2. Mix in tin with veg + sauce\\n3. Oven 25-30 mins",
"prep_time_mins": 10,
"cook_time_mins": 30,
"default_servings": 2,
"gear_needed": "baking tray",
"tags": ["easy", "one-tray"],
"slots": [
{
"name": "pasta",
"required": true,
"max_choices": 1,
"options": [
{"ingredient_name": "gnocchi", "quantity_per_serving": "250", "unit": "g"},
{"ingredient_name": "orzo", "quantity_per_serving": "100", "unit": "g"}
]
}
],
"base_ingredients": [
{"ingredient_name": "olive oil", "quantity_per_serving": "1", "unit": "splash"}
]
}
Ingredients are looked up by name (case-insensitive, checks aliases).
If not found, they are auto-created. Pass "tags" in an option to tag new ingredients.
"""
serializer = MetaRecipeWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
if request.method == "PUT":
meta_id = request.data.get("id")
if not meta_id:
return Response({"error": "PUT requires 'id' field"}, status=status.HTTP_400_BAD_REQUEST)
try:
meta = MetaRecipe.objects.get(id=meta_id)
except MetaRecipe.DoesNotExist:
return Response({"error": f"MetaRecipe {meta_id} not found"}, status=status.HTTP_404_NOT_FOUND)
# Update fields
meta.name = data["name"]
meta.method = data["method"]
meta.prep_time_mins = data.get("prep_time_mins")
meta.cook_time_mins = data.get("cook_time_mins")
meta.default_servings = data.get("default_servings", 2)
meta.gear_needed = data.get("gear_needed", "")
meta.tags = data.get("tags", [])
meta.save()
# Clear old slots and bases for rebuild
meta.slots.all().delete()
meta.base_ingredients.all().delete()
else:
meta = MetaRecipe.objects.create(
name=data["name"],
method=data["method"],
prep_time_mins=data.get("prep_time_mins"),
cook_time_mins=data.get("cook_time_mins"),
default_servings=data.get("default_servings", 2),
gear_needed=data.get("gear_needed", ""),
tags=data.get("tags", []),
)
created_ingredients = []
# Create slots + options
for slot_data in data.get("slots", []):
slot = Slot.objects.create(
meta_recipe=meta,
name=slot_data["name"],
required=slot_data.get("required", True),
max_choices=slot_data.get("max_choices", 1),
)
for opt_data in slot_data.get("options", []):
ingredient, was_created = _resolve_ingredient(
opt_data.get("ingredient_id"),
opt_data["ingredient_name"],
opt_data.get("default_unit", opt_data["unit"]),
opt_data.get("tags", []),
opt_data.get("shelf_life_days"),
)
if was_created:
created_ingredients.append(ingredient.name)
SlotOption.objects.create(
slot=slot,
ingredient=ingredient,
quantity_per_serving=opt_data["quantity_per_serving"],
unit=opt_data["unit"],
notes=opt_data.get("notes", ""),
)
# Create base ingredients
for base_data in data.get("base_ingredients", []):
ingredient, _ = _resolve_ingredient(
base_data.get("ingredient_id"),
base_data["ingredient_name"],
base_data.get("default_unit", base_data["unit"]),
)
MetaRecipeBase.objects.create(
meta_recipe=meta,
ingredient=ingredient,
quantity_per_serving=base_data["quantity_per_serving"],
unit=base_data["unit"],
)
# Return the full created recipe
meta.refresh_from_db()
result = MetaRecipeSerializer(
MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).get(id=meta.id)
).data
result["_created_ingredients"] = created_ingredients
return Response(result, status=status.HTTP_201_CREATED if request.method == "POST" else status.HTTP_200_OK)
def _resolve_ingredient(ingredient_id, name, default_unit="items", tag_names=None, shelf_life_days=None):
"""Find or create an ingredient by ID or name. Returns (ingredient, was_created)."""
if ingredient_id:
try:
return Ingredient.objects.get(id=ingredient_id), False
except Ingredient.DoesNotExist:
pass
# Look up by name/alias
found = _find_ingredient(name)
if found:
return found, False
# Create new
ingredient = Ingredient.objects.create(
name=name.lower(),
default_unit=default_unit,
shelf_life_days=shelf_life_days,
)
# Add tags if provided
if tag_names:
for tag_name in tag_names:
tag, _ = Tag.objects.get_or_create(name=tag_name.lower())
ingredient.tags.add(tag)
return ingredient, True
# --- 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}
today = date.today()
pantry = {}
for item in PantryItem.objects.select_related("ingredient").exclude(
state=PantryItem.State.OUT
):
if item.ingredient_id not in pantry:
pantry[item.ingredient_id] = []
is_expired = item.expiry_date and item.expiry_date < today
pantry[item.ingredient_id].append({
"quantity": item.quantity,
"unit": item.unit,
"location": item.location,
"expiry_date": item.expiry_date,
"is_expired": is_expired,
"expiring_soon": (
item.expiry_date
and not is_expired
and (item.expiry_date - today).days <= 2
),
})
def get_pantry_total(ingredient_id):
"""Presence-based availability: having the ingredient (any non-'out'
item) satisfies a slot regardless of amount. Quantity may be None."""
return Decimal("Infinity") if ingredient_id in pantry else Decimal("0")
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": "in stock" if available >= needed else "none",
"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.
Body:
{
"meta_recipe_id": 1, // or "recipe_id": 1 (exactly one)
"slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
"servings": 2,
"rating": 4, // optional (1-5)
"notes": "added extra garlic"
}
Does NOT change pantry state. Returns `used_ingredients` so the caller can
*suggest* marking them low/out — applied separately, after the user
confirms, via /api/pantry/set-state/.
"""
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", "")
rating = request.data.get("rating")
if rating is not None:
rating = int(rating)
log = CookLog(
meta_recipe_id=meta_recipe_id,
recipe_id=recipe_id,
slot_choices=slot_choices,
servings=servings,
notes=notes,
rating=rating,
)
try:
# Enforces the "exactly one of meta_recipe / recipe" rule (CookLog.clean)
log.full_clean()
except ValidationError as e:
return Response({"errors": e.message_dict}, status=status.HTTP_400_BAD_REQUEST)
log.save()
# Suggest (do NOT apply) which ingredients were used.
used = []
if meta_recipe_id:
meta = MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).get(id=meta_recipe_id)
for base in meta.base_ingredients.all():
used.append({"ingredient": base.ingredient.name, "via": "base"})
for slot_name, ingredient_name in slot_choices.items():
used.append({"ingredient": ingredient_name, "via": f"slot:{slot_name}"})
elif recipe_id:
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
for ri in recipe.ingredients.all():
used.append({"ingredient": ri.ingredient.name, "via": "ingredient"})
return Response(
{"cook_log_id": log.id, "used_ingredients": used},
status=status.HTTP_201_CREATED,
)
# --- 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()
qty_raw = item_data.get("quantity")
quantity = int(qty_raw) if qty_raw not in (None, "") else None
unit = item_data.get("unit") or "items" # tolerate null/empty
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)
# Restock an existing row for this ingredient+location (back to 'in')
# rather than creating a duplicate.
existing = PantryItem.objects.filter(
ingredient=ingredient, location=location
).first()
if existing:
existing.state = PantryItem.State.IN
if quantity is not None:
existing.quantity = (existing.quantity or 0) + quantity
if expiry_date:
existing.expiry_date = expiry_date # refresh expiry with new stock
existing.save()
results.append({
"ingredient": ingredient.name,
"action": "restocked",
"new_total": str(existing.quantity) if existing.quantity is not None else None,
"unit": existing.unit,
"location": location,
})
else:
PantryItem.objects.create(
ingredient=ingredient,
state=PantryItem.State.IN,
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) if quantity is not None else None,
"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 _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
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def set_pantry_state(request):
"""
Set an ingredient's pantry state (in/low/out) by name — the conversational
update path ("used the last of the noodles" -> out).
Body: {"ingredient": "egg noodles", "state": "out", "location": "fridge"}
`location` is optional; without it the first matching pantry row is used.
If no pantry row exists, one is created so the state can be recorded.
"""
name = (request.data.get("ingredient") or "").strip()
new_state = request.data.get("state", "")
location = request.data.get("location")
if new_state not in PantryItem.State.values:
return Response(
{"error": f"Invalid state '{new_state}'. Use one of {list(PantryItem.State.values)}."},
status=status.HTTP_400_BAD_REQUEST,
)
ingredient = _find_ingredient(name)
if not ingredient:
return Response(
{"error": f"No ingredient matching '{name}'."},
status=status.HTTP_404_NOT_FOUND,
)
qs = PantryItem.objects.filter(ingredient=ingredient)
if location:
qs = qs.filter(location=location)
item = qs.first()
if item is None:
item = PantryItem(
ingredient=ingredient,
location=location or PantryItem.Location.FRIDGE,
unit=ingredient.default_unit or "",
)
item.state = new_state
item.save()
return Response(PantryItemSerializer(item).data, status=status.HTTP_200_OK)
# --- 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,
})
# --- 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.
"""
# Imported lazily so the optional recipe-scrapers/requests dependencies are
# only required when this endpoint is actually used.
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
match = re.search(r'(\d+)', str(yields_str))
return int(match.group(1)) if match else None