diff options
Diffstat (limited to 'kitchen')
| -rw-r--r-- | kitchen/models.py | 247 |
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}" |
