Phase 3a: close the Django API gaps for the MCP server
Backend prerequisites for the FastMCP service (mcp.md §6) — no model changes, no migration. - log_cook: accepts `rating`, validates via full_clean (enforces the exactly-one-recipe rule), and returns `used_ingredients` as a suggestion set instead of mutating the pantry. Auto-deduct path and _deduct_ingredient removed — pantry state changes only via set-state after the user confirms. - New POST /api/pantry/set-state/ — set in/low/out by ingredient name (alias-aware), optional location; registered before the router so the pantry detail route doesn't capture "set-state" as a pk. - what_can_i_cook: presence-based (exclude 'out', tolerate null quantity) so the API matcher agrees with the web one. - bulk-pantry-add: restocks an existing ingredient+location row to 'in' instead of duplicating; quantity is an optional int. Tests cover all four + that pantry `state` is serialized (24 pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
50dcaca1e8
commit
7255c9302c
+128
-1
@@ -1,8 +1,11 @@
|
|||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption
|
from kitchen.models import (
|
||||||
|
Ingredient, PantryItem, MetaRecipe, Slot, SlotOption, Recipe, CookLog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthTests(TestCase):
|
class AuthTests(TestCase):
|
||||||
@@ -128,3 +131,127 @@ class PageSmokeTests(_AuthedTestCase):
|
|||||||
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"):
|
||||||
with self.subTest(page=name):
|
with self.subTest(page=name):
|
||||||
self.assertEqual(self.client.get(reverse(name)).status_code, 200)
|
self.assertEqual(self.client.get(reverse(name)).status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP-facing API (the Phase 3 gaps) ---
|
||||||
|
|
||||||
|
|
||||||
|
class _ApiTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user("api", password="pw")
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class LogCookApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.mr = MetaRecipe.objects.create(name="Stir Fry", method="fry it")
|
||||||
|
|
||||||
|
def test_accepts_rating(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/log-cook/", {"meta_recipe_id": self.mr.id, "rating": 5}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.assertEqual(CookLog.objects.get(id=r.data["cook_log_id"]).rating, 5)
|
||||||
|
|
||||||
|
def test_requires_exactly_one_recipe(self):
|
||||||
|
self.assertEqual(self.client.post("/api/log-cook/", {}, format="json").status_code, 400)
|
||||||
|
fixed = Recipe.objects.create(name="Beans", method="heat")
|
||||||
|
both = self.client.post(
|
||||||
|
"/api/log-cook/",
|
||||||
|
{"meta_recipe_id": self.mr.id, "recipe_id": fixed.id},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(both.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_used_ingredients_and_does_not_mutate_pantry(self):
|
||||||
|
noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
item = PantryItem.objects.create(ingredient=noodles, location="cupboard", state="in")
|
||||||
|
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
||||||
|
SlotOption.objects.create(slot=slot, ingredient=noodles, quantity_per_serving=2, unit="nests")
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/log-cook/",
|
||||||
|
{"meta_recipe_id": self.mr.id, "slot_choices": {"carb": "noodles"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
self.assertIn("noodles", [u["ingredient"] for u in r.data["used_ingredients"]])
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.state, "in") # untouched — suggestions only
|
||||||
|
|
||||||
|
|
||||||
|
class SetStateApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
self.item = PantryItem.objects.create(
|
||||||
|
ingredient=self.noodles, location="cupboard", state="in"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_state_by_name(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "out"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.item.refresh_from_db()
|
||||||
|
self.assertEqual(self.item.state, "out")
|
||||||
|
|
||||||
|
def test_invalid_state_rejected(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "noodles", "state": "bogus"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
|
||||||
|
def test_unknown_ingredient_404(self):
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/pantry/set-state/", {"ingredient": "saffron", "state": "in"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAddApiTests(_ApiTestCase):
|
||||||
|
def test_restocks_out_item_without_duplicating(self):
|
||||||
|
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
||||||
|
PantryItem.objects.create(ingredient=eggs, location="fridge", state="out")
|
||||||
|
r = self.client.post(
|
||||||
|
"/api/bulk-pantry-add/",
|
||||||
|
{"items": [{"ingredient_name": "eggs", "location": "fridge"}]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 201)
|
||||||
|
rows = PantryItem.objects.filter(ingredient=eggs, location="fridge")
|
||||||
|
self.assertEqual(rows.count(), 1)
|
||||||
|
self.assertEqual(rows.first().state, "in")
|
||||||
|
|
||||||
|
|
||||||
|
class WhatCanICookApiTests(_ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests")
|
||||||
|
self.mr = MetaRecipe.objects.create(name="Noodles", method="boil")
|
||||||
|
slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True)
|
||||||
|
SlotOption.objects.create(slot=slot, ingredient=self.noodles, quantity_per_serving=2, unit="nests")
|
||||||
|
|
||||||
|
def _status(self, resp):
|
||||||
|
return next(x for x in resp.data["results"] if x["name"] == "Noodles")["status"]
|
||||||
|
|
||||||
|
def test_present_with_no_quantity_is_ready(self):
|
||||||
|
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="in")
|
||||||
|
r = self.client.get("/api/what-can-i-cook/")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self._status(r), "ready")
|
||||||
|
|
||||||
|
def test_out_item_makes_recipe_missing(self):
|
||||||
|
PantryItem.objects.create(ingredient=self.noodles, location="cupboard", state="out")
|
||||||
|
r = self.client.get("/api/what-can-i-cook/")
|
||||||
|
self.assertEqual(self._status(r), "missing")
|
||||||
|
|
||||||
|
|
||||||
|
class PantrySerializerApiTests(_ApiTestCase):
|
||||||
|
def test_state_is_serialized(self):
|
||||||
|
eggs = Ingredient.objects.create(name="eggs", default_unit="items")
|
||||||
|
PantryItem.objects.create(ingredient=eggs, location="fridge", state="low")
|
||||||
|
r = self.client.get("/api/pantry/")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertTrue(any(row.get("state") == "low" for row in r.data))
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ router.register(r"cook-log", views.CookLogViewSet)
|
|||||||
router.register(r"shopping-list", views.ShoppingListItemViewSet)
|
router.register(r"shopping-list", views.ShoppingListItemViewSet)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Must precede the router include — otherwise the PantryItem detail route
|
||||||
|
# (pantry/<pk>/) captures "set-state" as a pk.
|
||||||
|
path("pantry/set-state/", views.set_pantry_state, name="set-pantry-state"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
path("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"),
|
||||||
path("log-cook/", views.log_cook, name="log-cook"),
|
path("log-cook/", views.log_cook, name="log-cook"),
|
||||||
|
|||||||
+95
-88
@@ -2,6 +2,7 @@ import re
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import api_view, permission_classes, action
|
from rest_framework.decorators import api_view, permission_classes, action
|
||||||
from rest_framework.permissions import IsAuthenticated
|
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}
|
# Build pantry lookup: ingredient_id -> list of {quantity, unit, location, expiry}
|
||||||
today = date.today()
|
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").exclude(
|
||||||
|
state=PantryItem.State.OUT
|
||||||
|
):
|
||||||
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
|
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):
|
def get_pantry_total(ingredient_id):
|
||||||
"""Total quantity available across all locations."""
|
"""Presence-based availability: having the ingredient (any non-'out'
|
||||||
if ingredient_id not in pantry:
|
item) satisfies a slot regardless of amount. Quantity may be None."""
|
||||||
return Decimal("0")
|
return Decimal("Infinity") if ingredient_id in pantry else Decimal("0")
|
||||||
return sum(p["quantity"] for p in pantry[ingredient_id])
|
|
||||||
|
|
||||||
def get_pantry_warnings(ingredient_id):
|
def get_pantry_warnings(ingredient_id):
|
||||||
"""Get expiry warnings for an ingredient."""
|
"""Get expiry warnings for an ingredient."""
|
||||||
@@ -403,7 +405,7 @@ def what_can_i_cook(request):
|
|||||||
option_info = {
|
option_info = {
|
||||||
"ingredient": option.ingredient.name,
|
"ingredient": option.ingredient.name,
|
||||||
"needed": f"{needed} {option.unit}",
|
"needed": f"{needed} {option.unit}",
|
||||||
"have": f"{available} {option.unit}",
|
"have": "in stock" if available >= needed else "none",
|
||||||
"notes": option.notes,
|
"notes": option.notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,108 +484,65 @@ def what_can_i_cook(request):
|
|||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def log_cook(request):
|
def log_cook(request):
|
||||||
"""
|
"""
|
||||||
Log a meal that was cooked. Optionally deducts ingredients from pantry.
|
Log a meal that was cooked.
|
||||||
|
|
||||||
Body:
|
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"},
|
"slot_choices": {"protein": "pork mince", "carb": "egg noodles"},
|
||||||
"servings": 2,
|
"servings": 2,
|
||||||
"notes": "added extra garlic",
|
"rating": 4, // optional (1-5)
|
||||||
"deduct": true // auto-deduct from pantry
|
"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")
|
meta_recipe_id = request.data.get("meta_recipe_id")
|
||||||
recipe_id = request.data.get("recipe_id")
|
recipe_id = request.data.get("recipe_id")
|
||||||
slot_choices = request.data.get("slot_choices", {})
|
slot_choices = request.data.get("slot_choices", {})
|
||||||
servings = int(request.data.get("servings", 2))
|
servings = int(request.data.get("servings", 2))
|
||||||
notes = request.data.get("notes", "")
|
notes = request.data.get("notes", "")
|
||||||
deduct = request.data.get("deduct", False)
|
rating = request.data.get("rating")
|
||||||
|
if rating is not None:
|
||||||
|
rating = int(rating)
|
||||||
|
|
||||||
if not meta_recipe_id and not recipe_id:
|
log = CookLog(
|
||||||
return Response(
|
|
||||||
{"error": "Must provide meta_recipe_id or recipe_id"},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create cook log
|
|
||||||
log = CookLog.objects.create(
|
|
||||||
meta_recipe_id=meta_recipe_id,
|
meta_recipe_id=meta_recipe_id,
|
||||||
recipe_id=recipe_id,
|
recipe_id=recipe_id,
|
||||||
slot_choices=slot_choices,
|
slot_choices=slot_choices,
|
||||||
servings=servings,
|
servings=servings,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
rating=rating,
|
||||||
)
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
deducted = []
|
# Suggest (do NOT apply) which ingredients were used.
|
||||||
|
used = []
|
||||||
if deduct and meta_recipe_id:
|
if meta_recipe_id:
|
||||||
meta = MetaRecipe.objects.prefetch_related(
|
meta = MetaRecipe.objects.prefetch_related(
|
||||||
"slots__options__ingredient", "base_ingredients__ingredient"
|
"slots__options__ingredient", "base_ingredients__ingredient"
|
||||||
).get(id=meta_recipe_id)
|
).get(id=meta_recipe_id)
|
||||||
|
|
||||||
# Deduct base ingredients
|
|
||||||
for base in meta.base_ingredients.all():
|
for base in meta.base_ingredients.all():
|
||||||
amount = base.quantity_per_serving * servings
|
used.append({"ingredient": base.ingredient.name, "via": "base"})
|
||||||
deducted += _deduct_ingredient(base.ingredient, amount, base.unit)
|
|
||||||
|
|
||||||
# Deduct slot choices
|
|
||||||
for slot_name, ingredient_name in slot_choices.items():
|
for slot_name, ingredient_name in slot_choices.items():
|
||||||
try:
|
used.append({"ingredient": ingredient_name, "via": f"slot:{slot_name}"})
|
||||||
slot = meta.slots.get(name=slot_name)
|
elif recipe_id:
|
||||||
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:
|
|
||||||
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
|
recipe = Recipe.objects.prefetch_related("ingredients__ingredient").get(id=recipe_id)
|
||||||
for ri in recipe.ingredients.all():
|
for ri in recipe.ingredients.all():
|
||||||
amount = ri.quantity * (servings / recipe.servings)
|
used.append({"ingredient": ri.ingredient.name, "via": "ingredient"})
|
||||||
deducted += _deduct_ingredient(ri.ingredient, amount, ri.unit)
|
|
||||||
|
|
||||||
return Response({
|
return Response(
|
||||||
"cook_log_id": log.id,
|
{"cook_log_id": log.id, "used_ingredients": used},
|
||||||
"deducted": deducted,
|
status=status.HTTP_201_CREATED,
|
||||||
}, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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) ---
|
# --- Bulk Pantry Add (Photo Intake) ---
|
||||||
|
|
||||||
@@ -615,7 +574,8 @@ def bulk_pantry_add(request):
|
|||||||
|
|
||||||
for item_data in items:
|
for item_data in items:
|
||||||
name = item_data.get("ingredient_name", "").strip()
|
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")
|
unit = item_data.get("unit", "items")
|
||||||
location = item_data.get("location", "fridge")
|
location = item_data.get("location", "fridge")
|
||||||
expiry_days = item_data.get("expiry_days") # optional override
|
expiry_days = item_data.get("expiry_days") # optional override
|
||||||
@@ -650,27 +610,30 @@ def bulk_pantry_add(request):
|
|||||||
elif ingredient.shelf_life_days:
|
elif ingredient.shelf_life_days:
|
||||||
expiry_date = date.today() + timedelta(days=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(
|
existing = PantryItem.objects.filter(
|
||||||
ingredient=ingredient, location=location, quantity__gt=0
|
ingredient=ingredient, location=location
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing:
|
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:
|
if expiry_date:
|
||||||
existing.expiry_date = expiry_date # refresh expiry with new stock
|
existing.expiry_date = expiry_date # refresh expiry with new stock
|
||||||
existing.save()
|
existing.save()
|
||||||
results.append({
|
results.append({
|
||||||
"ingredient": ingredient.name,
|
"ingredient": ingredient.name,
|
||||||
"action": "added_to_existing",
|
"action": "restocked",
|
||||||
"added": str(quantity),
|
"new_total": str(existing.quantity) if existing.quantity is not None else None,
|
||||||
"new_total": str(existing.quantity),
|
"unit": existing.unit,
|
||||||
"unit": unit,
|
|
||||||
"location": location,
|
"location": location,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
PantryItem.objects.create(
|
PantryItem.objects.create(
|
||||||
ingredient=ingredient,
|
ingredient=ingredient,
|
||||||
|
state=PantryItem.State.IN,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
unit=unit,
|
unit=unit,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -680,7 +643,7 @@ def bulk_pantry_add(request):
|
|||||||
results.append({
|
results.append({
|
||||||
"ingredient": ingredient.name,
|
"ingredient": ingredient.name,
|
||||||
"action": "created",
|
"action": "created",
|
||||||
"quantity": str(quantity),
|
"quantity": str(quantity) if quantity is not None else None,
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
"location": location,
|
"location": location,
|
||||||
"expiry_date": str(expiry_date) if expiry_date else None,
|
"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
|
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 ---
|
# --- Smart Shopping List Generation ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user