diff --git a/kitchen/admin.py b/kitchen/admin.py
index 661c931..9f924ae 100644
--- a/kitchen/admin.py
+++ b/kitchen/admin.py
@@ -37,6 +37,7 @@ class IngredientAdmin(admin.ModelAdmin):
class PantryItemAdmin(admin.ModelAdmin):
list_display = [
"ingredient",
+ "state",
"quantity",
"unit",
"location",
@@ -44,9 +45,9 @@ class PantryItemAdmin(admin.ModelAdmin):
"expiry_date",
"is_staple",
]
- list_filter = ["location", "is_staple"]
+ list_filter = ["state", "location", "is_staple"]
search_fields = ["ingredient__name"]
- list_editable = ["quantity"]
+ list_editable = ["state", "quantity"]
# --- Meta-Recipe inlines ---
diff --git a/kitchen/helpers.py b/kitchen/helpers.py
index a4be79c..7853fd7 100644
--- a/kitchen/helpers.py
+++ b/kitchen/helpers.py
@@ -25,10 +25,15 @@ def find_ingredient(name):
def get_pantry_total(ingredient_id):
- """Get total quantity of an ingredient across all pantry locations."""
+ """Total quantity of an ingredient across all locations, excluding items
+ marked 'out'. Null quantities count as 0 (presence is tracked via state,
+ not this number — see the pantry redesign in plan.md)."""
total = Decimal("0")
- for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0):
- total += item.quantity
+ for item in (
+ PantryItem.objects.filter(ingredient_id=ingredient_id)
+ .exclude(state=PantryItem.State.OUT)
+ ):
+ total += Decimal(item.quantity or 0)
return total
diff --git a/kitchen/management/commands/seed.py b/kitchen/management/commands/seed.py
index 522e178..d79adf6 100644
--- a/kitchen/management/commands/seed.py
+++ b/kitchen/management/commands/seed.py
@@ -94,14 +94,15 @@ class Command(BaseCommand):
# --- Pantry Items (from current Pantry.md as of 31 Mar 2026) ---
self.stdout.write("Creating pantry items...")
- def add_pantry(name, qty, unit, location, expiry=None, is_staple=False, notes=""):
+ def add_pantry(name, qty, unit, location, expiry=None, is_staple=False, notes="", state="in"):
# Use ingredient + location for lookup so same ingredient can exist
# in multiple locations (e.g. sausages in fridge AND freezer)
PantryItem.objects.get_or_create(
ingredient=ingredients[name],
location=location,
defaults={
- "quantity": Decimal(str(qty)),
+ "state": state,
+ "quantity": int(qty),
"unit": unit,
"expiry_date": expiry,
"is_staple": is_staple,
@@ -132,7 +133,7 @@ class Command(BaseCommand):
add_pantry(name, 1, "n/a", "cupboard", is_staple=True)
# Out of stock (for reference)
- add_pantry("eggs", 0, "items", "fridge", notes="OUT — restock")
+ add_pantry("eggs", 0, "items", "fridge", notes="OUT — restock", state="out")
add_pantry("baked beans", 1, "tins", "cupboard")
add_pantry("freezer bags", 1, "boxes", "cupboard")
diff --git a/kitchen/migrations/0003_pantryitem_state.py b/kitchen/migrations/0003_pantryitem_state.py
new file mode 100644
index 0000000..a380a39
--- /dev/null
+++ b/kitchen/migrations/0003_pantryitem_state.py
@@ -0,0 +1,42 @@
+from django.db import migrations, models
+
+
+def backfill_state(apps, schema_editor):
+ """Items at quantity 0 are 'out'; everything else is 'in' (the field default)."""
+ PantryItem = apps.get_model("kitchen", "PantryItem")
+ PantryItem.objects.filter(quantity=0).update(state="out")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("kitchen", "0002_add_rating_and_preferences"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="pantryitem",
+ name="state",
+ field=models.CharField(
+ choices=[("in", "In stock"), ("low", "Running low"), ("out", "Out")],
+ default="in",
+ max_length=10,
+ ),
+ ),
+ # Runs while quantity is still a Decimal column — 0 matches fine.
+ migrations.RunPython(backfill_state, migrations.RunPython.noop),
+ migrations.AlterField(
+ model_name="pantryitem",
+ name="quantity",
+ field=models.PositiveIntegerField(
+ blank=True,
+ help_text="Optional rough count; In/Low/Out is the primary signal",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="pantryitem",
+ name="unit",
+ field=models.CharField(blank=True, max_length=20),
+ ),
+ ]
diff --git a/kitchen/models.py b/kitchen/models.py
index ab0a720..07bf231 100644
--- a/kitchen/models.py
+++ b/kitchen/models.py
@@ -50,9 +50,18 @@ class PantryItem(models.Model):
FREEZER = "freezer", "Freezer"
CUPBOARD = "cupboard", "Cupboard"
+ class State(models.TextChoices):
+ IN = "in", "In stock"
+ LOW = "low", "Running low"
+ OUT = "out", "Out"
+
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
- quantity = models.DecimalField(max_digits=8, decimal_places=2)
- unit = models.CharField(max_length=20)
+ # In/Low/Out is the primary signal. quantity is an optional rough count.
+ state = models.CharField(max_length=10, choices=State.choices, default=State.IN)
+ quantity = models.PositiveIntegerField(
+ null=True, blank=True, help_text="Optional rough count; In/Low/Out is the primary signal"
+ )
+ unit = models.CharField(max_length=20, blank=True)
location = models.CharField(max_length=20, choices=Location.choices)
stored_date = models.DateField(auto_now_add=True)
expiry_date = models.DateField(null=True, blank=True)
@@ -65,7 +74,8 @@ class PantryItem(models.Model):
ordering = ["expiry_date", "ingredient__name"]
def __str__(self):
- return f"{self.ingredient.name} ({self.quantity} {self.unit}) [{self.location}]"
+ qty = f"{self.quantity} {self.unit}" if self.quantity is not None else self.get_state_display()
+ return f"{self.ingredient.name} ({qty}) [{self.location}]"
def clean(self):
"""Business rules for pantry items."""
diff --git a/kitchen/templates/kitchen/base.html b/kitchen/templates/kitchen/base.html
index 6bd33bf..323e985 100644
--- a/kitchen/templates/kitchen/base.html
+++ b/kitchen/templates/kitchen/base.html
@@ -398,6 +398,7 @@
td, th { padding: 0.35rem 0.5rem; font-size: 0.8rem; }
}
+ {% block extrastyle %}{% endblock %}