summaryrefslogtreecommitdiff
path: root/kitchen
diff options
context:
space:
mode:
authorTom Flux <tom@tomflux.xyz>2026-06-22 22:50:48 +0100
committerTom Flux <tom@tomflux.xyz>2026-06-22 22:50:48 +0100
commitcdbfabf0ea855df854b2357dc124df03f18d0514 (patch)
treeb54a502435ea46745356f352679cf8d5020a2beb /kitchen
parent96d9c4b22e5bcae1d19b2dd1fcae3bc46f080254 (diff)
Simplify from claude
Diffstat (limited to 'kitchen')
-rw-r--r--kitchen/helpers.py47
-rw-r--r--kitchen/models.py4
-rw-r--r--kitchen/serializers.py1
-rw-r--r--kitchen/views.py70
-rw-r--r--kitchen/views_htmx.py75
5 files changed, 96 insertions, 101 deletions
diff --git a/kitchen/helpers.py b/kitchen/helpers.py
new file mode 100644
index 0000000..a4be79c
--- /dev/null
+++ b/kitchen/helpers.py
@@ -0,0 +1,47 @@
+"""
+Shared kitchen helpers used by both the DRF API views (views.py) and the
+HTMX views (views_htmx.py). Consolidated here to avoid cross-module
+duplication and the views_htmx -> views runtime coupling.
+"""
+from decimal import Decimal
+
+from .models import Ingredient, PantryItem
+
+
+def find_ingredient(name):
+ """Find ingredient by name or alias (case-insensitive)."""
+ # Exact name match
+ try:
+ return Ingredient.objects.get(name__iexact=name)
+ except Ingredient.DoesNotExist:
+ pass
+
+ # Search aliases (JSONField contains). Guard against a falsy aliases value.
+ for ingredient in Ingredient.objects.all():
+ if ingredient.aliases and any(a.lower() == name.lower() for a in ingredient.aliases):
+ return ingredient
+
+ return None
+
+
+def get_pantry_total(ingredient_id):
+ """Get total quantity of an ingredient across all pantry locations."""
+ total = Decimal("0")
+ for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0):
+ total += item.quantity
+ return total
+
+
+def get_section_key(ingredient):
+ """Determine the canonical (lowercase) shopping section key from an
+ ingredient's tags."""
+ tag_names = set(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"
diff --git a/kitchen/models.py b/kitchen/models.py
index 9c3e8d3..ab0a720 100644
--- a/kitchen/models.py
+++ b/kitchen/models.py
@@ -1,3 +1,5 @@
+from datetime import date, timedelta
+
from django.db import models
from django.core.exceptions import ValidationError
@@ -72,8 +74,6 @@ class PantryItem(models.Model):
if self.location == self.Location.FRIDGE and self.expiry_date is None:
# Try to auto-set from ingredient shelf life
if self.ingredient_id and self.ingredient.shelf_life_days:
- from datetime import date, timedelta
-
self.expiry_date = date.today() + timedelta(days=self.ingredient.shelf_life_days)
# Don't raise an error — some fridge items just don't have a known expiry
diff --git a/kitchen/serializers.py b/kitchen/serializers.py
index 097caff..0de5220 100644
--- a/kitchen/serializers.py
+++ b/kitchen/serializers.py
@@ -29,7 +29,6 @@ class IngredientSerializer(serializers.ModelSerializer):
class Meta:
model = Ingredient
fields = "__all__"
- # preferences field included via __all__
class PantryItemSerializer(serializers.ModelSerializer):
diff --git a/kitchen/views.py b/kitchen/views.py
index 3dfbf4b..939ef2b 100644
--- a/kitchen/views.py
+++ b/kitchen/views.py
@@ -1,3 +1,4 @@
+import re
from decimal import Decimal
from datetime import date, timedelta
@@ -33,6 +34,11 @@ from .serializers import (
CookLogSerializer,
ShoppingListItemSerializer,
)
+from .helpers import (
+ find_ingredient as _find_ingredient,
+ get_pantry_total as _get_pantry_total,
+ get_section_key as _get_section,
+)
# --- Standard CRUD ViewSets ---
@@ -48,20 +54,16 @@ class IngredientViewSet(viewsets.ModelViewSet):
queryset = Ingredient.objects.all()
serializer_class = IngredientSerializer
permission_classes = [IsAuthenticated]
- search_fields = ["name", "aliases"]
class PantryItemViewSet(viewsets.ModelViewSet):
queryset = PantryItem.objects.select_related("ingredient").all()
serializer_class = PantryItemSerializer
permission_classes = [IsAuthenticated]
- filterset_fields = ["location", "is_staple"]
@action(detail=False, methods=["get"])
def expiring(self, request):
"""Items expiring within 3 days."""
- from datetime import timedelta
-
cutoff = date.today() + timedelta(days=3)
items = self.queryset.filter(
expiry_date__isnull=False,
@@ -127,7 +129,6 @@ class ShoppingListItemViewSet(viewsets.ModelViewSet):
queryset = ShoppingListItem.objects.select_related("ingredient").all()
serializer_class = ShoppingListItemSerializer
permission_classes = [IsAuthenticated]
- filterset_fields = ["checked"]
# --- Create/Update Meta-Recipe (nested) ---
@@ -307,20 +308,22 @@ def what_can_i_cook(request):
servings = int(request.query_params.get("servings", 2))
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
+ today = date.today()
pantry = {}
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
if item.ingredient_id not in pantry:
pantry[item.ingredient_id] = []
+ is_expired = item.expiry_date and item.expiry_date < today
pantry[item.ingredient_id].append({
"quantity": item.quantity,
"unit": item.unit,
"location": item.location,
"expiry_date": item.expiry_date,
- "is_expired": item.expiry_date and item.expiry_date < date.today(),
+ "is_expired": is_expired,
"expiring_soon": (
item.expiry_date
- and not (item.expiry_date < date.today())
- and (item.expiry_date - date.today()).days <= 2
+ and not is_expired
+ and (item.expiry_date - today).days <= 2
),
})
@@ -547,16 +550,18 @@ def log_cook(request):
def _deduct_ingredient(ingredient, amount, unit):
- """Deduct an amount from pantry, preferring fridge items first (use oldest first)."""
+ """Deduct an amount from pantry, using items with the earliest expiry first.
+
+ Ordering is purely by ``expiry_date`` across all locations — location is
+ not part of the ordering.
+ """
remaining = Decimal(str(amount))
deducted = []
- # Prefer fridge (expires first), then cupboard, then freezer
+ # Earliest expiry first, regardless of location (fridge/cupboard/freezer).
items = PantryItem.objects.filter(
ingredient=ingredient, quantity__gt=0
).order_by(
- # Fridge first, then cupboard, then freezer
- # Within same location, earliest expiry first
"expiry_date",
)
@@ -684,22 +689,6 @@ def bulk_pantry_add(request):
return Response({"added": len(results), "results": results}, status=status.HTTP_201_CREATED)
-def _find_ingredient(name):
- """Find ingredient by name or alias (case-insensitive)."""
- # Exact name match
- try:
- return Ingredient.objects.get(name__iexact=name)
- except Ingredient.DoesNotExist:
- pass
-
- # Search aliases (JSONField contains)
- for ingredient in Ingredient.objects.all():
- if any(alias.lower() == name.lower() for alias in ingredient.aliases):
- return ingredient
-
- return None
-
-
def _is_known_staple(ingredient):
"""Check if an ingredient should be marked as a staple."""
staple_names = {"onions", "frozen chips", "salt", "black pepper", "olive oil", "paprika", "garlic powder"}
@@ -842,28 +831,6 @@ def generate_shopping_list(request):
})
-def _get_pantry_total(ingredient_id):
- """Get total quantity of an ingredient across all pantry locations."""
- total = Decimal("0")
- for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0):
- total += item.quantity
- return total
-
-
-def _get_section(ingredient):
- """Determine shopping section from ingredient tags."""
- tag_names = set(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"
-
-
# --- Recipe URL Import ---
@@ -881,6 +848,8 @@ def import_recipe_url(request):
Supports 100+ sites including BBC Good Food, Allrecipes, Jamie Oliver, etc.
"""
+ # Imported lazily so the optional recipe-scrapers/requests dependencies are
+ # only required when this endpoint is actually used.
from recipe_scrapers import scrape_html
import requests as req
@@ -950,6 +919,5 @@ def _parse_servings(yields_str):
"""Extract number from yields string like '4 servings'."""
if not yields_str:
return None
- import re
match = re.search(r'(\d+)', str(yields_str))
return int(match.group(1)) if match else None
diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py
index a1e13f3..e9e8569 100644
--- a/kitchen/views_htmx.py
+++ b/kitchen/views_htmx.py
@@ -11,8 +11,12 @@ 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, Slot, SlotOption,
- Recipe, CookLog, ShoppingListItem,
+ 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,
)
@@ -38,25 +42,6 @@ def _pantry_context():
}
-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):
@@ -164,13 +149,17 @@ def recipes_page(request):
})
-def shopping_page(request):
+def _shopping_list_items():
+ """Fetch shopping list items annotated with their display section."""
items = ShoppingListItem.objects.select_related("ingredient").all()
- # Add section info
for item in items:
item.section = _get_section_for_item(item)
+ return items
+
+
+def shopping_page(request):
return render(request, "kitchen/shopping.html", {
- "items": items,
+ "items": _shopping_list_items(),
"active_tab": "shopping",
})
@@ -305,8 +294,6 @@ def pantry_cancel_edit(request):
@require_POST
def shopping_generate(request):
"""Generate smart shopping list and return updated HTML."""
- from .views import _get_section
-
staple_count = 0
expiring_count = 0
recipe_gap_count = 0
@@ -376,12 +363,10 @@ def shopping_generate(request):
},
)
- items = ShoppingListItem.objects.select_related("ingredient").all()
- for item in items:
- item.section = _get_section_for_item(item)
+ items = _shopping_list_items()
summary = {
- "total": len(set(s["ingredient"] for s in suggestions)),
+ "total": len(seen),
"staples": staple_count,
"expiring": expiring_count,
"recipe_gaps": recipe_gap_count,
@@ -400,31 +385,27 @@ def shopping_toggle(request, 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})
+ 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()
- 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})
+ 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:
- 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 _SECTION_LABELS[_get_section(item.ingredient)]
return "Other"