diff options
| author | Tom Flux <tom@tomflux.xyz> | 2026-06-23 21:07:18 +0100 |
|---|---|---|
| committer | Tom Flux <tom@tomflux.xyz> | 2026-06-23 21:07:18 +0100 |
| commit | d118726d3010d5b0133c7665fc20008c055f2757 (patch) | |
| tree | 175493aa0e26314998e29f760dfb83d134564787 | |
| parent | 95c04f07c6b554baa88d539d57b6bea0c1caf336 (diff) | |
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>
| -rw-r--r-- | kitchen/tests.py | 129 | ||||
| -rw-r--r-- | kitchen/urls.py | 3 | ||||
| -rw-r--r-- | kitchen/views.py | 185 |
3 files changed, 227 insertions, 90 deletions
diff --git a/kitchen/tests.py b/kitchen/tests.py index 065d530..9ce9098 100644 --- a/kitchen/tests.py +++ b/kitchen/tests.py @@ -1,8 +1,11 @@ from django.test import TestCase, Client from django.urls import reverse 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): @@ -128,3 +131,127 @@ class PageSmokeTests(_AuthedTestCase): for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"): with self.subTest(page=name): 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)) diff --git a/kitchen/urls.py b/kitchen/urls.py index ff3745f..69d2f30 100644 --- a/kitchen/urls.py +++ b/kitchen/urls.py @@ -16,6 +16,9 @@ router.register(r"cook-log", views.CookLogViewSet) router.register(r"shopping-list", views.ShoppingListItemViewSet) 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("what-can-i-cook/", views.what_can_i_cook, name="what-can-i-cook"), path("log-cook/", views.log_cook, name="log-cook"), 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 --- |
