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" 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) 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}"