"""
HTMX views — return HTML fragments for the frontend.
Separate from the DRF JSON API views.
"""
from datetime import date, timedelta
from decimal import Decimal
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST, require_http_methods
from .models import (
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption,
Recipe, CookLog, ShoppingListItem,
)
# --- Helpers ---
def _pantry_context():
"""Build pantry items grouped by location with expiry annotations."""
items = PantryItem.objects.select_related("ingredient").filter(quantity__gt=0)
today = date.today()
for item in items:
item.is_expired = item.expiry_date and item.expiry_date < today
item.expiring_soon = (
item.expiry_date
and not item.is_expired
and (item.expiry_date - today).days <= 2
)
return {
"fridge_items": [i for i in items if i.location == "fridge"],
"freezer_items": [i for i in items if i.location == "freezer"],
"cupboard_items": [i for i in items if i.location == "cupboard"],
}
def _get_pantry_total(ingredient_id):
total = Decimal("0")
for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0):
total += item.quantity
return total
def _find_ingredient(name):
"""Find ingredient by name or alias (case-insensitive)."""
try:
return Ingredient.objects.get(name__iexact=name)
except Ingredient.DoesNotExist:
pass
for ingredient in Ingredient.objects.all():
if ingredient.aliases and any(a.lower() == name.lower() for a in ingredient.aliases):
return ingredient
return None
# --- Page Views ---
def pantry_page(request):
ctx = _pantry_context()
ctx["active_tab"] = "pantry"
return render(request, "kitchen/pantry.html", ctx)
def recipes_page(request):
servings = 2
today = date.today()
# Build pantry lookup
pantry = {}
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
if item.ingredient_id not in pantry:
pantry[item.ingredient_id] = []
pantry[item.ingredient_id].append({
"quantity": item.quantity,
"unit": item.unit,
"location": item.location,
"expiry_date": item.expiry_date,
})
def get_total(ing_id):
return sum(p["quantity"] for p in pantry.get(ing_id, []))
def get_warnings(ing_id):
warnings = []
for p in pantry.get(ing_id, []):
if p["expiry_date"]:
if p["expiry_date"] < today:
warnings.append(f"EXPIRED in {p['location']}")
elif (p["expiry_date"] - today).days <= 2:
warnings.append(f"expiring soon in {p['location']}")
return warnings
recipes = []
for meta in MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).all():
result = {
"type": "meta_recipe",
"name": meta.name,
"gear": meta.gear_needed,
"slots": [],
"base_missing": [],
"warnings": [],
"status": "ready",
}
for base in meta.base_ingredients.all():
needed = base.quantity_per_serving * servings
available = get_total(base.ingredient_id)
ws = get_warnings(base.ingredient_id)
is_staple = PantryItem.objects.filter(ingredient=base.ingredient, is_staple=True).exists()
if available < needed and not is_staple:
result["base_missing"].append({
"ingredient": base.ingredient.name,
"needed": f"{needed} {base.unit}",
})
result["warnings"].extend([f"{base.ingredient.name}: {w}" for w in ws])
for slot in meta.slots.all():
slot_data = {
"name": slot.name,
"required": slot.required,
"available_options": [],
"missing_options": [],
}
for opt in slot.options.all():
needed = opt.quantity_per_serving * servings
available = get_total(opt.ingredient_id)
ws = get_warnings(opt.ingredient_id)
info = {
"ingredient": opt.ingredient.name,
"needed": f"{needed} {opt.unit}",
"have": f"{available} {opt.unit}",
"notes": opt.notes,
"warnings": ws,
}
if available >= needed:
slot_data["available_options"].append(info)
else:
slot_data["missing_options"].append(info)
if slot.required and not slot_data["available_options"]:
result["status"] = "missing"
result["slots"].append(slot_data)
if result["status"] == "ready" and result["base_missing"]:
result["status"] = "partial"
recipes.append(result)
# Sort: ready > partial > missing
order = {"ready": 0, "partial": 1, "missing": 2}
recipes.sort(key=lambda r: order.get(r["status"], 3))
return render(request, "kitchen/recipes.html", {
"recipes": recipes,
"active_tab": "recipes",
})
def shopping_page(request):
items = ShoppingListItem.objects.select_related("ingredient").all()
# Add section info
for item in items:
item.section = _get_section_for_item(item)
return render(request, "kitchen/shopping.html", {
"items": items,
"active_tab": "shopping",
})
def log_page(request):
entries = CookLog.objects.select_related("meta_recipe", "recipe").order_by("-date")[:50]
log_data = []
for entry in entries:
log_data.append({
"date": entry.date,
"recipe_name": entry.meta_recipe.name if entry.meta_recipe else (entry.recipe.name if entry.recipe else "Unknown"),
"rating": entry.rating,
"slot_choices": entry.slot_choices,
"notes": entry.notes,
"servings": entry.servings,
})
return render(request, "kitchen/log.html", {
"entries": log_data,
"active_tab": "log",
})
# --- HTMX Actions ---
@csrf_exempt
@require_POST
def pantry_add(request):
name = request.POST.get("ingredient_name", "").strip()
quantity = Decimal(request.POST.get("quantity", "0"))
unit = request.POST.get("unit", "items")
location = request.POST.get("location", "fridge")
if not name:
return HttpResponse("", status=400)
ingredient = _find_ingredient(name)
if not ingredient:
ingredient = Ingredient.objects.create(
name=name.lower(),
default_unit=unit,
)
expiry_date = None
if location == "fridge" and ingredient.shelf_life_days:
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
# Check for existing in same 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
existing.save(update_fields=["quantity", "expiry_date"])
else:
PantryItem.objects.create(
ingredient=ingredient,
quantity=quantity,
unit=unit,
location=location,
expiry_date=expiry_date,
)
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@csrf_exempt
@require_http_methods(["DELETE"])
def pantry_delete(request, item_id):
item = get_object_or_404(PantryItem, id=item_id)
item.delete()
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@csrf_exempt
@require_POST
def pantry_move(request, item_id):
"""Move item between fridge/freezer."""
item = get_object_or_404(PantryItem, id=item_id)
target = request.POST.get("to", "fridge")
if target == "freezer":
item.location = "freezer"
item.expiry_date = None
elif target == "fridge":
item.location = "fridge"
# Default +7 days when defrosting
if item.ingredient.shelf_life_days:
item.expiry_date = date.today() + timedelta(days=item.ingredient.shelf_life_days)
else:
item.expiry_date = date.today() + timedelta(days=7)
item.save(update_fields=["location", "expiry_date"])
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@csrf_exempt
@require_POST
def pantry_edit_expiry(request, item_id):
"""Show inline expiry date editor."""
item = get_object_or_404(PantryItem, id=item_id)
return render(request, "kitchen/partials/pantry_expiry_edit.html", {"item": item})
@csrf_exempt
@require_POST
def pantry_save_expiry(request, item_id):
"""Save edited expiry date."""
item = get_object_or_404(PantryItem, id=item_id)
expiry = request.POST.get("expiry_date")
if expiry:
item.expiry_date = expiry
else:
item.expiry_date = None
item.save(update_fields=["expiry_date"])
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
def pantry_cancel_edit(request):
"""Cancel expiry edit — just re-render the table."""
ctx = _pantry_context()
return render(request, "kitchen/partials/pantry_table.html", ctx)
@csrf_exempt
@require_POST
def shopping_generate(request):
"""Generate smart shopping list and return updated HTML."""
from .views import _get_section
staple_count = 0
expiring_count = 0
recipe_gap_count = 0
suggestions = []
# 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",
"section": _get_section(item.ingredient),
"type": "staple",
})
staple_count += 1
# Expiring items
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}",
"section": _get_section(item.ingredient),
"type": "expiring",
})
expiring_count += 1
# Recipe gaps — check required slots with zero available options
for meta in MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).all():
for slot in meta.slots.all():
if not slot.required:
continue
any_available = False
first_option = None
for opt in slot.options.all():
if not first_option:
first_option = opt
available = _get_pantry_total(opt.ingredient_id)
if available >= opt.quantity_per_serving * 2:
any_available = True
break
if not any_available and first_option:
suggestions.append({
"ingredient": first_option.ingredient.name,
"reason": f"for {meta.name} ({slot.name})",
"section": _get_section(first_option.ingredient),
"type": "recipe",
})
recipe_gap_count += 1
# Dedupe and save
seen = set()
for s in suggestions:
if s["ingredient"] not in seen:
seen.add(s["ingredient"])
ingredient = _find_ingredient(s["ingredient"])
ShoppingListItem.objects.get_or_create(
name=s["ingredient"],
checked=False,
defaults={
"ingredient": ingredient,
"reason": s["reason"],
},
)
items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items:
item.section = _get_section_for_item(item)
summary = {
"total": len(set(s["ingredient"] for s in suggestions)),
"staples": staple_count,
"expiring": expiring_count,
"recipe_gaps": recipe_gap_count,
}
return render(request, "kitchen/partials/shopping_list.html", {
"items": items,
"summary": summary,
})
@csrf_exempt
@require_POST
def shopping_toggle(request, item_id):
item = get_object_or_404(ShoppingListItem, id=item_id)
item.checked = not item.checked
item.save(update_fields=["checked"])
items = ShoppingListItem.objects.select_related("ingredient").all()
for i in items:
i.section = _get_section_for_item(i)
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
@csrf_exempt
@require_POST
def shopping_clear(request):
ShoppingListItem.objects.filter(checked=True).delete()
items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items:
item.section = _get_section_for_item(item)
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
def _get_section_for_item(item):
if item.ingredient:
tag_names = set(item.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"