Simplify from claude
This commit is contained in:
@@ -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"
|
||||||
+2
-2
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
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:
|
if self.location == self.Location.FRIDGE and self.expiry_date is None:
|
||||||
# Try to auto-set from ingredient shelf life
|
# Try to auto-set from ingredient shelf life
|
||||||
if self.ingredient_id and self.ingredient.shelf_life_days:
|
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)
|
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
|
# Don't raise an error — some fridge items just don't have a known expiry
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class IngredientSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Ingredient
|
model = Ingredient
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
# preferences field included via __all__
|
|
||||||
|
|
||||||
|
|
||||||
class PantryItemSerializer(serializers.ModelSerializer):
|
class PantryItemSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
+19
-51
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
@@ -33,6 +34,11 @@ from .serializers import (
|
|||||||
CookLogSerializer,
|
CookLogSerializer,
|
||||||
ShoppingListItemSerializer,
|
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 ---
|
# --- Standard CRUD ViewSets ---
|
||||||
@@ -48,20 +54,16 @@ class IngredientViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = Ingredient.objects.all()
|
queryset = Ingredient.objects.all()
|
||||||
serializer_class = IngredientSerializer
|
serializer_class = IngredientSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
search_fields = ["name", "aliases"]
|
|
||||||
|
|
||||||
|
|
||||||
class PantryItemViewSet(viewsets.ModelViewSet):
|
class PantryItemViewSet(viewsets.ModelViewSet):
|
||||||
queryset = PantryItem.objects.select_related("ingredient").all()
|
queryset = PantryItem.objects.select_related("ingredient").all()
|
||||||
serializer_class = PantryItemSerializer
|
serializer_class = PantryItemSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ["location", "is_staple"]
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
@action(detail=False, methods=["get"])
|
||||||
def expiring(self, request):
|
def expiring(self, request):
|
||||||
"""Items expiring within 3 days."""
|
"""Items expiring within 3 days."""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
cutoff = date.today() + timedelta(days=3)
|
cutoff = date.today() + timedelta(days=3)
|
||||||
items = self.queryset.filter(
|
items = self.queryset.filter(
|
||||||
expiry_date__isnull=False,
|
expiry_date__isnull=False,
|
||||||
@@ -127,7 +129,6 @@ class ShoppingListItemViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = ShoppingListItem.objects.select_related("ingredient").all()
|
queryset = ShoppingListItem.objects.select_related("ingredient").all()
|
||||||
serializer_class = ShoppingListItemSerializer
|
serializer_class = ShoppingListItemSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
filterset_fields = ["checked"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Create/Update Meta-Recipe (nested) ---
|
# --- Create/Update Meta-Recipe (nested) ---
|
||||||
@@ -307,20 +308,22 @@ def what_can_i_cook(request):
|
|||||||
servings = int(request.query_params.get("servings", 2))
|
servings = int(request.query_params.get("servings", 2))
|
||||||
|
|
||||||
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
|
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
|
||||||
|
today = date.today()
|
||||||
pantry = {}
|
pantry = {}
|
||||||
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
|
for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0):
|
||||||
if item.ingredient_id not in pantry:
|
if item.ingredient_id not in pantry:
|
||||||
pantry[item.ingredient_id] = []
|
pantry[item.ingredient_id] = []
|
||||||
|
is_expired = item.expiry_date and item.expiry_date < today
|
||||||
pantry[item.ingredient_id].append({
|
pantry[item.ingredient_id].append({
|
||||||
"quantity": item.quantity,
|
"quantity": item.quantity,
|
||||||
"unit": item.unit,
|
"unit": item.unit,
|
||||||
"location": item.location,
|
"location": item.location,
|
||||||
"expiry_date": item.expiry_date,
|
"expiry_date": item.expiry_date,
|
||||||
"is_expired": item.expiry_date and item.expiry_date < date.today(),
|
"is_expired": is_expired,
|
||||||
"expiring_soon": (
|
"expiring_soon": (
|
||||||
item.expiry_date
|
item.expiry_date
|
||||||
and not (item.expiry_date < date.today())
|
and not is_expired
|
||||||
and (item.expiry_date - date.today()).days <= 2
|
and (item.expiry_date - today).days <= 2
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -547,16 +550,18 @@ def log_cook(request):
|
|||||||
|
|
||||||
|
|
||||||
def _deduct_ingredient(ingredient, amount, unit):
|
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))
|
remaining = Decimal(str(amount))
|
||||||
deducted = []
|
deducted = []
|
||||||
|
|
||||||
# Prefer fridge (expires first), then cupboard, then freezer
|
# Earliest expiry first, regardless of location (fridge/cupboard/freezer).
|
||||||
items = PantryItem.objects.filter(
|
items = PantryItem.objects.filter(
|
||||||
ingredient=ingredient, quantity__gt=0
|
ingredient=ingredient, quantity__gt=0
|
||||||
).order_by(
|
).order_by(
|
||||||
# Fridge first, then cupboard, then freezer
|
|
||||||
# Within same location, earliest expiry first
|
|
||||||
"expiry_date",
|
"expiry_date",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -684,22 +689,6 @@ def bulk_pantry_add(request):
|
|||||||
return Response({"added": len(results), "results": results}, status=status.HTTP_201_CREATED)
|
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):
|
def _is_known_staple(ingredient):
|
||||||
"""Check if an ingredient should be marked as a staple."""
|
"""Check if an ingredient should be marked as a staple."""
|
||||||
staple_names = {"onions", "frozen chips", "salt", "black pepper", "olive oil", "paprika", "garlic powder"}
|
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 ---
|
# --- Recipe URL Import ---
|
||||||
|
|
||||||
|
|
||||||
@@ -881,6 +848,8 @@ def import_recipe_url(request):
|
|||||||
|
|
||||||
Supports 100+ sites including BBC Good Food, Allrecipes, Jamie Oliver, etc.
|
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
|
from recipe_scrapers import scrape_html
|
||||||
import requests as req
|
import requests as req
|
||||||
|
|
||||||
@@ -950,6 +919,5 @@ def _parse_servings(yields_str):
|
|||||||
"""Extract number from yields string like '4 servings'."""
|
"""Extract number from yields string like '4 servings'."""
|
||||||
if not yields_str:
|
if not yields_str:
|
||||||
return None
|
return None
|
||||||
import re
|
|
||||||
match = re.search(r'(\d+)', str(yields_str))
|
match = re.search(r'(\d+)', str(yields_str))
|
||||||
return int(match.group(1)) if match else None
|
return int(match.group(1)) if match else None
|
||||||
|
|||||||
+28
-47
@@ -11,8 +11,12 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.decorators.http import require_POST, require_http_methods
|
from django.views.decorators.http import require_POST, require_http_methods
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption,
|
Ingredient, PantryItem, MetaRecipe, CookLog, ShoppingListItem,
|
||||||
Recipe, 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 ---
|
# --- Page Views ---
|
||||||
|
|
||||||
def pantry_page(request):
|
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()
|
items = ShoppingListItem.objects.select_related("ingredient").all()
|
||||||
# Add section info
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item.section = _get_section_for_item(item)
|
item.section = _get_section_for_item(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def shopping_page(request):
|
||||||
return render(request, "kitchen/shopping.html", {
|
return render(request, "kitchen/shopping.html", {
|
||||||
"items": items,
|
"items": _shopping_list_items(),
|
||||||
"active_tab": "shopping",
|
"active_tab": "shopping",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,8 +294,6 @@ def pantry_cancel_edit(request):
|
|||||||
@require_POST
|
@require_POST
|
||||||
def shopping_generate(request):
|
def shopping_generate(request):
|
||||||
"""Generate smart shopping list and return updated HTML."""
|
"""Generate smart shopping list and return updated HTML."""
|
||||||
from .views import _get_section
|
|
||||||
|
|
||||||
staple_count = 0
|
staple_count = 0
|
||||||
expiring_count = 0
|
expiring_count = 0
|
||||||
recipe_gap_count = 0
|
recipe_gap_count = 0
|
||||||
@@ -376,12 +363,10 @@ def shopping_generate(request):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
items = ShoppingListItem.objects.select_related("ingredient").all()
|
items = _shopping_list_items()
|
||||||
for item in items:
|
|
||||||
item.section = _get_section_for_item(item)
|
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
"total": len(set(s["ingredient"] for s in suggestions)),
|
"total": len(seen),
|
||||||
"staples": staple_count,
|
"staples": staple_count,
|
||||||
"expiring": expiring_count,
|
"expiring": expiring_count,
|
||||||
"recipe_gaps": recipe_gap_count,
|
"recipe_gaps": recipe_gap_count,
|
||||||
@@ -400,31 +385,27 @@ def shopping_toggle(request, item_id):
|
|||||||
item.checked = not item.checked
|
item.checked = not item.checked
|
||||||
item.save(update_fields=["checked"])
|
item.save(update_fields=["checked"])
|
||||||
|
|
||||||
items = ShoppingListItem.objects.select_related("ingredient").all()
|
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
||||||
for i in items:
|
|
||||||
i.section = _get_section_for_item(i)
|
|
||||||
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_POST
|
@require_POST
|
||||||
def shopping_clear(request):
|
def shopping_clear(request):
|
||||||
ShoppingListItem.objects.filter(checked=True).delete()
|
ShoppingListItem.objects.filter(checked=True).delete()
|
||||||
items = ShoppingListItem.objects.select_related("ingredient").all()
|
return render(request, "kitchen/partials/shopping_list.html", {"items": _shopping_list_items()})
|
||||||
for item in items:
|
|
||||||
item.section = _get_section_for_item(item)
|
|
||||||
return render(request, "kitchen/partials/shopping_list.html", {"items": items})
|
_SECTION_LABELS = {
|
||||||
|
"protein": "Protein",
|
||||||
|
"veg": "Veg",
|
||||||
|
"carbs": "Carbs",
|
||||||
|
"dairy": "Dairy",
|
||||||
|
"other": "Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_section_for_item(item):
|
def _get_section_for_item(item):
|
||||||
|
"""Title-cased, template-facing section label for a ShoppingListItem."""
|
||||||
if item.ingredient:
|
if item.ingredient:
|
||||||
tag_names = set(item.ingredient.tags.values_list("name", flat=True))
|
return _SECTION_LABELS[_get_section(item.ingredient)]
|
||||||
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"
|
return "Other"
|
||||||
|
|||||||
Reference in New Issue
Block a user