from django.db import models from django.utils import timezone from django_cryptography.fields import encrypt from django.core.validators import MinValueValidator from config.extras import BREWFATHER_APP_ROOT from django.conf import settings import logging logger = logging.getLogger('django') class CustomModel(models.Model): """ Custom model class with default fields to use. """ created_date = models.DateTimeField(default=timezone.now) class Meta: abstract = True class UserProfile(CustomModel): user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE) brewfather_api_user = encrypt(models.TextField(max_length=128)) brewfather_api_key = encrypt(models.TextField(max_length=128)) def __str__(self): return self.user.username class Batch(CustomModel): brewfather_id = models.CharField(max_length=50) brewfather_num = models.IntegerField(default=1) brewfather_name = models.CharField(max_length=500, default='name') recipe = models.OneToOneField( 'Recipe', on_delete=models.CASCADE, default=1) @property def brewfather_url(self): return '{}/tabs/batches/batch/{}'.format( BREWFATHER_APP_ROOT, self.brewfather_id ) def __str__(self): # Return a string that represents the instance return 'BF #{num}: {name}'.format( name=self.brewfather_name, num=self.brewfather_num ) # ---------------------------------------------------------------------- # Recipe Stuff # ---------------------------------------------------------------------- class Unit(CustomModel): unit_types = { 'WT': 'Weight', 'VL': 'Volume', } """ Recipe to be stored with a batch.""" name = models.CharField(max_length=50) unit_type = models.CharField( max_length=3, choices=unit_types, default='WT') def __str__(self): return self.name class Supplier(CustomModel): name = models.CharField(max_length=50) def __str__(self): return self.name class CustomIngredient(CustomModel): """ Custom model class with default fields to use. """ created_date = models.DateTimeField(default=timezone.now) name = models.CharField(max_length=50) units = models.ForeignKey(Unit, on_delete=models.PROTECT) unit_cost = models.DecimalField( max_digits=6, decimal_places=2, null=True, blank=True) supplier = models.ForeignKey( Supplier, on_delete=models.PROTECT, null=True, blank=True) notes = models.TextField(max_length=500, blank=True, null=True) user_notes = models.TextField(max_length=500, blank=True, null=True) parent = models.ForeignKey( 'self', on_delete=models.SET_NULL, null=True, blank=True) class Meta: abstract = True class Recipe(CustomModel): """ Recipe to be stored with a batch.""" name = models.CharField(max_length=50) batch_recipe = models.BooleanField(null=True) recipe_json = models.TextField(null=True, blank=True) mash = models.ForeignKey( 'Mash', on_delete=models.PROTECT, null=True, blank=True) equipment = models.ForeignKey( 'EquipmentProfile', on_delete=models.PROTECT, null=True, blank=True) efficiency = models.DecimalField( max_digits=6, decimal_places=2, default=75) batch_size = models.DecimalField( max_digits=6, decimal_places=2, default=11) class Meta: verbose_name = 'Recipe' verbose_name_plural = 'Recipes' @property def fermentables(self): return [x for x in list(self.recipefermentable_set.all())] @property def fermentable_weight(self): return sum([x.quantity for x in self.fermentables]) @property def hops(self): return [x for x in list(self.recipehop_set.all())] @property def final_volume(self): return (float(self.batch_size) + self.hop_water_loss + self.net_kettle_deadspace + self.kettle_hose_loss) @property def sugar_yield(self): ferm_yield = 0 sugars = (x for x in self.fermentables if x.fermentable.fermentable_type == 3) # Is sugar for f in sugars: ferm_yield += f.quantity * (f.fermentable.potential - 1) * 1000 return float(ferm_yield) @property def mash_yield(self): mash_yield = 0 mashed = (x for x in self.fermentables if x.fermentable.fermentable_type != 3) # Is not sugar for f in mashed: mash_yield += (f.quantity * (self.efficiency / 100) * (f.fermentable.potential - 1) * 1000) return float(mash_yield) @property def original_sg(self): total_yield = self.sugar_yield + self.mash_yield return round(1 + total_yield / self.final_volume / 1000, 3) @property def pre_boil_sg(self): total_yield = self.sugar_yield + self.mash_yield return round(1 + total_yield / (self.final_volume + self.boil_off_gph) / 1000, 3) @property def hop_water_loss(self): hop_absorption = .025 # gallons per ounce return sum([float(x.quantity) * hop_absorption for x in self.hops]) @property def net_kettle_deadspace(self): if self.equipment is None: return 0 # If hops in kettle deadspace # No deadspace if its all filled with hop trub if self.equipment.hops_remain_kettle: result = self.kettle_dead_space - self.hop_water_loss return float(max(0, result)) else: return 0 @property def kettle_hose_loss(self): if self.equipment is None: return 0 return float(self.equipment.kettle_plumbing_loss) @property def kettle_dead_space(self): if self.equipment is None: return 0 return float(self.equipment.kettle_deadspace) @property def boil_off_gph(self): if self.equipment is None: return 0 return float(self.equipment.kettle_boil_rate) @property def ibu_tinseth(self): return sum([x.ibu_tinseth for x in self.hops]) @property def srm(self): color_total = sum([x.srm for x in self.fermentables]) return 1.4922*(color_total**0.6859) @property def srm_hex(self): srm_hex_lookup = { 1: 'F3F993', 2: 'F5F75C', 3: 'F6F513', 4: 'EAE615', 5: 'E0D01B', 6: 'D5BC26', 7: 'CDAA37', 8: 'C1963C', 9: 'BE8C3A', 10: 'BE823A', 11: 'C17A37', 12: 'BF7138', 13: 'BC6733', 14: 'B26033', 15: 'A85839', 16: '985336', 17: '8D4C32', 18: '7C452D', 19: '6B3A1E', 20: '5D341A', 21: '4E2A0C', 22: '4A2727', 23: '361F1B', 24: '261716', 25: '231716', 26: '19100F', 27: '16100F', 28: '120D0C', 29: '100B0A', 30: '050B0A', 0: 'C1963C' } return '#{}'.format(srm_hex_lookup[int(self.srm)]) @property def bu_gu(self): gu = (self.original_sg - 1) * 1000 try: return self.ibu_tinseth / gu except ZeroDivisionError: return 0 @property def rbr(self): # .75 needs to be calculated number... return self.bu_gu * (1 + (.75 - 0.7655)) def __str__(self): return self.name class Fermentable(CustomIngredient): categories = { 1: 'Base', 2: 'Wheat/Oat', 3: 'Crystal', 4: 'Roasted', 5: 'Acid', } types = { 1: 'Grain', 2: 'Adjunct', 3: 'Sugar' } grain_category = models.IntegerField( choices=categories, default=1) fermentable_type = models.IntegerField(choices=types, default=1) diastatic_power = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) potential = models.DecimalField(max_digits=6, decimal_places=4) protein = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) attenuation = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) lovibond = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) max_in_batch = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) moisture = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) non_fermentable = models.BooleanField(null=True, blank=True) ibu_per_unit = models.DecimalField( max_digits=6, decimal_places=4, default=0) def __str__(self): return self.name class RecipeFermentable(CustomModel): recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) fermentable = models.ForeignKey(Fermentable, on_delete=models.CASCADE) quantity = models.DecimalField(max_digits=6, decimal_places=4) @property def percent(self): return float(100 * self.quantity / self.recipe.fermentable_weight) @property def srm(self): srm_calc = (float(self.fermentable.lovibond) * float(self.quantity) / self.recipe.final_volume) return round(srm_calc, 1) class Hop(CustomIngredient): uses = { 1: 'Bittering', 2: 'Aroma', 3: 'Both', } types = { 1: 'Pellet', 2: 'Leaf', 3: 'Cryo', 4: 'CO2 Extract', } ibu = models.DecimalField(max_digits=6, decimal_places=4, default=0) use = models.IntegerField(choices=uses, default=1) hop_type = models.IntegerField(choices=types, default=1) alpha = models.DecimalField(max_digits=6, decimal_places=2, default=0) def __str__(self): return self.name class RecipeHop(CustomModel): uses = { 1: 'Boil', 2: 'Dry Hop', 3: 'Aroma (Hop Stand)', 4: 'Mash', 5: 'First Wort' } recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) hop = models.ForeignKey(Hop, on_delete=models.CASCADE) quantity = models.DecimalField(max_digits=6, decimal_places=4) time = models.IntegerField(default=60, validators=[MinValueValidator(0)]) use = models.IntegerField(choices=uses, default=1) @property def ibu_tinseth(self): type_bonus = { 1: 1.1, # Pellet 2: 1.0, # Leaf 3: 1.1, # Cryo 4: 1.4, # CO2 Extract } ibu = 0 average_wort_sg = (((self.recipe.pre_boil_sg - 1) + (self.recipe.original_sg - 1)) / 2) if self.use == 1: conc = (((float(self.hop.alpha) / 100) * float(self.quantity)) * 7490 / self.recipe.final_volume) util = ((type_bonus[self.hop.hop_type] * 1.65 * (0.000125**average_wort_sg)) * ((1-2.71828182845904**(-0.04 * self.time))/4.15)) ibu = conc * util return float(ibu) class Misc(CustomIngredient): uses = { 1: 'Mash', 2: 'Sparge', 3: 'Boil', 4: 'Flamout', 5: 'Primary', 6: 'Secondary', 7: 'Cold Crash', 8: 'Bottling', } types = { 1: 'Spice', 2: 'Fining', 3: 'Water Agent', 4: 'Herb', 5: 'Flavor', 6: 'Other', } use = models.IntegerField(choices=uses, default=1) misc_type = models.IntegerField(choices=types, default=1) water_adjustment = models.BooleanField(null=True, blank=True) def __str__(self): return self.name class RecipeMisc(CustomModel): recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) misc = models.ForeignKey(Misc, on_delete=models.CASCADE) quantity = models.DecimalField(max_digits=6, decimal_places=4) def __str__(self): return self.name class RecipeYeast(CustomModel): recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE) yeast = models.ForeignKey('yeast.Strain', on_delete=models.CASCADE) class Mash(CustomModel): name = models.CharField(max_length=50) parent = models.ForeignKey( 'self', on_delete=models.SET_NULL, null=True, blank=True) def __str__(self): return self.name class MashStep(CustomModel): step_types = { 1: 'infusion', 2: 'temperature', 3: 'decoction', } name = models.CharField(max_length=50) step_temp = models.DecimalField(max_digits=6, decimal_places=2) ramp_time = models.DecimalField(max_digits=6, decimal_places=2) step_time = models.DecimalField(max_digits=6, decimal_places=2) step_type = models.IntegerField(choices=step_types, default=1) parent_mash = models.ForeignKey(Mash, on_delete=models.CASCADE) def __str__(self): return self.name class EquipmentProfile(CustomModel): name = models.CharField(max_length=50) parent = models.ForeignKey( 'self', on_delete=models.SET_NULL, null=True, blank=True) # Water managment stuff hlt_deadspace = models.DecimalField( max_digits=6, decimal_places=2, default=0.25) mt_deadspace = models.DecimalField( max_digits=6, decimal_places=2, default=0.25) mt_capacity = models.DecimalField( max_digits=6, decimal_places=2, default=10) grain_absorption = models.DecimalField( max_digits=6, decimal_places=2, default=0.12) # gal/lb kettle_deadspace = models.DecimalField( max_digits=6, decimal_places=2, default=0.25) kettle_plumbing_loss = models.DecimalField( max_digits=6, decimal_places=2, default=0.25) kettle_boil_rate = models.DecimalField( max_digits=6, decimal_places=2, default=0.5) # gal/hr batch_volume = models.DecimalField( max_digits=6, decimal_places=2, default=5.5) leaf_hop_trub = models.DecimalField( max_digits=6, decimal_places=4, default=0.0625) pellet_hop_trub = models.DecimalField( max_digits=6, decimal_places=4, default=0.025) hops_remain_kettle = models.BooleanField(default=True) # Thermal Properties mt_initial_hear = models.DecimalField( max_digits=6, decimal_places=4, default=0.74) mt_heat_loss_hour = models.DecimalField( max_digits=6, decimal_places=4, default=2.0)