summaryrefslogtreecommitdiff
path: root/kitchen/views_htmx.py
diff options
context:
space:
mode:
Diffstat (limited to 'kitchen/views_htmx.py')
-rw-r--r--kitchen/views_htmx.py328
1 files changed, 328 insertions, 0 deletions
diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py
new file mode 100644
index 0000000..5fc7e67
--- /dev/null
+++ b/kitchen/views_htmx.py
@@ -0,0 +1,328 @@
+"""
+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.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 ---
+
+@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)
+
+
+@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)
+
+
+@require_POST
+def shopping_generate(request):
+ """Generate smart shopping list and return updated HTML."""
+ from .views import _get_section, _find_ingredient as api_find, _is_known_staple
+
+ 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),
+ })
+
+ # 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),
+ })
+
+ # 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)
+ return render(request, "kitchen/partials/shopping_list.html", {"items": items})
+
+
+@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})
+
+
+@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"