From c4ca4348edc5c4fd6cc36e9833fbb9c697f3bf9d Mon Sep 17 00:00:00 2001 From: Caine Date: Thu, 2 Apr 2026 23:08:43 +0100 Subject: Phase 4: HTMX frontend with dark palette - 4 pages: Pantry, Recipes, Shopping List, Cook Log - HTMX-powered: add/delete pantry items, toggle shopping, generate smart list - Custom 13-colour palette from Lospec (dark bg, yellow accent) - Mobile-responsive - Whitenoise for static files in production - All routes under /app/ - API (/api/) stays internal, frontend (/app/) for browser use --- kitchen/views_htmx.py | 328 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 kitchen/views_htmx.py (limited to 'kitchen/views_htmx.py') 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" -- cgit v1.2.3