16 Commits

Author SHA1 Message Date
7c25b8924a Use display weights for ingredients. 2024-06-27 12:55:12 -04:00
b39bfdd267 Add BJCP guidelines to the database. 2024-06-27 09:56:09 -04:00
53b7a1b128 Just PEP8 stuff. 2024-06-26 14:56:29 -04:00
e8c9196fc0 Field help text.
Specify units used.
2024-06-26 14:56:09 -04:00
93b93a1448 make timestamp non-editable.
Hides from admin panel since it's generated automatically.
2024-06-26 14:53:35 -04:00
3fd248d8b7 Fix page title for batch template. 2024-06-26 12:37:36 -04:00
90bf91c3ed Added additional database fields and properties.
Much calculation of efficiency and stuff now.
2024-06-26 11:48:34 -04:00
570ede627f Some more handy helper functions. 2024-06-26 11:47:04 -04:00
07c881bdfe SG and Plato conversion calcs. 2024-06-25 14:48:59 -04:00
1d830eb22d More batch info.
Please enter the commit message for your changes. Lines starting
2024-06-24 16:25:18 -04:00
1986d9fbd0 Remove as much list comprehension as possible.
Using aggregate functions instead.
2024-06-21 11:33:47 -04:00
671ea27d9f Add many to many field with through relationship for fermentables.
Gives direct access to fermentable objects.
2024-06-21 11:32:53 -04:00
b6be827d14 Getting interface ready for modals. 2024-06-21 07:22:50 -04:00
2f33a27e47 Stupid line endings. 2024-06-21 07:21:38 -04:00
12fca700da Rename BatchRecipe -> Recipe.
Much better now thanks.
2024-06-20 15:02:17 -04:00
ea803ed009 Run migrations when starting dev server. 2024-06-20 14:53:18 -04:00
34 changed files with 13834 additions and 15017 deletions

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.apps import apps from django.apps import apps
from beer.models import Batch, BatchRecipe, Mash, MashStep, \ from beer.models import Batch, Recipe, Mash, MashStep, \
RecipeFermentable, RecipeHop, RecipeMisc, RecipeYeast RecipeFermentable, RecipeHop, RecipeMisc, RecipeYeast
from yeast.models import Yeast from yeast.models import Yeast
@ -34,9 +34,9 @@ class StrainInline(admin.TabularInline):
extra = 1 extra = 1
@admin.register(BatchRecipe) @admin.register(Recipe)
class BatchRecipeAdmin(admin.ModelAdmin): class RecipeAdmin(admin.ModelAdmin):
list_display = ['name'] list_display = ['name', 'total_extract_kg']
inlines = [ inlines = [
FermentableInline, FermentableInline,
HopInline, HopInline,
@ -47,11 +47,40 @@ class BatchRecipeAdmin(admin.ModelAdmin):
@admin.register(Batch) @admin.register(Batch)
class BeerBatchAdmin(admin.ModelAdmin): class BeerBatchAdmin(admin.ModelAdmin):
list_display = ['brewfather_id', 'batch_url'] list_display = [
'brewfather_id',
'batch_url',
'brewhouse_efficiency',
'conversion_efficiency',
]
inlines = [ inlines = [
SampleInline, SampleInline,
] ]
fieldsets = [
[None, {
'fields': [
('brewfather_id', 'brewfather_num', 'brewfather_name'),
'recipe',
]
}],
['Mash', {
'fields': ['first_runnings', 'mash_ph'],
}],
['Boil', {
'fields': [
('pre_boil_vol', 'pre_boil_sg'),
('post_boil_vol', 'post_boil_sg'),
],
}],
['Ferment', {
'fields': [
('fermenter_topup_vol', 'fermenter_vol'),
('original_sg', 'final_sg'),
],
}],
]
def batch_url(self, obj): def batch_url(self, obj):
url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">' url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">'
'Brewfather Batch ID: {batch_id}</a>') 'Brewfather Batch ID: {batch_id}</a>')

View File

@ -9,6 +9,7 @@ RECIPE_URL = 'https://api.brewfather.app/v2/recipes'
BATCH_URL = 'https://api.brewfather.app/v2/batches' BATCH_URL = 'https://api.brewfather.app/v2/batches'
PULL_LIMIT = 50 PULL_LIMIT = 50
BREWFATHER_CONVERT_LOOKUP = { # local_name: brewfather_name BREWFATHER_CONVERT_LOOKUP = { # local_name: brewfather_name
'all': { 'all': {
'name': 'name', 'name': 'name',
@ -86,3 +87,77 @@ def get_batches(api_user, api_key, batch=''):
data = data + get_batches(batch=last_id) data = data + get_batches(batch=last_id)
return data return data
def sg_plato(sg):
"""Convert specific gravtiy to °P."""
sg = float(sg)
return (-1*616.868) + (1111.14*sg) - (630.272*sg**2) + (135.997*sg**3)
def plato_sg(plato):
"""Convert °P to specific gravtiy."""
return 1 + (plato / (258.6 - (plato/258.2) * 227.1))
def kg_extract(v, sg):
"""Calculate kg of extract based on volume and SG."""
return float(v) * float(sg) * sg_plato(sg)/100
def convert(value, start_unit, end_unit):
"""Convert units."""
family_dict = unit_family(start_unit)
if family_dict:
start = [val for key, val in family_dict.items()
if start_unit in key.split(',')][0]
end = [val for key, val in family_dict.items()
if end_unit in key.split(',')][0]
return float(value) * end / start
def unit_family(unit_name):
"""Find unit family base on unit name."""
unit_lookup = [
{'name': 'length',
'units': {
'm,meter,meters': 1.0,
'cm,centimeter,centimeters': 100,
'mm,millimeter,millimeters': 1000,
'in,inch,inches': 39.3701,
'yd,yard,yards': 1.09361,
'ft,foot,feet': 3.28084,
'mi,mile,miles': 0.000621371,
'km,killometer,killometers': .001,
}},
{'name': 'mass',
'units': {
'kg,kilogram,kilograms': 1.0,
'g,gram,grams': 1000,
'lb,pound,pounds': 2.20462,
'oz,ounce,ounces': 35.274,
'st,stone': 0.157473,
}},
{'name': 'volume',
'units': {
'l,liter,liters': 1.0,
'ml,milliliter,milliliters': 1000,
'floz,fluid ounce,fluid ounces': 33.814,
'cup,cups': 4.22675,
'qt,quart,quarts': 1.05669,
'gal,gallon,gallons': 0.264172,
'ft^3,cubic foot,cubic feet': 0.0353147,
'pt,pint,pints': 4.22675/2,
'tsp,teaspoon,teaspoons': 202.884,
'tbsp,tablespoon,tablespoons': 202.884/3,
}},
]
for family in unit_lookup:
if [key for key in family['units'] if unit_name in key.split(',')]:
return family['units']

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-06-20 18:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('beer', '0009_batchrecipe_equipment_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='batchrecipe',
options={'verbose_name': 'Recipe', 'verbose_name_plural': 'Recipes'},
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-06-20 18:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('beer', '0010_alter_batchrecipe_options'),
]
operations = [
migrations.RenameModel(
old_name='BatchRecipe',
new_name='Recipe',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-21 13:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0011_rename_batchrecipe_recipe'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='fermentables',
field=models.ManyToManyField(through='beer.RecipeFermentable', to='beer.fermentable'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-06-24 18:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0012_recipe_fermentables'),
]
operations = [
migrations.AddField(
model_name='batch',
name='first_runnings',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True),
),
migrations.AddField(
model_name='equipmentprofile',
name='mash_ratio',
field=models.DecimalField(decimal_places=2, default=2.6, max_digits=6),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2024-06-25 15:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0013_batch_first_runnings_equipmentprofile_mash_ratio'),
]
operations = [
migrations.AddField(
model_name='batch',
name='post_boil_sg',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True),
),
migrations.AddField(
model_name='batch',
name='post_boil_vol',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True),
),
migrations.AlterField(
model_name='fermentable',
name='moisture',
field=models.DecimalField(blank=True, decimal_places=4, default=0.04, max_digits=6, null=True),
),
migrations.AlterField(
model_name='fermentable',
name='potential',
field=models.DecimalField(decimal_places=4, default=0.8, max_digits=6),
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.0.6 on 2024-06-26 14:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0014_batch_post_boil_sg_batch_post_boil_vol_and_more'),
]
operations = [
migrations.AddField(
model_name='batch',
name='fermenter_topup_vol',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True),
),
migrations.AddField(
model_name='batch',
name='fermenter_vol',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True),
),
migrations.AddField(
model_name='batch',
name='final_sg',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True),
),
migrations.AddField(
model_name='batch',
name='mash_ph',
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name='batch',
name='original_sg',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True),
),
migrations.AddField(
model_name='batch',
name='pre_boil_sg',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True),
),
migrations.AddField(
model_name='batch',
name='pre_boil_vol',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True),
),
migrations.AlterField(
model_name='batch',
name='post_boil_sg',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True),
),
]

View File

