Track presence (In/Low/Out) as the primary signal instead of exact quantities; quantity becomes an optional integer, unit optional too (migration 0003 backfills state from quantity: 0 -> out, else in). - Pantry rebuilt as a phone-first card list: colored state rail, segmented In/Low/Out switch (server-driven HTMX), greyed out-items, live summary counts. - Fast add: bottom add bar with type-ahead autocomplete, location picker, and quick-add chips for things you've run out of. - Per-item menu (move / set expiry / delete); expiry is now an optional quiet pill, never required. - New endpoints: pantry search + set-state; dropped the old edit-expiry/cancel flow and its partial. - Recipes matcher made presence-based (have-it beats have-enough); state added to admin. Tests cover state, add/restock, search, presence matching, and page rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
454 lines
15 KiB
Python
454 lines
15 KiB
Python
"""
|
|
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, CookLog, ShoppingListItem,
|
|
)
|
|
from .helpers import (
|
|
find_ingredient as _find_ingredient,
|
|
get_pantry_total as _get_pantry_total,
|
|
get_section_key as _get_section,
|
|
)
|
|
|
|
|
|
# --- Helpers ---
|
|
|
|
_STATE_RANK = {"in": 0, "low": 1, "out": 2}
|
|
|
|
|
|
def _pantry_context():
|
|
"""All pantry items grouped by location with state + expiry annotations.
|
|
|
|
Shows everything (including 'out' items, greyed in the UI) — presence is the
|
|
state field, not a quantity filter. Within a group, in/low sort before out.
|
|
"""
|
|
items = list(PantryItem.objects.select_related("ingredient").all())
|
|
today = date.today()
|
|
|
|
for item in items:
|
|
item.is_expired = bool(item.expiry_date and item.expiry_date < today)
|
|
item.expiring_soon = bool(
|
|
item.expiry_date
|
|
and not item.is_expired
|
|
and (item.expiry_date - today).days <= 2
|
|
)
|
|
item.days_left = (
|
|
(item.expiry_date - today).days
|
|
if item.expiry_date and not item.is_expired
|
|
else None
|
|
)
|
|
|
|
def by_location(loc):
|
|
return sorted(
|
|
(i for i in items if i.location == loc),
|
|
key=lambda i: (_STATE_RANK.get(i.state, 3), i.ingredient.name.lower()),
|
|
)
|
|
|
|
# Quick-add chips: things you've run out of — one tap to restock to 'in'.
|
|
chips, seen = [], set()
|
|
for i in items:
|
|
if i.state == "out" and i.ingredient.name not in seen:
|
|
seen.add(i.ingredient.name)
|
|
chips.append({"name": i.ingredient.name, "location": i.location})
|
|
|
|
return {
|
|
"groups": [
|
|
{"key": "fridge", "label": "Fridge", "items": by_location("fridge")},
|
|
{"key": "cupboard", "label": "Cupboard", "items": by_location("cupboard")},
|
|
{"key": "freezer", "label": "Freezer", "items": by_location("freezer")},
|
|
],
|
|
"has_items": bool(items),
|
|
"n_in": sum(1 for i in items if i.state == "in"),
|
|
"n_low": sum(1 for i in items if i.state == "low"),
|
|
"n_out": sum(1 for i in items if i.state == "out"),
|
|
"chips": chips[:8],
|
|
}
|
|
|
|
|
|
# --- 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 of what's present (anything not marked 'out').
|
|
# Presence-based: having the ingredient at all satisfies a slot, regardless
|
|
# of amount. Full quantity-aware matching is deferred (see plan.md §3).
|
|
pantry = {}
|
|
for item in PantryItem.objects.select_related("ingredient").exclude(
|
|
state=PantryItem.State.OUT
|
|
):
|
|
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):
|
|
# Presence as availability: "have it" beats "have enough" for now.
|
|
return Decimal("Infinity") if ing_id in pantry else Decimal("0")
|
|
|
|
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_list_items():
|
|
"""Fetch shopping list items annotated with their display section."""
|
|
items = ShoppingListItem.objects.select_related("ingredient").all()
|
|
for item in items:
|
|
item.section = _get_section_for_item(item)
|
|
return items
|
|
|
|
|
|
def shopping_page(request):
|
|
return render(request, "kitchen/shopping.html", {
|
|
"items": _shopping_list_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):
|
|
"""Add an item (or restock an existing one to 'in'). Quantity is optional."""
|
|
name = request.POST.get("ingredient_name", "").strip()
|
|
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="items")
|
|
|
|
unit = ingredient.default_unit or "items"
|
|
qty_raw = request.POST.get("quantity", "").strip()
|
|
quantity = int(qty_raw) if qty_raw.isdigit() else None
|
|
|
|
expiry_date = None
|
|
if location == "fridge" and ingredient.shelf_life_days:
|
|
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
|
|
|
|
# Restock an existing row for this ingredient+location rather than duplicate.
|
|
existing = PantryItem.objects.filter(ingredient=ingredient, location=location).first()
|
|
if existing:
|
|
existing.state = PantryItem.State.IN
|
|
if quantity is not None:
|
|
existing.quantity = (existing.quantity or 0) + quantity
|
|
if expiry_date:
|
|
existing.expiry_date = expiry_date
|
|
existing.save()
|
|
else:
|
|
PantryItem.objects.create(
|
|
ingredient=ingredient,
|
|
state=PantryItem.State.IN,
|
|
quantity=quantity,
|
|
unit=unit,
|
|
location=location,
|
|
expiry_date=expiry_date,
|
|
)
|
|
|
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def pantry_set_state(request, item_id):
|
|
"""Set an item's In/Low/Out state — the primary pantry interaction."""
|
|
item = get_object_or_404(PantryItem, id=item_id)
|
|
to = request.POST.get("to", "")
|
|
if to in PantryItem.State.values:
|
|
item.state = to
|
|
item.save()
|
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
|
|
|
|
|
def pantry_search(request):
|
|
"""Autocomplete suggestions for the add box (name match, case-insensitive)."""
|
|
q = request.GET.get("ingredient_name", "").strip()
|
|
suggestions = (
|
|
list(Ingredient.objects.filter(name__icontains=q).order_by("name")[:8]) if q else []
|
|
)
|
|
return render(
|
|
request,
|
|
"kitchen/partials/pantry_suggestions.html",
|
|
{"suggestions": suggestions, "q": q},
|
|
)
|
|
|
|
|
|
@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 an item between fridge / freezer / cupboard."""
|
|
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 # frozen = no expiry
|
|
elif target == "fridge":
|
|
item.location = "fridge"
|
|
# Default a fresh window when defrosting / moving into the fridge.
|
|
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)
|
|
elif target == "cupboard":
|
|
item.location = "cupboard"
|
|
|
|
item.save()
|
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def pantry_save_expiry(request, item_id):
|
|
"""Set or clear an item's expiry date (inline editor in the item menu)."""
|
|
item = get_object_or_404(PantryItem, id=item_id)
|
|
item.expiry_date = request.POST.get("expiry_date") or None
|
|
item.save()
|
|
return render(request, "kitchen/partials/pantry_table.html", _pantry_context())
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def shopping_generate(request):
|
|
"""Generate smart shopping list and return updated HTML."""
|
|
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 = _shopping_list_items()
|
|
|
|
summary = {
|
|
"total": len(seen),
|
|
"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"])
|
|
|
|
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def shopping_clear(request):
|
|
ShoppingListItem.objects.filter(checked=True).delete()
|
|
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
|
|
|
|
|
_SECTION_LABELS = {
|
|
"protein": "Protein",
|
|
"veg": "Veg",
|
|
"carbs": "Carbs",
|
|
"dairy": "Dairy",
|
|
"other": "Other",
|
|
}
|
|
|
|
|
|
def _get_section_for_item(item):
|
|
"""Title-cased, template-facing section label for a ShoppingListItem."""
|
|
if item.ingredient:
|
|
return _SECTION_LABELS[_get_section(item.ingredient)]
|
|
return "Other"
|