From cdbfabf0ea855df854b2357dc124df03f18d0514 Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Mon, 22 Jun 2026 22:50:48 +0100 Subject: Simplify from claude --- kitchen/helpers.py | 47 +++++++++++++++++++++++++++++++ kitchen/models.py | 4 +-- kitchen/serializers.py | 1 - kitchen/views.py | 70 +++++++++++++--------------------------------- kitchen/views_htmx.py | 75 +++++++++++++++++++------------------------------- 5 files changed, 96 insertions(+), 101 deletions(-) create mode 100644 kitchen/helpers.py (limited to 'kitchen') 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" -- cgit v1.2.3