Add all Django models: pantry, ingredients, meta-recipes, recipes, cook log, shopping list
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user