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
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user