""" 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"