Pantry move/expiry edit + smart shopping list with summary
- Move items between fridge↔freezer (freezer→fridge sets +7d or shelf life expiry, fridge→freezer clears expiry)
- Inline expiry date editor on fridge items (📅 button)
- Smart shopping list now shows recipe gaps (missing required slot ingredients)
- Summary card shows breakdown: X staple restocks, Y expiring, Z recipe gaps
This commit is contained in:
+98
-2
@@ -249,11 +249,67 @@ def pantry_delete(request, item_id):
|
||||
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def pantry_move(request, item_id):
|
||||
"""Move item between fridge/freezer."""
|
||||
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
|
||||
elif target == "fridge":
|
||||
item.location = "fridge"
|
||||
# Default +7 days when defrosting
|
||||
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)
|
||||
|
||||
item.save(update_fields=["location", "expiry_date"])
|
||||
ctx = _pantry_context()
|
||||
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def pantry_edit_expiry(request, item_id):
|
||||
"""Show inline expiry date editor."""
|
||||
item = get_object_or_404(PantryItem, id=item_id)
|
||||
return render(request, "kitchen/partials/pantry_expiry_edit.html", {"item": item})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def pantry_save_expiry(request, item_id):
|
||||
"""Save edited expiry date."""
|
||||
item = get_object_or_404(PantryItem, id=item_id)
|
||||
expiry = request.POST.get("expiry_date")
|
||||
if expiry:
|
||||
item.expiry_date = expiry
|
||||
else:
|
||||
item.expiry_date = None
|
||||
item.save(update_fields=["expiry_date"])
|
||||
ctx = _pantry_context()
|
||||
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
||||
|
||||
|
||||
def pantry_cancel_edit(request):
|
||||
"""Cancel expiry edit — just re-render the table."""
|
||||
ctx = _pantry_context()
|
||||
return render(request, "kitchen/partials/pantry_table.html", ctx)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@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
|
||||
from .views import _get_section
|
||||
|
||||
staple_count = 0
|
||||
expiring_count = 0
|
||||
recipe_gap_count = 0
|
||||
|
||||
suggestions = []
|
||||
|
||||
@@ -263,7 +319,9 @@ def shopping_generate(request):
|
||||
"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)
|
||||
@@ -274,7 +332,34 @@ def shopping_generate(request):
|
||||
"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()
|
||||
@@ -294,7 +379,18 @@ def shopping_generate(request):
|
||||
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})
|
||||
|
||||
summary = {
|
||||
"total": len(set(s["ingredient"] for s in suggestions)),
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user