Files
Tom FluxandClaude Opus 4.8 ce61a0a86f Phase 1: mobile-first pantry redesign with In/Low/Out state
Track presence (In/Low/Out) as the primary signal instead of exact
quantities; quantity becomes an optional integer, unit optional too
(migration 0003 backfills state from quantity: 0 -> out, else in).

- Pantry rebuilt as a phone-first card list: colored state rail,
  segmented In/Low/Out switch (server-driven HTMX), greyed out-items,
  live summary counts.
- Fast add: bottom add bar with type-ahead autocomplete, location
  picker, and quick-add chips for things you've run out of.
- Per-item menu (move / set expiry / delete); expiry is now an
  optional quiet pill, never required.
- New endpoints: pantry search + set-state; dropped the old
  edit-expiry/cancel flow and its partial.
- Recipes matcher made presence-based (have-it beats have-enough);
  state added to admin. Tests cover state, add/restock, search,
  presence matching, and page rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:55:16 +01:00

268 lines
9.5 KiB
Python

from datetime import date, timedelta
from django.db import models
from django.core.exceptions import ValidationError
class Tag(models.Model):
"""Flexible tagging for ingredients — protein, carb, veg, seasoning, etc.
An ingredient can have multiple tags."""
name = models.CharField(max_length=50, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Ingredient(models.Model):
"""Master list of known ingredients."""
name = models.CharField(max_length=100, unique=True) # canonical: "egg noodles"
default_unit = models.CharField(max_length=20) # "nests", "g", "items"
tags = models.ManyToManyField(Tag, blank=True)
shelf_life_days = models.IntegerField(
null=True, blank=True, help_text="Typical fridge life in days once opened/defrosted"
)
aliases = models.JSONField(
default=list, blank=True, help_text='Alternative names, e.g. ["noodles", "egg noodle nests"]'
)
preferences = models.TextField(
blank=True,
help_text="How Tom likes this ingredient cooked / qualitative notes. "
'E.g. "Don\'t traybake — needs to be fried. Not substantial as sole protein."',
)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class PantryItem(models.Model):
"""What's in the kitchen right now."""
class Location(models.TextChoices):
FRIDGE = "fridge", "Fridge"
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)
# 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)
is_staple = models.BooleanField(
default=False, help_text="Always restock when gone (onions, frozen chips)"
)
notes = models.TextField(blank=True)
class Meta:
ordering = ["expiry_date", "ingredient__name"]
def __str__(self):
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."""
if self.location == self.Location.FREEZER and self.expiry_date is not None:
raise ValidationError("Freezer items should not have an expiry date (frozen = no expiry).")
if self.location == self.Location.FRIDGE and self.expiry_date is None:
# Try to auto-set from ingredient shelf life
if self.ingredient_id and self.ingredient.shelf_life_days:
self.expiry_date = date.today() + timedelta(days=self.ingredient.shelf_life_days)
# Don't raise an error — some fridge items just don't have a known expiry
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
# --- Meta-Recipes ---
class MetaRecipe(models.Model):
"""A cooking template with swappable ingredient slots."""
name = models.CharField(max_length=100) # "Stir Fry"
method = models.TextField(help_text="Cooking instructions (markdown)")
prep_time_mins = models.IntegerField(null=True, blank=True)
cook_time_mins = models.IntegerField(null=True, blank=True)
default_servings = models.IntegerField(default=2)
gear_needed = models.CharField(max_length=200, blank=True) # "frying pan"
tags = models.JSONField(default=list, blank=True) # ["quick", "one-pan"]
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Slot(models.Model):
"""A swappable category in a meta-recipe (e.g. 'protein', 'carb')."""
meta_recipe = models.ForeignKey(MetaRecipe, on_delete=models.CASCADE, related_name="slots")
name = models.CharField(max_length=50) # "protein", "carb", "veg"
required = models.BooleanField(default=True)
max_choices = models.IntegerField(
default=1, help_text="Max ingredients to pick (1 for protein, 3 for veg)"
)
class Meta:
ordering = ["meta_recipe", "name"]
def __str__(self):
return f"{self.meta_recipe.name} > {self.name}"
class SlotOption(models.Model):
"""An ingredient that can fill a slot, with quantity per serving.
Example: Stir Fry > protein slot > pork mince, 250g per serving.
"""
slot = models.ForeignKey(Slot, on_delete=models.CASCADE, related_name="options")
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
quantity_per_serving = models.DecimalField(max_digits=8, decimal_places=2)
unit = models.CharField(max_length=20)
notes = models.CharField(max_length=200, blank=True) # "skin off", "defrost first"
class Meta:
ordering = ["slot", "ingredient__name"]
def __str__(self):
return f"{self.slot} > {self.ingredient.name} ({self.quantity_per_serving} {self.unit})"
class MetaRecipeBase(models.Model):
"""Ingredients always needed regardless of slot choices.
Example: Stir Fry always needs 1 onion + 2 cloves garlic per serving.
"""
meta_recipe = models.ForeignKey(
MetaRecipe, on_delete=models.CASCADE, related_name="base_ingredients"
)
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
quantity_per_serving = models.DecimalField(max_digits=8, decimal_places=2)
unit = models.CharField(max_length=20)
class Meta:
ordering = ["meta_recipe", "ingredient__name"]
def __str__(self):
return f"{self.meta_recipe.name} base: {self.ingredient.name}"
# --- Fixed Recipes ---
class Recipe(models.Model):
"""Traditional recipe with a fixed ingredient list."""
name = models.CharField(max_length=200)
method = models.TextField(help_text="Cooking instructions (markdown)")
prep_time_mins = models.IntegerField(null=True, blank=True)
cook_time_mins = models.IntegerField(null=True, blank=True)
servings = models.IntegerField(default=2)
source_url = models.URLField(blank=True)
source_book = models.CharField(max_length=200, blank=True)
gear_needed = models.CharField(max_length=200, blank=True)
tags = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class RecipeIngredient(models.Model):
"""An ingredient in a fixed recipe with quantity."""
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients")
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=8, decimal_places=2)
unit = models.CharField(max_length=20)
optional = models.BooleanField(default=False)
class Meta:
ordering = ["recipe", "ingredient__name"]
def __str__(self):
return f"{self.recipe.name}: {self.ingredient.name}"
# --- Cook Log & Shopping ---
class CookLog(models.Model):
"""What was cooked and when. Links to either a meta-recipe or fixed recipe."""
date = models.DateField(auto_now_add=True)
meta_recipe = models.ForeignKey(
MetaRecipe, null=True, blank=True, on_delete=models.SET_NULL
)
recipe = models.ForeignKey(Recipe, null=True, blank=True, on_delete=models.SET_NULL)
slot_choices = models.JSONField(
default=dict,
blank=True,
help_text='Slot choices for meta-recipe, e.g. {"protein": "pork mince", "carb": "noodles"}',
)
servings = models.IntegerField(default=2)
rating = models.IntegerField(
null=True,
blank=True,
help_text="1-5 rating. 1=awful, 3=fine, 5=great",
)
notes = models.TextField(blank=True, help_text="What worked, what didn't, what to change next time")
class Meta:
ordering = ["-date"]
def __str__(self):
recipe_name = self.meta_recipe or self.recipe or "Unknown"
return f"{self.date}: {recipe_name}"
def clean(self):
if not self.meta_recipe and not self.recipe:
raise ValidationError("Must link to either a meta-recipe or a fixed recipe.")
if self.meta_recipe and self.recipe:
raise ValidationError("Cannot link to both a meta-recipe and a fixed recipe.")
class ShoppingListItem(models.Model):
"""Items on the shopping list."""
ingredient = models.ForeignKey(
Ingredient, null=True, blank=True, on_delete=models.SET_NULL
)
name = models.CharField(max_length=100, help_text="Fallback name if not in ingredients table")
quantity = models.DecimalField(max_digits=8, decimal_places=2, null=True, blank=True)
unit = models.CharField(max_length=20, blank=True)
reason = models.CharField(max_length=200, blank=True) # "for stir fry" / "restock staple"
added_date = models.DateField(auto_now_add=True)
checked = models.BooleanField(default=False)
class Meta:
ordering = ["checked", "added_date"]
def __str__(self):
display = self.ingredient.name if self.ingredient else self.name
return f"{'✅' if self.checked else '🛒'} {display}"