Files
food/kitchen/models.py
T

248 lines
8.6 KiB
Python

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