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>
931 lines
33 KiB
Python
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
|