From e0ac1f96dbdb93bdadbd7d1fa1c83edef97cd331 Mon Sep 17 00:00:00 2001 From: Chris Giacofei Date: Mon, 17 Jun 2024 13:56:13 -0400 Subject: [PATCH] Create basic recipe stuff. --- beer/admin.py | 37 ++- beer/extras.py | 40 +++- ...le_hop_mash_misc_supplier_unit_and_more.py | 217 ++++++++++++++++++ beer/models.py | 155 ++++++++++++- 4 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 beer/migrations/0004_fermentable_hop_mash_misc_supplier_unit_and_more.py diff --git a/beer/admin.py b/beer/admin.py index 488088e..62d2d77 100644 --- a/beer/admin.py +++ b/beer/admin.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.html import format_html from django.apps import apps -from beer.models import Batch, BatchRecipe, BatchRecipe -from yeast.models import Yeast +from beer.models import Batch, BatchRecipe, Mash, MashStep, RecipeFermentable, RecipeHop, RecipeMisc, RecipeYeast +from yeast.models import Yeast, Strain from config.extras import BREWFATHER_APP_ROOT @@ -12,9 +12,31 @@ class SampleInline(admin.TabularInline): model = Yeast extra = 0 +class FermentableInline(admin.TabularInline): + model = RecipeFermentable + extra = 1 + +class HopInline(admin.TabularInline): + model = RecipeHop + extra = 1 + +class MiscInline(admin.TabularInline): + model = RecipeMisc + extra = 1 + +class StrainInline(admin.TabularInline): + model = RecipeYeast + extra = 1 + @admin.register(BatchRecipe) class BatchRecipeAdmin(admin.ModelAdmin): list_display = ['name'] + inlines = [ + FermentableInline, + HopInline, + MiscInline, + StrainInline + ] @admin.register(Batch) class BeerBatchAdmin(admin.ModelAdmin): @@ -30,6 +52,17 @@ class BeerBatchAdmin(admin.ModelAdmin): return format_html("Brewfather App: {batch_id}", batch_id=bf_id, root=BREWFATHER_APP_ROOT) +class MashStepInline(admin.TabularInline): + model = MashStep + extra = 1 + +@admin.register(Mash) +class MashAdmin(admin.ModelAdmin): + list_display = ['name', ] + inlines = [ + MashStepInline, + ] + app = apps.get_app_config('beer') for model_name, model in app.models.items(): diff --git a/beer/extras.py b/beer/extras.py index ab8f7d3..35f70a6 100644 --- a/beer/extras.py +++ b/beer/extras.py @@ -9,9 +9,45 @@ RECIPE_URL = 'https://api.brewfather.app/v2/recipes' BATCH_URL = 'https://api.brewfather.app/v2/batches' PULL_LIMIT = 50 +BREWFATHER_CONVERT_LOOKUP = { # local_name: brewfather_name + 'all': { + 'name': 'name', + 'unit_cost': 'costPerAmount', + 'supplier': 'supplier', + 'notes': 'notes', + 'user_notes': 'userNotes', + }, + 'fermentable': { + 'grain_category': 'grainCategory', + 'fermentable_type': 'type', + 'diastatic_power': 'diastaticPower', + 'potential': 'potential', + 'protein': 'protein', + 'attenuation': 'attenuation', + 'lovibond': 'lovibond', + 'max_in_batch': 'maxInBatch', + 'moisture': 'moisture', + 'non_fermentable': 'notFermentable', + 'ibu_per_unit': 'ibuPerAmount', + }, + 'hop': { + 'ibu': 'ibu', + 'use': 'use', + 'hop_type': 'type', + 'alpha': 'alpha', + + }, + 'misc': { + 'use': 'use', + 'misc_type': 'type', + 'water_adjustment': 'waterAdjustment', + + } +} + def get_batches(api_user, api_key, batch=''): auth_string = api_user + ':' + api_key - + auth64 = base64.b64encode(auth_string.encode("utf-8")) batch_array = [] @@ -19,7 +55,7 @@ def get_batches(api_user, api_key, batch=''): lastbatch = '&start_after=' + batch else: lastbatch = '' - + query = '{batch_url}?limit={pull_limit}&complete=True&include=recipe,recipe.batchSize&status=Planning{last_batch}'.format( batch_url=BATCH_URL, pull_limit=PULL_LIMIT, diff --git a/beer/migrations/0004_fermentable_hop_mash_misc_supplier_unit_and_more.py b/beer/migrations/0004_fermentable_hop_mash_misc_supplier_unit_and_more.py new file mode 100644 index 0000000..a1d9800 --- /dev/null +++ b/beer/migrations/0004_fermentable_hop_mash_misc_supplier_unit_and_more.py @@ -0,0 +1,217 @@ +# Generated by Django 5.0.6 on 2024-06-17 17:13 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('beer', '0003_delete_recipe_batchrecipe_batch_recipe_and_more'), + ('yeast', '0004_alter_propogation_options'), + ] + + operations = [ + migrations.CreateModel( + name='Fermentable', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ('unit_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('notes', models.TextField(blank=True, max_length=500, null=True)), + ('user_notes', models.TextField(blank=True, max_length=500, null=True)), + ('grain_category', models.IntegerField(choices=[(1, 'Base'), (2, 'Wheat/Oat'), (3, 'Crystal'), (4, 'Roasted'), (5, 'Acid')], default=1)), + ('fermentable_type', models.IntegerField(choices=[(1, 'Grain'), (2, 'Adjunct')], default=1)), + ('diastatic_power', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('potential', models.DecimalField(decimal_places=4, max_digits=6)), + ('protein', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('attenuation', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('lovibond', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('max_in_batch', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('moisture', models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True)), + ('non_fermentable', models.BooleanField(blank=True, null=True)), + ('ibu_per_unit', models.DecimalField(decimal_places=4, default=0, max_digits=6)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Hop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ('unit_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('notes', models.TextField(blank=True, max_length=500, null=True)), + ('user_notes', models.TextField(blank=True, max_length=500, null=True)), + ('ibu', models.DecimalField(decimal_places=4, default=0, max_digits=6)), + ('use', models.IntegerField(choices=[(1, 'Bittering'), (2, 'Aroma'), (3, 'Both')], default=1)), + ('hop_type', models.IntegerField(choices=[(1, 'Pellet'), (2, 'Leaf'), (3, 'Cryo'), (4, 'CO2 Extract')], default=1)), + ('alpha', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Mash', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Misc', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ('unit_cost', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('notes', models.TextField(blank=True, max_length=500, null=True)), + ('user_notes', models.TextField(blank=True, max_length=500, null=True)), + ('use', models.IntegerField(choices=[(1, 'Mash'), (2, 'Sparge'), (3, 'Boil'), (4, 'Flamout'), (5, 'Primary'), (6, 'Secondary'), (7, 'Cold Crash'), (8, 'Bottling')], default=1)), + ('misc_type', models.IntegerField(choices=[(1, 'Spice'), (2, 'Fining'), (3, 'Water Agent'), (4, 'Herb'), (5, 'Flavor'), (6, 'Other')], default=1)), + ('water_adjustment', models.BooleanField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Unit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ('unit_type', models.CharField(choices=[('WT', 'Weight'), ('VL', 'Volume')], default='WT', max_length=3)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='batchrecipe', + name='efficiency', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True), + ), + migrations.AddField( + model_name='batchrecipe', + name='mash', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.mash'), + ), + migrations.CreateModel( + name='MashStep', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('name', models.CharField(max_length=50)), + ('step_temp', models.DecimalField(decimal_places=2, max_digits=6)), + ('ramp_time', models.DecimalField(decimal_places=2, max_digits=6)), + ('step_time', models.DecimalField(decimal_places=2, max_digits=6)), + ('step_type', models.IntegerField(choices=[(1, 'infusion'), (2, 'temperature'), (3, 'decoction')], default=1)), + ('parent_mash', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.mash')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RecipeFermentable', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('quantity', models.DecimalField(decimal_places=4, max_digits=6)), + ('fermentable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.fermentable')), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RecipeHop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('quantity', models.DecimalField(decimal_places=4, max_digits=6)), + ('hop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.hop')), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RecipeMisc', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('quantity', models.DecimalField(decimal_places=4, max_digits=6)), + ('misc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.misc')), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RecipeYeast', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe')), + ('yeast', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='yeast.strain')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='misc', + name='supplier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.supplier'), + ), + migrations.AddField( + model_name='hop', + name='supplier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.supplier'), + ), + migrations.AddField( + model_name='fermentable', + name='supplier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.supplier'), + ), + migrations.AddField( + model_name='misc', + name='units', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='beer.unit'), + ), + migrations.AddField( + model_name='hop', + name='units', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='beer.unit'), + ), + migrations.AddField( + model_name='fermentable', + name='units', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='beer.unit'), + ), + ] diff --git a/beer/models.py b/beer/models.py index 77d1a24..dd9e54b 100644 --- a/beer/models.py +++ b/beer/models.py @@ -37,9 +37,162 @@ class Batch(CustomModel): # 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') + +class Supplier(CustomModel): + name = models.CharField(max_length=50) + +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) + + class Meta: + abstract = True + class BatchRecipe(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) + efficiency = models.DecimalField(max_digits=6, decimal_places=4, null=True, blank=True) + + 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', + } + + 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(BatchRecipe, on_delete=models.CASCADE) + fermentable = models.ForeignKey(Fermentable, on_delete=models.CASCADE) + quantity = models.DecimalField(max_digits=6, decimal_places=4) + +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): + recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) + hop = models.ForeignKey(Hop, on_delete=models.CASCADE) + quantity = models.DecimalField(max_digits=6, decimal_places=4) + +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(BatchRecipe, on_delete=models.CASCADE) + misc = models.ForeignKey(Misc, on_delete=models.CASCADE) + quantity = models.DecimalField(max_digits=6, decimal_places=4) + +class RecipeYeast(CustomModel): + recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) + yeast = models.ForeignKey('yeast.Strain', on_delete=models.CASCADE) + +class Mash(CustomModel): + name = models.CharField(max_length=50) + + 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