summaryrefslogtreecommitdiff
path: root/kitchen
diff options
context:
space:
mode:
authorCaine <caine@jihakuz.xyz>2026-04-01 22:47:35 +0100
committerCaine <caine@jihakuz.xyz>2026-04-01 22:47:35 +0100
commit40cc3de3e4d017f9241eecb35cd7854e9aec48fe (patch)
tree4fdba3e4c7cf3eb0a7c6b8fa2285c45969fa0ba9 /kitchen
parente9e7a47e2e6204151383b1812d60ff5dd92977e2 (diff)
Add all Django models: pantry, ingredients, meta-recipes, recipes, cook log, shopping list
Diffstat (limited to 'kitchen')
-rw-r--r--kitchen/models.py247
1 files changed, 247 insertions, 0 deletions
diff --git a/kitchen/models.py b/kitchen/models.py
new file mode 100644
index 0000000..0f47f8f
--- /dev/null
+++ b/kitchen/models.py
@@ -0,0 +1,247 @@
+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"]'
+ )
+
+ 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"
+
+ ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE)
+ quantity = models.DecimalField(max_digits=8, decimal_places=2)
+ unit = models.CharField(max_length=20)
+ 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):
+ return f"{self.ingredient.name} ({self.quantity} {self.unit}) [{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:
+ from datetime import date, timedelta
+
+ 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)
+ notes = models.TextField(blank=True)
+
+ 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}"