@ -0,0 +1,103 @@
# Generated by Django 5.0.6 on 2024-06-26 17:49
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0015_batch_fermenter_topup_vol_batch_fermenter_vol_and_more'),
]
operations = [
migrations.AlterModelTableComment(
name='equipmentprofile',
table_comment='Volumes in liters and weights in kg.',
),
migrations.AlterField(
model_name='batch',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='equipmentprofile',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='equipmentprofile',
name='leaf_hop_trub',
field=models.DecimalField(db_comment='liters/gram', decimal_places=10, default=0.0083454045, max_digits=12),
),
migrations.AlterField(
model_name='equipmentprofile',
name='pellet_hop_trub',
field=models.DecimalField(db_comment='liters/gram', decimal_places=10, default=0.003338162, max_digits=12),
),
migrations.AlterField(
model_name='fermentable',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='hop',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='mash',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='mashstep',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='misc',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='recipe',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='recipefermentable',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='recipehop',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='recipemisc',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='recipeyeast',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='supplier',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='unit',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
migrations.AlterField(
model_name='userprofile',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
),
]

View File

@ -0,0 +1,68 @@
# Generated by Django 5.0.6 on 2024-06-26 18:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0016_alter_equipmentprofile_table_comment_and_more'),
]
operations = [
migrations.AlterField(
model_name='equipmentprofile',
name='batch_volume',
field=models.DecimalField(decimal_places=2, default=5.5, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='grain_absorption',
field=models.DecimalField(decimal_places=2, default=0.12, help_text='liters/kilogram', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='hlt_deadspace',
field=models.DecimalField(decimal_places=2, default=0.25, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='kettle_boil_rate',
field=models.DecimalField(decimal_places=2, default=0.5, help_text='liters/hr', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='kettle_deadspace',
field=models.DecimalField(decimal_places=2, default=0.25, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='kettle_plumbing_loss',
field=models.DecimalField(decimal_places=2, default=0.25, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='leaf_hop_trub',
field=models.DecimalField(decimal_places=10, default=0.0083454045, help_text='liters/gram', max_digits=12),
),
migrations.AlterField(
model_name='equipmentprofile',
name='mash_ratio',
field=models.DecimalField(decimal_places=2, default=2.6, help_text='liters/kilogram', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='mt_capacity',
field=models.DecimalField(decimal_places=2, default=10, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='mt_deadspace',
field=models.DecimalField(decimal_places=2, default=0.25, help_text='liters', max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='pellet_hop_trub',
field=models.DecimalField(decimal_places=10, default=0.003338162, help_text='liters/gram', max_digits=12),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-06-26 19:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0017_alter_equipmentprofile_batch_volume_and_more'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='bjcp_category_id',
field=models.CharField(default='1', max_length=3),
),
migrations.AddField(
model_name='recipe',
name='bjcp_style_id',
field=models.CharField(default='1A', max_length=3),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-26 19:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0018_recipe_bjcp_category_id_recipe_bjcp_style_id'),
]
operations = [
migrations.AlterField(
model_name='recipe',
name='bjcp_style_id',
field=models.CharField(choices=[('1A', 'American Light Lager'), ('1B', 'American Lager'), ('1C', 'Cream Ale'), ('1D', 'American Wheat Beer'), ('2A', 'International Pale Lager'), ('2B', 'International Amber Lager'), ('2C', 'International Dark Lager'), ('3A', 'Czech Pale Lager'), ('3B', 'Czech Premium Pale Lager'), ('3C', 'Czech Amber Lager'), ('3D', 'Czech Dark Lager'), ('4A', 'Munich Helles'), ('4B', 'Festbier'), ('4C', 'Helles Bock'), ('5A', 'German Leichtbier'), ('5B', 'Kölsch'), ('5C', 'German Helles Exportbier'), ('5D', 'German Pils'), ('6A', 'Märzen'), ('6B', 'Rauchbier'), ('6C', 'Dunkles Bock'), ('7A', 'Vienna Lager'), ('7B', 'Altbier'), ('8A', 'Munich Dunkel'), ('8B', 'Schwarzbier'), ('9A', 'Doppelbock'), ('9B', 'Eisbock'), ('9C', 'Baltic Porter'), ('10A', 'Weissbier'), ('10B', 'Dunkles Weissbier'), ('10C', 'Weizenbock'), ('11A', 'Ordinary Bitter'), ('11B', 'Best Bitter'), ('11C', 'Strong Bitter'), ('12A', 'British Golden Ale'), ('12B', 'Australian Sparkling Ale'), ('12C', 'English IPA'), ('13A', 'Dark Mild'), ('13B', 'British Brown Ale'), ('13C', 'English Porter'), ('14A', 'Scottish Light'), ('14B', 'Scottish Heavy'), ('14C', 'Scottish Export'), ('15A', 'Irish Red Ale'), ('15B', 'Irish Stout'), ('15C', 'Irish Extra Stout'), ('16A', 'Sweet Stout'), ('16B', 'Oatmeal Stout'), ('16C', 'Tropical Stout'), ('16D', 'Foreign Extra Stout'), ('17A', 'British Strong Ale'), ('17B', 'Old Ale'), ('17C', 'Wee Heavy'), ('17D', 'English Barley Wine'), ('18A', 'Blonde Ale'), ('18B', 'American Pale Ale'), ('19A', 'American Amber Ale'), ('19B', 'California Common'), ('19C', 'American Brown Ale'), ('20A', 'American Porter'), ('20B', 'American Stout'), ('20C', 'Imperial Stout'), ('21A', 'American IPA'), ('21B', 'Specialty IPA'), ('21C', 'Hazy IPA'), ('22A', 'Double IPA'), ('22B', 'American Strong Ale'), ('22C', 'American Barleywine'), ('22D', 'Wheatwine'), ('23A', 'Berliner Weisse'), ('23B', 'Flanders Red Ale'), ('23C', 'Oud Bruin'), ('23D', 'Lambic'), ('23E', 'Gueuze'), ('23F', 'Fruit Lambic'), ('23G', 'Gose'), ('24A', 'Witbier'), ('24B', 'Belgian Pale Ale'), ('24C', 'Bière de Garde'), ('25A', 'Belgian Blond Ale'), ('25B', 'Saison'), ('25C', 'Belgian Golden Strong Ale'), ('26A', 'Belgian Single'), ('26B', 'Belgian Dubbel'), ('26C', 'Belgian Tripel'), ('26D', 'Belgian Dark Strong Ale'), ('28A', 'Brett Beer'), ('28B', 'Mixed-Fermentation Sour Beer'), ('28C', 'Wild Specialty Beer'), ('28D', 'Straight Sour Beer'), ('29A', 'Fruit Beer'), ('29B', 'Fruit and Spice Beer'), ('29C', 'Specialty Fruit Beer'), ('29D', 'Grape Ale'), ('30A', 'Spice, Herb, or Vegetable Beer'), ('30B', 'Autumn Seasonal Beer'), ('30C', 'Winter Seasonal Beer'), ('30D', 'Specialty Spice Beer'), ('31A', 'Alternative Grain Beer'), ('31B', 'Alternative Sugar Beer'), ('32A', 'Classic Style Smoked Beer'), ('32B', 'Specialty Smoked Beer'), ('34A', 'Commercial Specialty Beer'), ('34B', 'Mixed-Style Beer'), ('34C', 'Experimental Beer'), ('X1', 'Dorada Pampeana'), ('X2', 'IPA Argenta'), ('X3', 'Italian Grape Ale'), ('X4', 'Catharina Sour'), ('X5', 'New Zealand Pilsner')], default='1A', max_length=3),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-06-26 19:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0019_alter_recipe_bjcp_style_id'),
]
operations = [
migrations.AlterField(
model_name='recipe',
name='bjcp_style_id',
field=models.CharField(choices=[('1A', '1A: American Light Lager'), ('1B', '1B: American Lager'), ('1C', '1C: Cream Ale'), ('1D', '1D: American Wheat Beer'), ('2A', '2A: International Pale Lager'), ('2B', '2B: International Amber Lager'), ('2C', '2C: International Dark Lager'), ('3A', '3A: Czech Pale Lager'), ('3B', '3B: Czech Premium Pale Lager'), ('3C', '3C: Czech Amber Lager'), ('3D', '3D: Czech Dark Lager'), ('4A', '4A: Munich Helles'), ('4B', '4B: Festbier'), ('4C', '4C: Helles Bock'), ('5A', '5A: German Leichtbier'), ('5B', '5B: Kölsch'), ('5C', '5C: German Helles Exportbier'), ('5D', '5D: German Pils'), ('6A', '6A: Märzen'), ('6B', '6B: Rauchbier'), ('6C', '6C: Dunkles Bock'), ('7A', '7A: Vienna Lager'), ('7B', '7B: Altbier'), ('8A', '8A: Munich Dunkel'), ('8B', '8B: Schwarzbier'), ('9A', '9A: Doppelbock'), ('9B', '9B: Eisbock'), ('9C', '9C: Baltic Porter'), ('10A', '10A: Weissbier'), ('10B', '10B: Dunkles Weissbier'), ('10C', '10C: Weizenbock'), ('11A', '11A: Ordinary Bitter'), ('11B', '11B: Best Bitter'), ('11C', '11C: Strong Bitter'), ('12A', '12A: British Golden Ale'), ('12B', '12B: Australian Sparkling Ale'), ('12C', '12C: English IPA'), ('13A', '13A: Dark Mild'), ('13B', '13B: British Brown Ale'), ('13C', '13C: English Porter'), ('14A', '14A: Scottish Light'), ('14B', '14B: Scottish Heavy'), ('14C', '14C: Scottish Export'), ('15A', '15A: Irish Red Ale'), ('15B', '15B: Irish Stout'), ('15C', '15C: Irish Extra Stout'), ('16A', '16A: Sweet Stout'), ('16B', '16B: Oatmeal Stout'), ('16C', '16C: Tropical Stout'), ('16D', '16D: Foreign Extra Stout'), ('17A', '17A: British Strong Ale'), ('17B', '17B: Old Ale'), ('17C', '17C: Wee Heavy'), ('17D', '17D: English Barley Wine'), ('18A', '18A: Blonde Ale'), ('18B', '18B: American Pale Ale'), ('19A', '19A: American Amber Ale'), ('19B', '19B: California Common'), ('19C', '19C: American Brown Ale'), ('20A', '20A: American Porter'), ('20B', '20B: American Stout'), ('20C', '20C: Imperial Stout'), ('21A', '21A: American IPA'), ('21B', '21B: Specialty IPA'), ('21C', '21C: Hazy IPA'), ('22A', '22A: Double IPA'), ('22B', '22B: American Strong Ale'), ('22C', '22C: American Barleywine'), ('22D', '22D: Wheatwine'), ('23A', '23A: Berliner Weisse'), ('23B', '23B: Flanders Red Ale'), ('23C', '23C: Oud Bruin'), ('23D', '23D: Lambic'), ('23E', '23E: Gueuze'), ('23F', '23F: Fruit Lambic'), ('23G', '23G: Gose'), ('24A', '24A: Witbier'), ('24B', '24B: Belgian Pale Ale'), ('24C', '24C: Bière de Garde'), ('25A', '25A: Belgian Blond Ale'), ('25B', '25B: Saison'), ('25C', '25C: Belgian Golden Strong Ale'), ('26A', '26A: Belgian Single'), ('26B', '26B: Belgian Dubbel'), ('26C', '26C: Belgian Tripel'), ('26D', '26D: Belgian Dark Strong Ale'), ('28A', '28A: Brett Beer'), ('28B', '28B: Mixed-Fermentation Sour Beer'), ('28C', '28C: Wild Specialty Beer'), ('28D', '28D: Straight Sour Beer'), ('29A', '29A: Fruit Beer'), ('29B', '29B: Fruit and Spice Beer'), ('29C', '29C: Specialty Fruit Beer'), ('29D', '29D: Grape Ale'), ('30A', '30A: Spice, Herb, or Vegetable Beer'), ('30B', '30B: Autumn Seasonal Beer'), ('30C', '30C: Winter Seasonal Beer'), ('30D', '30D: Specialty Spice Beer'), ('31A', '31A: Alternative Grain Beer'), ('31B', '31B: Alternative Sugar Beer'), ('32A', '32A: Classic Style Smoked Beer'), ('32B', '32B: Specialty Smoked Beer'), ('34A', '34A: Commercial Specialty Beer'), ('34B', '34B: Mixed-Style Beer'), ('34C', '34C: Experimental Beer'), ('X1', 'X1: Dorada Pampeana'), ('X2', 'X2: IPA Argenta'), ('X3', 'X3: Italian Grape Ale'), ('X4', 'X4: Catharina Sour'), ('X5', 'X5: New Zealand Pilsner')], default='1A', max_length=3),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-06-26 19:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('beer', '0020_alter_recipe_bjcp_style_id'),
]
operations = [
migrations.RemoveField(
model_name='recipe',
name='bjcp_category_id',
),
]

View File

@ -0,0 +1,72 @@
# Generated by Django 5.0.6 on 2024-06-27 13:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0021_remove_recipe_bjcp_category_id'),
]
operations = [
migrations.CreateModel(
name='BjcpStyle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.TextField(blank=True, null=True)),
('category', models.TextField(blank=True, null=True)),
('category_id', models.TextField(blank=True, null=True)),
('style_id', models.TextField(blank=True, null=True)),
('category_description', models.TextField(blank=True, null=True)),
('overall_impression', models.TextField(blank=True, null=True)),
('aroma', models.TextField(blank=True, null=True)),
('appearance', models.TextField(blank=True, null=True)),
('flavor', models.TextField(blank=True, null=True)),
('mouthfeel', models.TextField(blank=True, null=True)),
('comments', models.TextField(blank=True, null=True)),
('history', models.TextField(blank=True, null=True)),
('style_comparison', models.TextField(blank=True, null=True)),
('tags', models.TextField(blank=True, null=True)),
('original_gravity_minimum_unit', models.TextField(blank=True, null=True)),
('original_gravity_minimum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('original_gravity_maximum_unit', models.TextField(blank=True, null=True)),
('original_gravity_maximum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('international_bitterness_units_minimum_unit', models.TextField(blank=True, null=True)),
('international_bitterness_units_minimum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('international_bitterness_units_maximum_unit', models.TextField(blank=True, null=True)),
('international_bitterness_units_maximum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('final_gravity_minimum_unit', models.TextField(blank=True, null=True)),
('final_gravity_minimum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('final_gravity_maximum_unit', models.TextField(blank=True, null=True)),
('final_gravity_maximum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('alcohol_by_volume_minimum_unit', models.TextField(blank=True, null=True)),
('alcohol_by_volume_minimum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('alcohol_by_volume_maximum_unit', models.TextField(blank=True, null=True)),
('alcohol_by_volume_maximum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('color_minimum_unit', models.TextField(blank=True, null=True)),
('color_minimum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('color_maximum_unit', models.TextField(blank=True, null=True)),
('color_maximum_value', models.DecimalField(blank=True, decimal_places=5, max_digits=10, null=True)),
('ingredients', models.TextField(blank=True, null=True)),
('examples', models.TextField(blank=True, null=True)),
('style_guide', models.TextField(blank=True, null=True)),
('type', models.TextField(blank=True, null=True)),
('entry_instructions', models.TextField(blank=True, null=True)),
('notes', models.TextField(blank=True, null=True)),
('currently_defined_types', models.TextField(blank=True, null=True)),
('strength_classifications', models.TextField(blank=True, null=True)),
('vital_statistics', models.TextField(blank=True, null=True)),
('profile', models.TextField(blank=True, null=True)),
('comparison', models.TextField(blank=True, null=True)),
],
options={
'db_table': 'beer_bjcp',
},
),
migrations.AlterField(
model_name='recipe',
name='bjcp_style_id',
field=models.CharField(choices=[('1A', '1A: American Light Lager'), ('1B', '1B: American Lager'), ('1C', '1C: Cream Ale'), ('1D', '1D: American Wheat Beer'), ('2A', '2A: International Pale Lager'), ('2B', '2B: International Amber Lager'), ('2C', '2C: International Dark Lager'), ('3A', '3A: Czech Pale Lager'), ('3B', '3B: Czech Premium Pale Lager'), ('3C', '3C: Czech Amber Lager'), ('3D', '3D: Czech Dark Lager'), ('4A', '4A: Munich Helles'), ('4B', '4B: Festbier'), ('4C', '4C: Helles Bock'), ('5A', '5A: German Leichtbier'), ('5B', '5B: Kölsch'), ('5C', '5C: German Helles Exportbier'), ('5D', '5D: German Pils'), ('6A', '6A: Märzen'), ('6B', '6B: Rauchbier'), ('6C', '6C: Dunkles Bock'), ('7A', '7A: Vienna Lager'), ('7B', '7B: Altbier'), ('8A', '8A: Munich Dunkel'), ('8B', '8B: Schwarzbier'), ('9A', '9A: Doppelbock'), ('9B', '9B: Eisbock'), ('9C', '9C: Baltic Porter'), ('10A', '10A: Weissbier'), ('10B', '10B: Dunkles Weissbier'), ('10C', '10C: Weizenbock'), ('11A', '11A: Ordinary Bitter'), ('11B', '11B: Best Bitter'), ('11C', '11C: Strong Bitter'), ('12A', '12A: British Golden Ale'), ('12B', '12B: Australian Sparkling Ale'), ('12C', '12C: English IPA'), ('13A', '13A: Dark Mild'), ('13B', '13B: British Brown Ale'), ('13C', '13C: English Porter'), ('14A', '14A: Scottish Light'), ('14B', '14B: Scottish Heavy'), ('14C', '14C: Scottish Export'), ('15A', '15A: Irish Red Ale'), ('15B', '15B: Irish Stout'), ('15C', '15C: Irish Extra Stout'), ('16A', '16A: Sweet Stout'), ('16B', '16B: Oatmeal Stout'), ('16C', '16C: Tropical Stout'), ('16D', '16D: Foreign Extra Stout'), ('17A', '17A: British Strong Ale'), ('17B', '17B: Old Ale'), ('17C', '17C: Wee Heavy'), ('17D', '17D: English Barley Wine'), ('18A', '18A: Blonde Ale'), ('18B', '18B: American Pale Ale'), ('19A', '19A: American Amber Ale'), ('19B', '19B: California Common'), ('19C', '19C: American Brown Ale'), ('20A', '20A: American Porter'), ('20B', '20B: American Stout'), ('20C', '20C: Imperial Stout'), ('21A', '21A: American IPA'), ('21B', '21B: Specialty IPA'), ('21C', '21C: Hazy IPA'), ('22A', '22A: Double IPA'), ('22B', '22B: American Strong Ale'), ('22C', '22C: American Barleywine'), ('22D', '22D: Wheatwine'), ('23A', '23A: Berliner Weisse'), ('23B', '23B: Flanders Red Ale'), ('23C', '23C: Oud Bruin'), ('23D', '23D: Lambic'), ('23E', '23E: Gueuze'), ('23F', '23F: Fruit Lambic'), ('23G', '23G: Gose'), ('24A', '24A: Witbier'), ('24B', '24B: Belgian Pale Ale'), ('24C', '24C: Bière de Garde'), ('25A', '25A: Belgian Blond Ale'), ('25B', '25B: Saison'), ('25C', '25C: Belgian Golden Strong Ale'), ('26A', '26A: Belgian Single'), ('26B', '26B: Belgian Dubbel'), ('26C', '26C: Belgian Tripel'), ('26D', '26D: Belgian Dark Strong Ale'), ('28A', '28A: Brett Beer'), ('28B', '28B: Mixed-Fermentation Sour Beer'), ('28C', '28C: Wild Specialty Beer'), ('28D', '28D: Straight Sour Beer'), ('29A', '29A: Fruit Beer'), ('29B', '29B: Fruit and Spice Beer'), ('29C', '29C: Specialty Fruit Beer'), ('29D', '29D: Grape Ale'), ('30A', '30A: Spice, Herb, or Vegetable Beer'), ('30B', '30B: Autumn Seasonal Beer'), ('30C', '30C: Winter Seasonal Beer'), ('30D', '30D: Specialty Spice Beer'), ('31A', '31A: Alternative Grain Beer'), ('31B', '31B: Alternative Sugar Beer'), ('32A', '32A: Classic Style Smoked Beer'), ('32B', '32B: Specialty Smoked Beer'), ('34A', '34A: Commercial Specialty Beer'), ('34B', '34B: Mixed-Style Beer'), ('34C', '34C: Experimental Beer'), ('X5', 'X5: New Zealand Pilsner')], default='1A', max_length=3),
),
]

View File

@ -0,0 +1,88 @@
# Generated by Django 5.0.6 on 2024-06-27 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0022_bjcpstyle_alter_recipe_bjcp_style_id'),
]
operations = [
migrations.AlterField(
model_name='bjcpstyle',
name='alcohol_by_volume_maximum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='alcohol_by_volume_minimum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='category',
field=models.TextField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='category_id',
field=models.TextField(blank=True, max_length=5, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='color_maximum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='color_minimum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='final_gravity_maximum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='final_gravity_minimum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='international_bitterness_units_maximum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='international_bitterness_units_minimum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='name',
field=models.TextField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='original_gravity_maximum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='original_gravity_minimum_unit',
field=models.TextField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='style_guide',
field=models.TextField(blank=True, max_length=20, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='style_id',
field=models.TextField(blank=True, max_length=5, null=True),
),
]

View File

@ -0,0 +1,88 @@
# Generated by Django 5.0.6 on 2024-06-27 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0023_alter_bjcpstyle_alcohol_by_volume_maximum_unit_and_more'),
]
operations = [
migrations.AlterField(
model_name='bjcpstyle',
name='alcohol_by_volume_maximum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='alcohol_by_volume_minimum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='category',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='category_id',
field=models.CharField(blank=True, max_length=5, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='color_maximum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='color_minimum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='final_gravity_maximum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='final_gravity_minimum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='international_bitterness_units_maximum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='international_bitterness_units_minimum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='name',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='original_gravity_maximum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='original_gravity_minimum_unit',
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='style_guide',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AlterField(
model_name='bjcpstyle',
name='style_id',
field=models.CharField(blank=True, max_length=5, null=True),
),
]

View File

@ -1,18 +1,25 @@
from django.db import models from django.db import models
from django.db.models import Sum, Q
from django.contrib.staticfiles import finders
from django.utils import timezone from django.utils import timezone
from django_cryptography.fields import encrypt from django_cryptography.fields import encrypt
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from config.extras import BREWFATHER_APP_ROOT from config.extras import BREWFATHER_APP_ROOT
from beer.extras import sg_plato, plato_sg, kg_extract, convert
from django.conf import settings from django.conf import settings
import json
import logging import logging
logger = logging.getLogger('django') logger = logging.getLogger('django')
class CustomModel(models.Model): class CustomModel(models.Model):
""" Custom model class with default fields to use. """ """ Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now) created_date = models.DateTimeField(
default=timezone.now,
editable=False
)
class Meta: class Meta:
abstract = True abstract = True
@ -33,7 +40,78 @@ class Batch(CustomModel):
brewfather_num = models.IntegerField(default=1) brewfather_num = models.IntegerField(default=1)
brewfather_name = models.CharField(max_length=500, default='name') brewfather_name = models.CharField(max_length=500, default='name')
recipe = models.OneToOneField( recipe = models.OneToOneField(
'BatchRecipe', on_delete=models.CASCADE, default=1) 'Recipe', on_delete=models.CASCADE, default=1)
# -------------------------------------------------------------------------
# Brewday Measurements
# -------------------------------------------------------------------------
# Mash
first_runnings = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
mash_ph = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True)
# Boil
pre_boil_vol = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
pre_boil_sg = models.DecimalField(
max_digits=5, decimal_places=4, null=True, blank=True)
post_boil_vol = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
post_boil_sg = models.DecimalField(
max_digits=5, decimal_places=4, null=True, blank=True)
# Ferment
fermenter_topup_vol = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
fermenter_vol = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
original_sg = models.DecimalField(
max_digits=5, decimal_places=4, null=True, blank=True)
final_sg = models.DecimalField(
max_digits=5, decimal_places=4, null=True, blank=True)
# Properties Needed:
# braukaiser.com/wiki/index.php/Troubleshooting_Brewhouse_Efficiency
# - Mash Efficiency
# - ABV
# - Attenuation
# - Actual Trub/Chiller Loss
# -------------------------------------------------------------------------
# Batch Stats
# -------------------------------------------------------------------------
@property
def brewhouse_efficiency(self):
try:
return round(self.boil_extract_kg/self.recipe.total_extract_kg, 4)
except ZeroDivisionError:
return 0
@property
def boil_extract_kg(self):
return kg_extract(float(self.post_boil_vol)*.96, self.post_boil_sg)
@property
def conversion_efficiency(self):
""" Calculate conversion efficiency of mash."""
if (self.first_runnings is None
or self.recipe.fermentable_weight_kg == 0):
return '-'
return round((sg_plato(self.first_runnings)/self.recipe.fw_max)
* (100-self.recipe.fw_max)
/ (100-sg_plato(self.first_runnings)), 4)
@property
def boil_off_calcualted(self):
return float(self.pre_boil_vol - self.post_boil_vol) * .96
@property
def trub_loss_calculated(self):
transfered_volume = self.fermenter_vol - self.fermenter_topup_vol
return self.post_boil_vol-transfered_volume
@property @property
def brewfather_url(self): def brewfather_url(self):
@ -77,7 +155,10 @@ class Supplier(CustomModel):
class CustomIngredient(CustomModel): class CustomIngredient(CustomModel):
""" Custom model class with default fields to use. """ """ Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now) created_date = models.DateTimeField(
default=timezone.now,
editable=False
)
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
units = models.ForeignKey(Unit, on_delete=models.PROTECT) units = models.ForeignKey(Unit, on_delete=models.PROTECT)
unit_cost = models.DecimalField( unit_cost = models.DecimalField(
@ -93,7 +174,7 @@ class CustomIngredient(CustomModel):
abstract = True abstract = True
class BatchRecipe(CustomModel): class Recipe(CustomModel):
""" Recipe to be stored with a batch.""" """ Recipe to be stored with a batch."""
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
batch_recipe = models.BooleanField(null=True) batch_recipe = models.BooleanField(null=True)
@ -106,25 +187,80 @@ class BatchRecipe(CustomModel):
max_digits=6, decimal_places=2, default=75) max_digits=6, decimal_places=2, default=75)
batch_size = models.DecimalField( batch_size = models.DecimalField(
max_digits=6, decimal_places=2, default=11) max_digits=6, decimal_places=2, default=11)
fermentables = models.ManyToManyField(
'Fermentable', through='RecipeFermentable')
with open(finders.find('bjcp/2021.json'), encoding='utf-8', errors="ignore") as bjcp_file:
bjcp = json.load(bjcp_file)
styles = bjcp['styles']
style_ids = dict([
(x['style_id'], '{}: {}'.format(x['style_id'], x['name']))
for x in styles
])
bjcp_style_id = models.CharField(
max_length=3, choices=style_ids, default='1A')
class Meta: class Meta:
verbose_name = 'Recipe' verbose_name = 'Recipe'
verbose_name_plural = 'Recipes' verbose_name_plural = 'Recipes'
@property @property
def fermentables(self): def batch_size_display(self):
return [x for x in list(self.recipefermentable_set.all())] return convert(self.batch_size, 'l', 'gal')
@property @property
def fermentable_weight(self): def fw_max(self):
return sum([x.quantity for x in self.fermentables]) potential = 0
weight = 0
for ferm in self.recipefermentable_set.all():
potential += ferm.fermentable.extract_percent*float(ferm.quantity)
weight += float(ferm.quantity)
e_grain = potential / weight
ratio = float(self.equipment.mash_ratio)
return 100*e_grain / (ratio+e_grain)
@property @property
def hops(self): def total_fermentable_display(self):
return [x for x in list(self.recipehop_set.all())] return convert(self.fermentable_weight_kg, 'kg', 'lb')
@property
def fermentable_weight_kg(self):
"""Weight of all fermentables attached to recipe."""
aggregate = self.recipefermentable_set.all().aggregate(Sum('quantity'))
if aggregate['quantity__sum']:
return aggregate['quantity__sum']
else:
return 0
@property
def total_extract_kg(self):
extract = 0
for f in self.recipefermentable_set.all():
extract += f.extract_weight_kg
return extract
@property
def total_hop_display(self):
return convert(self.hop_weight_g, 'g', 'oz')
@property
def hop_weight_g(self):
"""Weight of all fermentables attached to recipe."""
aggregate = self.recipehop_set.all().aggregate(Sum('quantity'))
if aggregate['quantity__sum']:
return aggregate['quantity__sum']
else:
return 0
@property @property
def final_volume(self): def final_volume(self):
"""Return final volume (after boil)."""
return (float(self.batch_size) return (float(self.batch_size)
+ self.hop_water_loss + self.hop_water_loss
+ self.net_kettle_deadspace + self.net_kettle_deadspace
@ -132,44 +268,50 @@ class BatchRecipe(CustomModel):
@property @property
def sugar_yield(self): def sugar_yield(self):
"""Return point yield of all non-mashed ingredients."""
ferms = self.recipefermentable_set.all().select_related('fermentable')
sugars = ferms.filter(fermentable__fermentable_type=3)
ferm_yield = 0 ferm_yield = 0
sugars = (x for x in self.fermentables
if x.fermentable.fermentable_type == 3) # Is sugar
for f in sugars: for f in sugars:
ferm_yield += f.quantity * (f.fermentable.potential - 1) * 1000 ferm_yield += f.extract_weight_kg
return float(ferm_yield) return float(ferm_yield)
@property @property
def mash_yield(self): def mash_yield(self):
"""Return point yield of all mashed ingredients."""
ferms = self.recipefermentable_set.all().select_related('fermentable')
mashed = ferms.filter(~Q(fermentable__fermentable_type=3))
mash_yield = 0 mash_yield = 0
mashed = (x for x in self.fermentables
if x.fermentable.fermentable_type != 3) # Is not sugar
for f in mashed: for f in mashed:
mash_yield += (f.quantity * (self.efficiency / 100) mash_yield += f.extract_weight_kg * float(self.efficiency/100)
* (f.fermentable.potential - 1) * 1000)
return float(mash_yield) return float(mash_yield)
@property @property
def original_sg(self): def original_sg(self):
total_yield = self.sugar_yield + self.mash_yield """Return original gravity."""
return round(1 + total_yield / self.final_volume / 1000, 3) total_extract = self.sugar_yield + self.mash_yield
plato = 100 * total_extract / (self.final_volume + total_extract)
return round(plato_sg(plato), 3)
@property @property
def pre_boil_sg(self): def pre_boil_sg(self):
total_yield = self.sugar_yield + self.mash_yield """Return pre-boil gravity."""
return round(1 + total_yield / (self.final_volume total_extract = self.sugar_yield + self.mash_yield
+ self.boil_off_gph) / 1000, 3) total_water = self.final_volume+self.boil_off_gph
plato = 100 * total_extract / (total_water + total_extract)
return round(plato_sg(plato), 3)
@property @property
def hop_water_loss(self): def hop_water_loss(self):
hop_absorption = .025 # gallons per ounce return float(sum(
return sum([float(x.quantity) * hop_absorption for x in self.hops]) x.quantity*x.trub_volume for x in self.recipehop_set.all()
))
@property @property
def net_kettle_deadspace(self): def net_kettle_deadspace(self):
@ -202,13 +344,18 @@ class BatchRecipe(CustomModel):
return 0 return 0
return float(self.equipment.kettle_boil_rate) return float(self.equipment.kettle_boil_rate)
@property
def ibu(self): # TODO: Multiple IBU formulas
return self.ibu_tinseth
@property @property
def ibu_tinseth(self): def ibu_tinseth(self):
return sum([x.ibu_tinseth for x in self.hops]) return sum(x.ibu_tinseth for x in self.recipehop_set.all())
@property @property
def srm(self): def srm(self):
color_total = sum([x.srm for x in self.fermentables]) color_total = sum(x.lovibond_contributed
for x in self.recipefermentable_set.all())
return 1.4922*(color_total**0.6859) return 1.4922*(color_total**0.6859)
@property @property
@ -262,7 +409,8 @@ class Fermentable(CustomIngredient):
fermentable_type = models.IntegerField(choices=types, default=1) fermentable_type = models.IntegerField(choices=types, default=1)
diastatic_power = models.DecimalField( diastatic_power = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True)
potential = models.DecimalField(max_digits=6, decimal_places=4) potential = models.DecimalField(
max_digits=6, decimal_places=4, default=0.80)
protein = models.DecimalField( protein = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True)
attenuation = models.DecimalField( attenuation = models.DecimalField(
@ -272,28 +420,47 @@ class Fermentable(CustomIngredient):
max_in_batch = models.DecimalField( max_in_batch = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True)
moisture = models.DecimalField( moisture = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True, default=0.04)
non_fermentable = models.BooleanField(null=True, blank=True) non_fermentable = models.BooleanField(null=True, blank=True)
ibu_per_unit = models.DecimalField( ibu_per_unit = models.DecimalField(
max_digits=6, decimal_places=4, default=0) max_digits=6, decimal_places=4, default=0)
@property
def extract_percent(self):
return (((float(self.potential)-1)*1000/46.17)
* (1-float(self.moisture)/100))
def __str__(self): def __str__(self):
return self.name return self.name
class RecipeFermentable(CustomModel): class RecipeFermentable(CustomModel):
recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
fermentable = models.ForeignKey(Fermentable, on_delete=models.CASCADE) fermentable = models.ForeignKey(Fermentable, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4) quantity = models.DecimalField(max_digits=6, decimal_places=4)
@property # Properties Needed:
def percent(self): # - The weight of extract in the grist
return float(100 * self.quantity / self.recipe.fermentable_weight) # extract in grist in kg = weight of grist in kg * extract potential
@property @property
def srm(self): def quantity_display(self):
return convert(self.quantity, 'kg', 'lb')
@property
def extract_weight_kg(self):
return float(self.quantity) * self.fermentable.extract_percent
@property
def percent(self):
return float(100 * self.quantity / self.recipe.fermentable_weight_kg)
@property
def lovibond_contributed(self):
srm_calc = (float(self.fermentable.lovibond) srm_calc = (float(self.fermentable.lovibond)
* float(self.quantity) / self.recipe.final_volume) * convert(self.quantity, 'kg', 'lb')
/ convert(self.recipe.final_volume, 'l', 'gal'))
return round(srm_calc, 1) return round(srm_calc, 1)
@ -328,12 +495,17 @@ class RecipeHop(CustomModel):
4: 'Mash', 4: 'Mash',
5: 'First Wort' 5: 'First Wort'
} }
recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
hop = models.ForeignKey(Hop, on_delete=models.CASCADE) hop = models.ForeignKey(Hop, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4) quantity = models.DecimalField(max_digits=6, decimal_places=4)
time = models.IntegerField(default=60, validators=[MinValueValidator(0)]) time = models.IntegerField(default=60, validators=[MinValueValidator(0)])
use = models.IntegerField(choices=uses, default=1) use = models.IntegerField(choices=uses, default=1)
@property
def quantity_display(self):
"""Convert grams to ounces."""
return convert(self.quantity, 'g', 'oz')
@property @property
def ibu_tinseth(self): def ibu_tinseth(self):
type_bonus = { type_bonus = {
@ -343,22 +515,32 @@ class RecipeHop(CustomModel):
4: 1.4, # CO2 Extract 4: 1.4, # CO2 Extract
} }
ibu = 0 hop_bonus = type_bonus[self.hop.hop_type]
average_wort_sg = (((self.recipe.pre_boil_sg - 1) average_wort_sg = (((self.recipe.pre_boil_sg-1)
+ (self.recipe.original_sg - 1)) / 2) + (self.recipe.original_sg-1)) / 2)
if self.use == 1: if self.use == 1:
conc = (((float(self.hop.alpha) / 100) conc = (float(self.hop.alpha/100)
* float(self.quantity)) * convert(self.quantity, 'g', 'oz')
* 7490 / self.recipe.final_volume) * 7490/convert(self.recipe.final_volume, 'l', 'gal'))
util = ((type_bonus[self.hop.hop_type] util = (hop_bonus*1.65*0.000125**average_wort_sg
* 1.65 * (0.000125**average_wort_sg)) * ((1-2.71828182845904**(-0.04*self.time)) / 4.15))
* ((1-2.71828182845904**(-0.04 * self.time))/4.15))
ibu = conc * util ibu = conc * util
else:
ibu = 0
return float(ibu) return float(ibu)
@property
def trub_volume(self):
if self.hop.hop_type == 1:
return self.recipe.equipment.pellet_hop_trub
elif self.hop.hop_type in [2, 3]:
return self.recipe.equipment.leaf_hop_trub
else:
return 0
class Misc(CustomIngredient): class Misc(CustomIngredient):
uses = { uses = {
@ -390,7 +572,7 @@ class Misc(CustomIngredient):
class RecipeMisc(CustomModel): class RecipeMisc(CustomModel):
recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
misc = models.ForeignKey(Misc, on_delete=models.CASCADE) misc = models.ForeignKey(Misc, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4) quantity = models.DecimalField(max_digits=6, decimal_places=4)
@ -399,7 +581,7 @@ class RecipeMisc(CustomModel):
class RecipeYeast(CustomModel): class RecipeYeast(CustomModel):
recipe = models.ForeignKey(BatchRecipe, on_delete=models.CASCADE) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
yeast = models.ForeignKey('yeast.Strain', on_delete=models.CASCADE) yeast = models.ForeignKey('yeast.Strain', on_delete=models.CASCADE)
@ -436,29 +618,96 @@ class EquipmentProfile(CustomModel):
# Water managment stuff # Water managment stuff
hlt_deadspace = models.DecimalField( hlt_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25) max_digits=6, decimal_places=2, default=0.25, help_text='liters')
mt_deadspace = models.DecimalField( mt_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25) max_digits=6, decimal_places=2, default=0.25, help_text='liters')
mt_capacity = models.DecimalField( mt_capacity = models.DecimalField(
max_digits=6, decimal_places=2, default=10) max_digits=6, decimal_places=2, default=10, help_text='liters')
grain_absorption = models.DecimalField( grain_absorption = models.DecimalField(
max_digits=6, decimal_places=2, default=0.12) # gal/lb max_digits=6, decimal_places=2,
default=0.12, help_text='liters/kilogram')
kettle_deadspace = models.DecimalField( kettle_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25) max_digits=6, decimal_places=2, default=0.25, help_text='liters')
kettle_plumbing_loss = models.DecimalField( kettle_plumbing_loss = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25) max_digits=6, decimal_places=2, default=0.25, help_text='liters')
kettle_boil_rate = models.DecimalField( kettle_boil_rate = models.DecimalField(
max_digits=6, decimal_places=2, default=0.5) # gal/hr max_digits=6, decimal_places=2,
default=0.5, help_text='liters/hr')
batch_volume = models.DecimalField( batch_volume = models.DecimalField(
max_digits=6, decimal_places=2, default=5.5) max_digits=6, decimal_places=2, default=5.5, help_text='liters')
leaf_hop_trub = models.DecimalField( leaf_hop_trub = models.DecimalField(
max_digits=6, decimal_places=4, default=0.0625) max_digits=12, decimal_places=10,
default=0.0083454045, help_text='liters/gram')
pellet_hop_trub = models.DecimalField( pellet_hop_trub = models.DecimalField(
max_digits=6, decimal_places=4, default=0.025) max_digits=12, decimal_places=10,
default=0.0033381620, help_text='liters/gram')
hops_remain_kettle = models.BooleanField(default=True) hops_remain_kettle = models.BooleanField(default=True)
mash_ratio = models.DecimalField(
max_digits=6, decimal_places=2,
default=2.6, help_text='liters/kilogram')
# Thermal Properties # Thermal Properties
mt_initial_hear = models.DecimalField( mt_initial_hear = models.DecimalField(
max_digits=6, decimal_places=4, default=0.74) max_digits=6, decimal_places=4, default=0.74)
mt_heat_loss_hour = models.DecimalField( mt_heat_loss_hour = models.DecimalField(
max_digits=6, decimal_places=4, default=2.0) max_digits=6, decimal_places=4, default=2.0)
class Meta:
db_table_comment = 'Volumes in liters and weights in kg.'
class BjcpStyle(models.Model):
name = models.CharField(max_length=50, blank=True, null=True)
category = models.CharField(max_length=50, blank=True, null=True)
category_id = models.CharField(max_length=5, blank=True, null=True)
style_id = models.CharField(max_length=5, blank=True, null=True)
category_description = models.TextField(blank=True, null=True)
overall_impression = models.TextField(blank=True, null=True)
aroma = models.TextField(blank=True, null=True)
appearance = models.TextField(blank=True, null=True)
flavor = models.TextField(blank=True, null=True)
mouthfeel = models.TextField(blank=True, null=True)
comments = models.TextField(blank=True, null=True)
history = models.TextField(blank=True, null=True)
style_comparison = models.TextField(blank=True, null=True)
tags = models.TextField(blank=True, null=True)
original_gravity_minimum_unit = models.CharField(max_length=10, blank=True, null=True)
original_gravity_minimum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
original_gravity_maximum_unit = models.CharField(max_length=10, blank=True, null=True)
original_gravity_maximum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
international_bitterness_units_minimum_unit = models.CharField(max_length=10, blank=True, null=True)
international_bitterness_units_minimum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
international_bitterness_units_maximum_unit = models.CharField(max_length=10, blank=True, null=True)
international_bitterness_units_maximum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
final_gravity_minimum_unit = models.CharField(max_length=10, blank=True, null=True)
final_gravity_minimum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
final_gravity_maximum_unit = models.CharField(max_length=10, blank=True, null=True)
final_gravity_maximum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
alcohol_by_volume_minimum_unit = models.CharField(max_length=10, blank=True, null=True)
alcohol_by_volume_minimum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
alcohol_by_volume_maximum_unit = models.CharField(max_length=10, blank=True, null=True)
alcohol_by_volume_maximum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
color_minimum_unit = models.CharField(max_length=10, blank=True, null=True)
color_minimum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
color_maximum_unit = models.CharField(max_length=10, blank=True, null=True)
color_maximum_value = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float
ingredients = models.TextField(blank=True, null=True)
examples = models.TextField(blank=True, null=True)
style_guide = models.CharField(max_length=20, blank=True, null=True)
type = models.TextField(blank=True, null=True)
entry_instructions = models.TextField(blank=True, null=True)
notes = models.TextField(blank=True, null=True)
currently_defined_types = models.TextField(blank=True, null=True)
strength_classifications = models.TextField(blank=True, null=True)
vital_statistics = models.TextField(blank=True, null=True)
profile = models.TextField(blank=True, null=True)
comparison = models.TextField(blank=True, null=True)
def __str__(self):
return '{} {}: {}'.format(
self.category,
self.style_id,
self.name
)
class Meta:
db_table = 'beer_bjcp'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
{% extends "no_jumbo_base.html" %}
{% load mathfilters %}
{% block title %}#{{ batch.brewfather_num }} {{ batch.brewfather_name }}{% endblock %}
{% block style %}
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%;
}
{% endblock %}
{% block content %}
<!-- Information Header -->
<div class="container">
<h1>{{ batch.brewfather_name }}</h1>
</div> <!-- /container -->
<!-- End Information Header -->
<!-- Main Data Container -->
<div class="container mt-4 mb-4">
<div class="row">
<!-- Tabbed Pages -->
<!-- Nav tabs -->
<ul class="nav nav-tabs nav-fill" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#planning">Planning</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#brewing">Brewing</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#fermenting">Fermenting</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#complete">Complete</a>
</li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div id="planning" class="container tab-pane active"><br>
{% include "beer/planning.html" %}
</div>
<div id="brewing" class="container tab-pane fade"><br>
</div>
<div id="fermenting" class="container tab-pane fade"><br>
</div>
<div id="complete" class="container tab-pane fade"><br>
</div>
</div>
<!-- End Tabbed Pages -->
</div>
</div>
<!-- End Main Data Container -->
{% endblock %}

View File

@ -0,0 +1,58 @@
<div class="row">
<div class="col-lg-6">
<div class="d-flex justify-content-between bg-dark text-white ms-2 my-1">
Recipe Ingredients
</div>
<div class="container-fluid">
<table class="table table-sm table-hover">
<tbody>
{% for f in batch.recipe.recipefermentable_set.all %}
<tr>
<td>{{ f.quantity|floatformat:2 }} lb</td>
<td>{{ f.fermentable.name }}<br>
<span class="text-muted">{{ f.fermentable.get_fermentable_type_display }} {{ f.srm }} SRM</span>
</td>
<td>{{ f.percent|floatformat:1 }} %</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-lg-6">
<div class="d-flex justify-content-between bg-dark text-white ms-2 my-1">
Batch Recipe
</div>
<div class="container-fluid">
<table class="table table-sm table-hover">
<tbody>
<tr onclick="window.location='recipe/{{ batch.recipe.id }}';">
<td><b>{{ batch.recipe.name }}</b><br>
<span class="text-muted"><b>OG:</b> {{ batch.recipe.original_sg }} <b>IBU:</b> {{ batch.recipe.ibu|floatformat:0 }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between bg-dark text-white ms-2 my-1">
Yeast
</div>
<div class="container-fluid">
<table class="table table-sm table-hover">
<tbody>
{% for y in batch.recipe.recipeyeast_set.all %}
<tr>
<td>{{ y.yeast.manufacturer.name }} {{ y.yeast.name }}<br>
<span class="text-muted">{{ y.yeast.long_name }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@ -10,6 +10,11 @@ input, label {
.container-beer { .container-beer {
background: {{ recipe.srm_hex }}; background: {{ recipe.srm_hex }};
} }
p.smaller {
font-size: .8em;
}
{% endblock %} {% endblock %}
{% block title %}Recipes{% endblock %} {% block title %}Recipes{% endblock %}
@ -19,7 +24,7 @@ input, label {
{% block jumbotronsub %} {% block jumbotronsub %}
<div class="row"> <div class="row">
<div class="col-lg d-flex justify-content-between" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em"> <div class="col-lg d-flex justify-content-between">
<div> <div>
<div class="container-beer"><img src="{% static "beer_back.png" %}" alt="" class="img-responsive d-none d-sm-block"></div> <div class="container-beer"><img src="{% static "beer_back.png" %}" alt="" class="img-responsive d-none d-sm-block"></div>
</div> </div>
@ -32,23 +37,27 @@ input, label {
<dd>All Grain</dd> <dd>All Grain</dd>
</dl> </dl>
</div> </div>
<div class="col-lg-4" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em"> <div class="col-lg-4">
<div class="d-flex justify-content-between bg-dark text-white">
<div class="text-truncate">Equipment: {{ recipe.equipment.name }}</div> <div class="text-truncate ms-1 my-1">{{ recipe.equipment.name }}</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><i class="fas fa-scale-unbalanced-flip"></i></button>
<button type="button" class="btn btn-dark btn-sm"><i class="fas fa-arrow-right-arrow-left"></i></button>
<button type="button" class="btn btn-dark btn-sm"><i class="fas fa-pen-to-square"></i></button>
</div>
</div>
<!-- <!--
<b>Batch Size:</b> {{ recipe.batch_size }} gal <b>Actual Volume:</b> {{ recipe.final_volume|floatformat:2 }}<br> <b>Batch Size:</b> {{ recipe.batch_size }} gal <b>Actual Volume:</b> {{ recipe.final_volume|floatformat:2 }}<br>
<b>Mash Efficiency:</b> {{ recipe.efficiency|floatformat:2 }} % <b>Mash Efficiency:</b> {{ recipe.efficiency|floatformat:2 }} %
--> -->
<dl class="row"> <dl class="row">
<dt>Batch Size</dt> <dt>Batch Size</dt>
<dd>{{ recipe.batch_size }} gal</dd> <dd>{{ recipe.batch_size_display|floatformat:2 }} gal</dd>
<dt>Actual Volume</dt>
<dd>{{ recipe.final_volume|floatformat:2 }} gal</dd>
<dt>Mash Efficiency</dt> <dt>Mash Efficiency</dt>
<dd>{{ recipe.efficiency|floatformat:2 }} %</dd> <dd>{{ recipe.efficiency|floatformat:2 }} %</dd>
</dl> </dl>
</div> </div>
<div class="col-xl-3" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em"> <div class="col-xl-3">
Style Data Style Data
</div> </div>
</div> </div>
@ -64,25 +73,38 @@ input, label {
<!-- Fermentables --> <!-- Fermentables -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Fermentables ({{ fermentable_weight|floatformat:2 }} lbs)</div> <div class="d-flex justify-content-between bg-dark text-white">
<table class="table table-sm "> <div class="ms-2 my-1">Fermentables ({{ recipe.total_fermentable_display|floatformat:2 }} lbs)</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><b>%</b></button>
<button type="button" class="btn btn-dark btn-sm"><b>OG</b></button>
<button type="button" class="btn btn-dark btn-sm"><i class="fa fa-plus"></i><b>ADD</b></button>
</div>
</div>
<table class="table table-sm table-hover">
<caption>
<p class="text-end smaller">
Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg|floatformat:3 }}</b><br>
Original Gravity: <b>{{ recipe.original_sg|floatformat:3 }}</b><br>
Color: <b>{{ recipe.srm|floatformat:0 }} SRM</b>
</p>
</caption>
<tbody> <tbody>
{% for f in recipe.recipefermentable_set.all %} {% for f in recipe.recipefermentable_set.all %}
<tr> <tr onclick="window.location='{% url 'beer:update_fermentable' f.fermentable.id %}';">
<td>{{ f.quantity|floatformat:2 }} lb</td> <td>{{ f.quantity_display|floatformat:2 }} lb</td>
<td>{{ f.fermentable.name }}<br> <td>{{ f.fermentable.name }}<br>
<span class="text-muted">{{ f.fermentable.get_fermentable_type_display }} {{ f.srm }} SRM</span> <span class="text-muted">{{ f.fermentable.get_fermentable_type_display }} {{ f.fermentable.lovibond|floatformat:0 }} &deg;L</span>
</td> </td>
<td>{{ f.percent|floatformat:1 }} %</td> <td>{{ f.percent|floatformat:1 }} %</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p class="text-end small">
Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg }}</b><br>
Original Gravity: <b>{{ recipe.original_sg }}</b><br>
Color: <b>{{ recipe.srm|floatformat:0 }} SRM</b>
</p>
</div> </div>
</div> </div>
@ -90,12 +112,25 @@ input, label {
<!-- Hops --> <!-- Hops -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Hops ({{ hop_weight|floatformat:2 }} oz)</div> <div class="d-flex justify-content-between bg-dark text-white">
<table class="table table-sm "> <div class="ms-2 my-1">Hops ({{ recipe.total_hop_display|floatformat:2 }} oz)</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><b>IBU</b></button>
<button type="button" class="btn btn-dark btn-sm"><i class="fa fa-plus"></i><b>ADD</b></button>
</div>
</div>
<table class="table table-sm table-hover">
<caption>
<p class="text-end smaller">
Total IBU: <b>{{ recipe.ibu_tinseth|floatformat:1 }}</b><br>
BU/GU: <b>{{ recipe.bu_gu|floatformat:2 }}</b><br>
RBR: <b>{{ recipe.rbr|floatformat:2 }}</b>
</p>
</caption>
<tbody> <tbody>
{% for h in recipe.recipehop_set.all %} {% for h in recipe.recipehop_set.all %}
<tr> <tr onclick="window.location='{% url 'beer:update_hop' h.hop.id %}';">
<td>{{ h.quantity|floatformat:2 }} oz</td> <td>{{ h.quantity_display|floatformat:2 }} oz</td>
<td>{{ h.hop.name }} {{ h.hop.alpha|floatformat:1 }} %<br> <td>{{ h.hop.name }} {{ h.hop.alpha|floatformat:1 }} %<br>
<span class="text-muted">{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU</span> <span class="text-muted">{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU</span>
</td> </td>
@ -103,11 +138,7 @@ input, label {
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p class="text-end small">
Total IBU: <b>{{ recipe.ibu_tinseth|floatformat:1 }}</b><br>
BU/GU: <b>{{ recipe.bu_gu|floatformat:2 }}</b><br>
RBR: <b>{{ recipe.rbr|floatformat:2 }}</b>
</p>
</div> </div>
</div> </div>
@ -118,7 +149,12 @@ input, label {
<!-- Misc --> <!-- Misc -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Misc.</div> <div class="d-flex justify-content-between bg-dark text-white">
<div class="ms-2 my-1">Misc.</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><i class="fa fa-plus"></i><b>ADD</b></button>
</div>
</div>
<table class="table table-sm"> <table class="table table-sm">
<tbody> <tbody>
{% for m in recipe.recipemisc_set.all %} {% for m in recipe.recipemisc_set.all %}
@ -131,7 +167,12 @@ input, label {
<!-- Yeast --> <!-- Yeast -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Yeast</div> <div class="d-flex justify-content-between bg-dark text-white">
<div class="ms-2 my-1">Yeast</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><i class="fa fa-plus"></i><b>ADD</b></button>
</div>
</div>
<table class="table table-sm"> <table class="table table-sm">
<tbody> <tbody>
{% for y in recipe.recipeyeast_set.all %} {% for y in recipe.recipeyeast_set.all %}
@ -149,7 +190,13 @@ input, label {
<!-- Mash --> <!-- Mash -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Mash Profile {{ recipe.mash.name }}</div> <div class="d-flex justify-content-between bg-dark text-white">
<div class="ms-2 my-1">Mash Profile: {{ recipe.mash.name }}</div>
<div>
<button type="button" class="btn btn-dark btn-sm"><i class="fas fa-arrow-right-arrow-left"></i></button>
<button type="button" class="btn btn-dark btn-sm"><i class="fas fa-pen-to-square"></i></button>
</div>
</div>
<table class="table table-sm "> <table class="table table-sm ">
<tbody> <tbody>
{% for step in recipe.mash.mashstep_set.all %} {% for step in recipe.mash.mashstep_set.all %}
@ -165,14 +212,13 @@ input, label {
<!-- Fermentation --> <!-- Fermentation -->
<div class="col-md"> <div class="col-md">
<div class="container-fluid"> <div class="container-fluid">
<div class="container-fluid bg-dark text-white">Fermentation Profile</div> <div class="d-flex justify-content-between bg-dark text-white">
<table class="table table-sm"> <div class="ms-2 my-1">Fermentation Profile</div>
<tbody> <div>
<!-- {% for y in recipe.recipeyeast_set.all %} --> <button type="button" class="btn btn-dark btn-sm"><i class="fas fa-arrow-right-arrow-left"></i></button>
<!-- <tr><td>{{ y.yeast.name }}</td></tr> --> <button type="button" class="btn btn-dark btn-sm"><i class="fas fa-pen-to-square"></i></button>
<!-- {% endfor %} --> </div>
</tbody> </div>
</table>
</div> </div>
</div> </div>

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load mathfilters %}
{% load funcs %}
{% load static %}
{% block title %}Recipes{% endblock %}
{% block jumbotron %}{{ data.name }}{% endblock %}
{% block jumbotronsub %}
{{ data.potential }}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load mathfilters %}
{% load funcs %}
{% load static %}
{% block title %}Recipes{% endblock %}
{% block jumbotron %}{{ data.name }}{% endblock %}
{% block jumbotronsub %}
{{ data.alpha }} %
{% endblock %}

View File

@ -1,10 +1,15 @@
from django.urls import path from django.urls import path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from .views import home, view_recipe from .views import home, view_recipe, view_batch, update_ferm, update_hop
urlpatterns = [ urlpatterns = [
path('', home, name='home'), path('', home, name='home'),
path('recipes/<int:recipe_id>/', view_recipe, name='recipe'), path('recipes/<int:recipe_id>/', view_recipe, name='recipe'),
path('batches/<int:batch_id>/recipe/<int:recipe_id>', view_recipe),
path('batches/<int:batch_id>/', view_batch, name='batch'),
path('ingredients/fermentables/<int:ferm_id>',
update_ferm, name='update_fermentable'),
path('ingredients/hops/<int:hop_id>', update_hop, name='update_hop'),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -1,6 +1,6 @@
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from .models import UserProfile, BatchRecipe, Batch from .models import UserProfile, Recipe, Batch, Fermentable, Hop
from .extras import get_batches from .extras import get_batches
import json import json
@ -20,7 +20,7 @@ def home(request):
if Batch.objects.filter(brewfather_id=batch['_id']).first() is None: if Batch.objects.filter(brewfather_id=batch['_id']).first() is None:
recipe_name = batch['recipe']['name'] recipe_name = batch['recipe']['name']
recipe_obj = BatchRecipe( recipe_obj = Recipe(
name=recipe_name, name=recipe_name,
batch_recipe=True, recipe_json=json.dumps(batch['recipe']) batch_recipe=True, recipe_json=json.dumps(batch['recipe'])
) )
@ -36,18 +36,39 @@ def home(request):
batch_obj.save() batch_obj.save()
context = { context = {
'recipes': BatchRecipe.objects.all(), 'recipes': Recipe.objects.all(),
} }
return render(request, 'beer/home.html', context) return render(request, 'beer/home.html', context)
def view_recipe(request, recipe_id): def view_recipe(request, recipe_id, batch_id=None):
recipe = get_object_or_404(BatchRecipe, pk=recipe_id)
context = { context = {
'recipe': recipe, 'recipe': get_object_or_404(Recipe, pk=recipe_id),
'fermentable_weight': sum([x.quantity for x in recipe.fermentables]),
'hop_weight': sum([x.quantity for x in recipe.hops]),
} }
return render(request, 'beer/recipe.html', context) return render(request, 'beer/recipe.html', context)
def view_batch(request, batch_id):
context = {
'batch': get_object_or_404(Batch, pk=batch_id)
}
return render(request, 'beer/batch.html', context)
def update_ferm(request, ferm_id):
fermentable = get_object_or_404(Fermentable, pk=ferm_id)
context = {
'data': fermentable,
}
return render(request, 'beer/update_fermentable.html', context)
def update_hop(request, hop_id):
hop = get_object_or_404(Hop, pk=hop_id)
context = {
'data': hop,
}
return render(request, 'beer/update_hop.html', context)

View File

@ -37,6 +37,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.flatpages', 'django.contrib.flatpages',
'fontawesomefree',
'mathfilters', 'mathfilters',
'yeast.apps.YeastLabConfig', 'yeast.apps.YeastLabConfig',
'beer.apps.BeerConfig', 'beer.apps.BeerConfig',

12344
fixtures/bjcp.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ django-cryptography-django5==2.2
django-mathfilters==1.0.0 django-mathfilters==1.0.0
docutils==0.21.2 docutils==0.21.2
environs==11.0.0 environs==11.0.0
fontawesomefree==6.5.1
gunicorn==22.0.0 gunicorn==22.0.0
pip-chill==1.0.3 pip-chill==1.0.3
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9

8
run.sh
View File

@ -1,5 +1,9 @@
#! /usr/bin/env bash #! /usr/bin/env bash
source .env/bin/activate source .env/bin/activate || source .env/Scripts/activate
pip install --upgrade -r requirements.txt find -name "*.py" -not -name "manage.py" -not -path "./.env/*" -not -path "*/migrations/*" -exec python -m flake8 {} \;
pip install --upgrade -r requirements.txt > /dev/null
python manage.py makemigrations
python manage.py migrate
python manage.py runserver 0.0.0.0:9595 python manage.py runserver 0.0.0.0:9595

View File

@ -6,7 +6,9 @@
<!-- CSS only --> <!-- CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% load static %}
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet" type="text/css">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet" type="text/css">
<!-- JS, Popper.js, and jQuery --> <!-- JS, Popper.js, and jQuery -->
<!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" --> <!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" -->
<!-- integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" --> <!-- integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" -->

View File

@ -0,0 +1,98 @@
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %}</title>
<!-- CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
{% load static %}
<link href="{% static 'fontawesomefree/css/fontawesome.css' %}" rel="stylesheet" type="text/css">
<link href="{% static 'fontawesomefree/css/solid.css' %}" rel="stylesheet" type="text/css">
<!-- JS, Popper.js, and jQuery -->
<!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" -->
<!-- integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy" -->
<!-- crossorigin="anonymous"></script> -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
{% block script %}{% endblock %}
<style>
{% block style %}{% endblock %}
body {
margin-top: 10px;
}
label {
margin-right: 10px;
}
.table-borderless > tbody > tr > td,
.table-borderless > tbody > tr > th,
.table-borderless > tfoot > tr > td,
.table-borderless > tfoot > tr > th,
.table-borderless > thead > tr > td,
.table-borderless > thead > tr > th {
border: none;
background-color: rgba(0,0,0, 0.0) !important;
}
</style>
</head>
<body>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">Damn Yankee Brewing</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'yeast:batches' %}">Batches</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown01" data-bs-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">Brewery Operations</a>
<div class="dropdown-menu" aria-labelledby="dropdown01">
<a class="dropdown-item" href="/yeast/">Yeast</a>
<a class="dropdown-item" href="/equipment/">Equipment</a>
<a class="dropdown-item" href="/beer/">Beer</a>
</div>
</li>
</ul>
{% if user.is_authenticated %}
<div class="dropdown ms-auto">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ user.username }}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="document.getElementById('LougoutForm').submit();">Log Out</a></li>
<li><a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a></li>
{% if user.is_superuser %}
<div class="dropdown-divider"></div>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
</ul>
</div>
{% else %}
<a href="{% url 'login' %}">Log In</a>
{% endif %}
<form id="LougoutForm" action="{% url 'logout' %}" method="POST" class="form-inline my-2 my-lg-0">
{% csrf_token %}
</form>
</div>
</div>
</nav>
</div>
<main role="main">
{% block content %}{% endblock %}
</main>
</body>
{% block endscript %}{% endblock %}
</html>

View File

@ -11,26 +11,10 @@ Add Batch
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<form action="" method="post">{% csrf_token %} <form action="" method="post">
{% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
</form> </form>
<!-- <form action="addbatch/" method="post">
{% csrf_token %}
production_date:<br>
<input name="production_date">
<br><br>
strain:<br>
<input name="strain">
<br><br>
source:<br>
<input name="source">
<br><br>
data_web:<br>
<input name="data_web">
<br><br>
<input type="submit" value="Submit">
</form> -->
</div> </div>
{% endblock %} {% endblock %}