diff options
Diffstat (limited to 'kitchen/views.py')
| -rw-r--r-- | kitchen/views.py | 185 |
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 --- |
