summaryrefslogtreecommitdiff
path: root/kitchen/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'kitchen/views.py')
-rw-r--r--kitchen/views.py185
1 files changed, 96 insertions, 89 deletions
diff --git a/kitchen/views.py b/kitchen/views.py
index 939ef2b..5fdf041 100644
--- a/kitchen/views.py
+++ b/kitchen/views.py
@@ -2,6 +2,7 @@ import re
from decimal import Decimal
from datetime import date, timedelta
+from django.core.exceptions import ValidationError
from rest_framework import viewsets, status
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated
@@ -310,7 +311,9 @@ def what_can_i_cook(request):
# 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):
+ for item in PantryItem.objects.select_related("ingredient").exclude(
+ state=PantryItem.State.OUT
+ ):
if item.ingredient_id not in pantry:
pantry[item.ingredient_id] = []
is_expired = item.expiry_date and item.expiry_date < today
@@ -328,10 +331,9 @@ def what_can_i_cook(request):
})
def get_pantry_total(ingredient_id):
- """Total quantity available across all locations."""
- if ingredient_id not in pantry:
- return Decimal("0")
- return sum(p["quantity"] for p in pantry[ingredient_id])
+ """Presence-based availability: having the ingredient (any non-'out'
+ item) satisfies a slot regardless of amount. Quantity may be None."""
+ return Decimal("Infinity") if ingredient_id in pantry else Decimal("0")
def get_pantry_warnings(ingredient_id):
"""Get expiry warnings for an ingredient."""
@@ -403,7 +405,7 @@ def what_can_i_cook(request):
option_info = {
"ingredient": option.ingredient.name,
"needed": f"{needed} {option.unit}",
- "have": f"{available} {option.unit}",
+ "have": "in stock" if available >= needed else "none",
"notes": option.notes,
}
@@ -482,108 +484,65 @@ def what_can_i_cook(request):
@permission_classes([IsAuthenticated])
def log_cook(request):
"""
- Log a meal that was cooked. Optionally deducts ingredients from pantry.
-
+ Log a meal that was cooked.
+
Body:
{
- "meta_recipe_id": 1, // or "recipe_id": 1
+ "meta_recipe_id": 1, // or "recipe_id": 1 (exactly one)
"slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
"servings": 2,
- "notes": "added extra garlic",
- "deduct": true // auto-deduct from pantry
+ "rating": 4, // optional (1-5)
+ "notes": "added extra garlic"
}
+
+ Does NOT change pantry state. Returns `used_ingredients` so the caller can
+ *suggest* marking them low/out — applied separately, after the user
+ confirms, via /api/pantry/set-state/.
"""
meta_recipe_id = request.data.get("meta_recipe_id")
recipe_id = request.data.get("recipe_id")
slot_choices = request.data.get("slot_choices", {})
servings = int(request.data.get("servings", 2))
notes = request.data.get("notes", "")
- deduct = request.data.get("deduct", False)
-
- if not meta_recipe_id and not recipe_id:
- return Response(
- {"error": "Must provide meta_recipe_id or recipe_id"},
- status=status.HTTP_400_BAD_REQUEST,
- )
+ rating = request.data.get("rating")
+ if rating is not None:
+ rating = int(rating)
- # Create cook log
- log = CookLog.objects.create(
+ log = CookLog(
meta_recipe_id=meta_recipe_id,
recipe_id=recipe_id,
slot_choices=slot_choices,
servings=servings,
notes=notes,
+ rating=rating,
)
-
- deducted = []
-
- if deduct and meta_recipe_id:
+ try:
+ # Enforces the "exactly one of meta_recipe / recipe" rule (CookLog.clean)
+ log.full_clean()
+ except ValidationError as e:
+ return Response({"errors": e.message_dict}, status=status.HTTP_400_BAD_REQUEST)
+ log.save()
+
+ # Suggest (do NOT apply) which ingredients were used.
+ used = []
+ if meta_recipe_id:
meta = MetaRecipe.objects.prefetch_related(
"slots__options__ingredient", "base_ingredients__ingredient"
).get(id=meta_recipe_id)
-
- # Deduct base ingredients
for base in meta.base_ingredients.all():
- amount = base.quantity_per_serving * servings
- deducted += _deduct_ingredient(base.ingredient, amount, base.unit)
-
- # Deduct slot choices
+ used.append({"ingredient": base.ingredient.name, "via": "base"})
for slot_name, ingredient_name in slot_choices.items():
- try:
- slot = meta.slots.get(name=slot_name)
- option = slot.options.get(ingredient__name=ingredient_name)
- amount = option.quantity_per_serving * servings
- deducted += _deduct_ingredient(option.ingredient, amount, option.unit)
- except (Slot.DoesNotExist, SlotOption.DoesNotExist):
- pass
-
- elif deduct and recipe_id:
+ used.append({"ingredient": ingredient_name, "via": f"slot:{slot_name}"})
+ elif recipe_id:
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
for ri in recipe.ingredients.all():
- amount = ri.quantity * (servings / recipe.servings)
- deducted += _deduct_ingredient(ri.ingredient, amount, ri.unit)
-
- return Response({
- "cook_log_id": log.id,
- "deducted": deducted,
- }, status=status.HTTP_201_CREATED)
-
+ used.append({"ingredient": ri.ingredient.name, "via": "ingredient"})
-def _deduct_ingredient(ingredient, amount, unit):
- """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 = []
-
- # Earliest expiry first, regardless of location (fridge/cupboard/freezer).
- items = PantryItem.objects.filter(
- ingredient=ingredient, quantity__gt=0
- ).order_by(
- "expiry_date",
+ return Response(
+ {"cook_log_id": log.id, "used_ingredients": used},
+ status=status.HTTP_201_CREATED,
)
- for item in items:
- if remaining <= 0:
- break
-
- take = min(item.quantity, remaining)
- item.quantity -= take
- item.save(update_fields=["quantity"])
- remaining -= take
-
- deducted.append({
- "ingredient": ingredient.name,
- "amount": str(take),
- "unit": unit,
- "from": item.location,
- "remaining_in_pantry": str(item.quantity),
- })
-
- return deducted
-
# --- Bulk Pantry Add (Photo Intake) ---
@@ -615,7 +574,8 @@ def bulk_pantry_add(request):
for item_data in items:
name = item_data.get("ingredient_name", "").strip()
- quantity = Decimal(str(item_data.get("quantity", 0)))
+ qty_raw = item_data.get("quantity")
+ quantity = int(qty_raw) if qty_raw not in (None, "") else None
unit = item_data.get("unit", "items")
location = item_data.get("location", "fridge")
expiry_days = item_data.get("expiry_days") # optional override
@@ -650,27 +610,30 @@ def bulk_pantry_add(request):
elif ingredient.shelf_life_days:
expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days)
- # Check if item already exists in this location
+ # Restock an existing row for this ingredient+location (back to 'in')
+ # rather than creating a duplicate.
existing = PantryItem.objects.filter(
- ingredient=ingredient, location=location, quantity__gt=0
+ ingredient=ingredient, location=location
).first()
if existing:
- existing.quantity += quantity
+ 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 # refresh expiry with new stock
existing.save()
results.append({
"ingredient": ingredient.name,
- "action": "added_to_existing",
- "added": str(quantity),
- "new_total": str(existing.quantity),
- "unit": unit,
+ "action": "restocked",
+ "new_total": str(existing.quantity) if existing.quantity is not None else None,
+ "unit": existing.unit,
"location": location,
})
else:
PantryItem.objects.create(
ingredient=ingredient,
+ state=PantryItem.State.IN,
quantity=quantity,
unit=unit,
location=location,
@@ -680,7 +643,7 @@ def bulk_pantry_add(request):
results.append({
"ingredient": ingredient.name,
"action": "created",
- "quantity": str(quantity),
+ "quantity": str(quantity) if quantity is not None else None,
"unit": unit,
"location": location,
"expiry_date": str(expiry_date) if expiry_date else None,
@@ -695,6 +658,50 @@ def _is_known_staple(ingredient):
return ingredient.name.lower() in staple_names
+@api_view(["POST"])
+@permission_classes([IsAuthenticated])
+def set_pantry_state(request):
+ """
+ Set an ingredient's pantry state (in/low/out) by name — the conversational
+ update path ("used the last of the noodles" -> out).
+
+ Body: {"ingredient": "egg noodles", "state": "out", "location": "fridge"}
+ `location` is optional; without it the first matching pantry row is used.
+ If no pantry row exists, one is created so the state can be recorded.
+ """
+ name = (request.data.get("ingredient") or "").strip()
+ new_state = request.data.get("state", "")
+ location = request.data.get("location")
+
+ if new_state not in PantryItem.State.values:
+ return Response(
+ {"error": f"Invalid state '{new_state}'. Use one of {list(PantryItem.State.values)}."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ ingredient = _find_ingredient(name)
+ if not ingredient:
+ return Response(
+ {"error": f"No ingredient matching '{name}'."},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ qs = PantryItem.objects.filter(ingredient=ingredient)
+ if location:
+ qs = qs.filter(location=location)
+ item = qs.first()
+ if item is None:
+ item = PantryItem(
+ ingredient=ingredient,
+ location=location or PantryItem.Location.FRIDGE,
+ unit=ingredient.default_unit or "",
+ )
+
+ item.state = new_state
+ item.save()
+ return Response(PantryItemSerializer(item).data, status=status.HTTP_200_OK)
+
+
# --- Smart Shopping List Generation ---