Compare commits

...

14 Commits

Author SHA1 Message Date
dd9ae6cd21 Return some zero's when recipe is empty.
Get rid of errors.
closes #12
2024-06-20 11:26:08 -04:00
0f6bed9707 Don't track Geany project file. 2024-06-20 10:53:12 -04:00
14fb791357 Lots of PEP8 changes. 2024-06-20 10:50:44 -04:00
ea4340c8ea Add parent field to all recipe relations.
Need to make a copy of master item when attaching
to a recipe.
Also add some more calculations.
2024-06-18 20:41:58 -04:00
34470cbea7 BJCP style data in json format. 2024-06-18 15:17:10 -04:00
9566f203a0 Make a basic recipe display page.
No modificiation possible from here yet.
2024-06-18 15:16:22 -04:00
d6aa8e8d6a Add a bunch of properties for beer calculation. 2024-06-18 15:15:22 -04:00
8a3c80b517 Add batch size to beer model. 2024-06-18 15:14:28 -04:00
1a4d352d85 Forgot to commit this. 2024-06-17 16:24:24 -04:00
b05f486f19 Testing beer recipe pages. 2024-06-17 16:23:04 -04:00
cd66ff1f50 New functions for converting BF recipes eventually. 2024-06-17 16:22:44 -04:00
1eab86e5ae Add time and use to recipehop model. 2024-06-17 14:25:09 -04:00
06dfd5a9e9 Show name for model print. 2024-06-17 14:11:17 -04:00
e0ac1f96db Create basic recipe stuff. 2024-06-17 13:56:13 -04:00
34 changed files with 16330 additions and 538 deletions

21
.gitignore vendored
View File

@ -1,10 +1,11 @@
# Django #
*.log
*.pot
*.pyc
__pycache__
brewery.sqlite
media
secrets.json
.env
tags
# Django #
*.log
*.pot
*.pyc
__pycache__
brewery.sqlite
media
secrets.json
.env
tags
*.geany

View File

@ -1,20 +1,49 @@
from django.contrib import admin
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 beer.models import Batch, BatchRecipe, Mash, MashStep, \
RecipeFermentable, RecipeHop, RecipeMisc, RecipeYeast
from yeast.models import Yeast
from config.extras import BREWFATHER_APP_ROOT
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):
@ -23,11 +52,28 @@ class BeerBatchAdmin(admin.ModelAdmin):
SampleInline,
]
url_string = "<a href='{root}/tabs/batches/batch/{batch_id}'>Brewfather Batch ID: {batch_id}</a>"
def batch_url(self, obj):
url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">'
'Brewfather Batch ID: {batch_id}</a>')
bf_id = obj.brewfather_id
return format_html("<a href='{root}/tabs/batches/batch/{batch_id}'>Brewfather App: {batch_id}</a>", batch_id=bf_id, root=BREWFATHER_APP_ROOT)
return format_html(
url_string,
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')

View File

@ -9,18 +9,67 @@ 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 bf_from_local(category, name):
return BREWFATHER_CONVERT_LOOKUP[category][name]
def local_from_bf(category, name):
local_keys = list(BREWFATHER_CONVERT_LOOKUP[category].keys())
bf_keys = list(BREWFATHER_CONVERT_LOOKUP[category].values())
return local_keys[bf_keys.index(name)]
def get_batches(api_user, api_key, batch=''):
auth_string = api_user + ':' + api_key
auth64 = base64.b64encode(auth_string.encode("utf-8"))
batch_array = []
if batch != '':
lastbatch = '&start_after=' + batch
else:
lastbatch = ''
query = '{batch_url}?limit={pull_limit}&complete=True&include=recipe,recipe.batchSize&status=Planning{last_batch}'.format(
qry_str = ('{batch_url}?limit={pull_limit}'
'&complete=True&include=recipe,recipe.batchSize'
'&status=Planning{last_batch}')
query = qry_str.format(
batch_url=BATCH_URL,
pull_limit=PULL_LIMIT,
last_batch=lastbatch
@ -37,45 +86,3 @@ def get_batches(api_user, api_key, batch=''):
data = data + get_batches(batch=last_id)
return data
def pull_recipes(api_user, api_key):
batch_list = get_batches(api_user, api_key);
for batch in batch_list:
batch_id = batch['_id']
brewDate = batch['brewDate']
recipe = batch['recipe']
batch_name = '{} {}'.format(batch['name'], batch['batchNo'])
name = recipe['name']
batchSize = recipe['batchSize'] * 0.264172
strike_water = recipe['data']['mashWaterAmount'] * 0.264172
sparge_water = recipe['data']['spargeWaterAmount'] * 0.264172
fermentables = recipe['data']['mashFermentables']
fermentable_text = ''
for num, ferm in enumerate(fermentables):
logger.critical(ferm)
# var this_ferm = recipe.data.mashFermentables[key];
# console.log(this_ferm);
# var malt_string = this_ferm.name + '@@@' + this_ferm.grainCategory + '@@@' + this_ferm.amount * 2.20462 + '@@@ @@@' + this_ferm.color;
# fermentable_text = fermentable_text + "%%%" + malt_string;
# }
# /*for (j=0;j<fermentables.length;j++){
# console.log(fermentables[j]);
# var malt_string = fermentables[j].name + '@@@' + fermentables[j].grainCategory + '@@@' + fermentables[j].amount * 2.20462 + '@@@ @@@' + fermentables[j].color;
# fermentable_text = fermentable_text + "%%%" + malt_string;
# }*/
# row_data.push([name, batch_name, fermentable_text, id, strike_water, sparge_water, batchSize, brewDate]);
# }
# sheet = SpreadsheetApp.getActive().getSheetByName('Recipes');
# clearrange = sheet.getRange("A2:H");
# clearrange.clear();
# range = sheet.getRange(1, 1, row_data.length, row_data[0].length);
# range.setValues(row_data);
# clearrange.sort(8);
# }

View File

@ -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'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.0.6 on 2024-06-17 18:24
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0004_fermentable_hop_mash_misc_supplier_unit_and_more'),
]
operations = [
migrations.AddField(
model_name='recipehop',
name='time',
field=models.IntegerField(default=60, validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='recipehop',
name='use',
field=models.IntegerField(choices=[(1, 'Boil'), (2, 'Dry Hop'), (3, 'Aroma (Hop Stand)'), (4, 'Mash'), (5, 'First Wort')], default=1),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-06-18 11:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0005_recipehop_time_recipehop_use'),
]
operations = [
migrations.AddField(
model_name='batchrecipe',
name='batch_size',
field=models.DecimalField(decimal_places=2, default=11, max_digits=6),
),
migrations.AlterField(
model_name='batchrecipe',
name='efficiency',
field=models.DecimalField(decimal_places=2, default=75, max_digits=6),
),
]

View File

@ -0,0 +1,77 @@
# Generated by Django 5.0.6 on 2024-06-18 23:23
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0006_batchrecipe_batch_size_alter_batchrecipe_efficiency'),
]
operations = [
migrations.AddField(
model_name='fermentable',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.fermentable'),
),
migrations.AddField(
model_name='hop',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.hop'),
),
migrations.AddField(
model_name='mash',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.mash'),
),
migrations.AddField(
model_name='misc',
name='parent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.misc'),
),
migrations.AlterField(
model_name='fermentable',
name='fermentable_type',
field=models.IntegerField(choices=[(1, 'Grain'), (2, 'Adjunct'), (3, 'Sugar')], default=1),
),
migrations.CreateModel(
name='EquipmentProfile',
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)),
('hlt_deadspace', models.DecimalField(decimal_places=2, default=0.25, max_digits=6)),
('mt_deadspace', models.DecimalField(decimal_places=2, default=0.25, max_digits=6)),
('mt_capacity', models.DecimalField(decimal_places=2, default=10, max_digits=6)),
('grain_absorption', models.DecimalField(decimal_places=2, default=0.12, max_digits=6)),
('kettle_deadspace', models.DecimalField(decimal_places=2, default=0.25, max_digits=6)),
('kettle_plumbing_loss', models.DecimalField(decimal_places=2, default=0.25, max_digits=6)),
('kettle_boil_rate', models.DecimalField(decimal_places=2, default=0.5, max_digits=6)),
('batch_volume', models.DecimalField(decimal_places=2, default=5.5, max_digits=6)),
('leaf_hop_trub', models.DecimalField(decimal_places=2, default=0.0625, max_digits=6)),
('pellet_hop_trub', models.DecimalField(decimal_places=2, default=0.025, max_digits=6)),
('hops_remain_kettle', models.BooleanField(default=True)),
('mt_initial_hear', models.DecimalField(decimal_places=2, default=0.74, max_digits=6)),
('mt_heat_loss_hour', models.DecimalField(decimal_places=2, default=2.0, max_digits=6)),
('parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.equipmentprofile')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RecipeEquipment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('equipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.equipmentprofile')),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='beer.batchrecipe')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 5.0.6 on 2024-06-18 23:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0007_fermentable_parent_hop_parent_mash_parent_and_more'),
]
operations = [
migrations.AlterField(
model_name='equipmentprofile',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.equipmentprofile'),
),
migrations.AlterField(
model_name='fermentable',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.fermentable'),
),
migrations.AlterField(
model_name='hop',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.hop'),
),
migrations.AlterField(
model_name='mash',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.mash'),
),
migrations.AlterField(
model_name='misc',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='beer.misc'),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.0.6 on 2024-06-19 00:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beer', '0008_alter_equipmentprofile_parent_and_more'),
]
operations = [
migrations.AddField(
model_name='batchrecipe',
name='equipment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='beer.equipmentprofile'),
),
migrations.AlterField(
model_name='equipmentprofile',
name='leaf_hop_trub',
field=models.DecimalField(decimal_places=4, default=0.0625, max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='mt_heat_loss_hour',
field=models.DecimalField(decimal_places=4, default=2.0, max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='mt_initial_hear',
field=models.DecimalField(decimal_places=4, default=0.74, max_digits=6),
),
migrations.AlterField(
model_name='equipmentprofile',
name='pellet_hop_trub',
field=models.DecimalField(decimal_places=4, default=0.025, max_digits=6),
),
migrations.DeleteModel(
name='RecipeEquipment',
),
]

View File

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

BIN
beer/static/beer_back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

7964
beer/static/bjcp/2015.json Normal file

File diff suppressed because it is too large Load Diff

6885
beer/static/bjcp/2021.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
input, label {
display:block;
}
{% endblock %}
{% block title %}Recipea{% endblock %}
@ -13,6 +14,13 @@ input, label {
{% block jumbotron %}Recipe Manager {% endblock %}
{% block jumbotronsub %}{% endblock %}
{% block content %}
<h1>Comming Soon?</h1>
<div class="container" id="main">
<h1>Comming Soon?</h1>
<ul>
{% for recipe in recipes %}
<li><a href="{% url 'beer:recipe' recipe.id %}">{{ recipe.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@ -0,0 +1,187 @@
{% extends "base.html" %}
{% load mathfilters %}
{% load funcs %}
{% load static %}
{% block style %}
input, label {
display:block;
}
.container-beer {
background: {{ recipe.srm_hex }};
}
{% endblock %}
{% block title %}Recipes{% endblock %}
{% block jumbotron %}{{ recipe.name }}{% endblock %}
{% block jumbotronsub %}
<div class="row">
<div class="col-lg d-flex justify-content-between" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em">
<div>
<div class="container-beer"><img src="{% static "beer_back.png" %}" alt="" class="img-responsive d-none d-sm-block"></div>
</div>
<dl class="row">
<dt>Recipe</dt>
<dd>{{ recipe.name }}</dd>
<dt>Author</dt>
<dd>Author</dd>
<dt>Type</dt>
<dd>All Grain</dd>
</dl>
</div>
<div class="col-lg-4" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em">
<div class="text-truncate">Equipment: {{ recipe.equipment.name }}</div>
<!--
<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 }} %
-->
<dl class="row">
<dt>Batch Size</dt>
<dd>{{ recipe.batch_size }} gal</dd>
<dt>Actual Volume</dt>
<dd>{{ recipe.final_volume|floatformat:2 }} gal</dd>
<dt>Mash Efficiency</dt>
<dd>{{ recipe.efficiency|floatformat:2 }} %</dd>
</dl>
</div>
<div class="col-xl-3" style="border:1px solid #cecece; margin-right:.5em; margin-left:.5em">
Style Data
</div>
</div>
{% endblock %}
{% block content %}
<div class="container" id="main">
<div class="container-fluid">
<!-- Ferm and Hop Row -->
<div class="row">
<!-- Fermentables -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Fermentables ({{ fermentable_weight|floatformat:2 }} lbs)</div>
<table class="table table-sm ">
<tbody>
{% for f in 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>
<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>
<!-- Hops -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Hops ({{ hop_weight|floatformat:2 }} oz)</div>
<table class="table table-sm ">
<tbody>
{% for h in recipe.recipehop_set.all %}
<tr>
<td>{{ h.quantity|floatformat:2 }} oz</td>
<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>
</td>
<td>{{ h.time }} min</td></tr>
{% endfor %}
</tbody>
</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>
<!-- Misc and Yeast Row -->
<div class="row">
<!-- Misc -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Misc.</div>
<table class="table table-sm">
<tbody>
{% for m in recipe.recipemisc_set.all %}
<tr><td>{{ m.misc.name }}</td><td>{{ m.misc.quantity }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Yeast -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Yeast</div>
<table class="table table-sm">
<tbody>
{% for y in recipe.recipeyeast_set.all %}
<tr><td>{{ y.yeast.name }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Mash and Fermentation Row -->
<div class="row">
<!-- Mash -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Mash Profile {{ recipe.mash.name }}</div>
<table class="table table-sm ">
<tbody>
{% for step in recipe.mash.mashstep_set.all %}
<tr><td>{{ step.name }}</td><td>{{ step.step_temp }} &deg;F</td><td>{{ step.step_time }} min</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Fermentation -->
<div class="col-md">
<div class="container-fluid">
<div class="container-fluid bg-dark text-white">Fermentation Profile</div>
<table class="table table-sm">
<tbody>
<!-- {% for y in recipe.recipeyeast_set.all %} -->
<!-- <tr><td>{{ y.yeast.name }}</td></tr> -->
<!-- {% endfor %} -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<p>
<hr/>
<p>
</div>
{% endblock %}

View File

@ -1,6 +1,10 @@
from django.urls import path
from .views import home
from django.conf import settings
from django.conf.urls.static import static
from .views import home, view_recipe
urlpatterns = [
path('', home, name='home'),
]
path('recipes/<int:recipe_id>/', view_recipe, name='recipe'),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -1,38 +1,53 @@
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView
from django.http import HttpResponse
from .models import UserProfile, BatchRecipe, Batch
from .extras import get_batches
import json
from config.extras import AveryLabel
import logging
logger = logging.getLogger('django')
def home(request):
profile = get_object_or_404(UserProfile, user=request.user)
api_user = profile.brewfather_api_user
api_key = profile.brewfather_api_key
batch_list = get_batches(api_user, api_key)
for batch in batch_list:
if Batch.objects.filter(brewfather_id=batch['_id']).first() is None:
recipe_name = batch['recipe']['name']
recipe_obj = BatchRecipe(name=recipe_name, batch_recipe=True,recipe_json=json.dumps(batch['recipe']))
recipe_obj = BatchRecipe(
name=recipe_name,
batch_recipe=True, recipe_json=json.dumps(batch['recipe'])
)
recipe_obj.save()
batch_obj = Batch(
brewfather_id = batch['_id'],
brewfather_num = batch['batchNo'],
brewfather_name = batch['recipe']['name'],
recipe = recipe_obj,
brewfather_id=batch['_id'],
brewfather_num=batch['batchNo'],
brewfather_name=batch['recipe']['name'],
recipe=recipe_obj,
)
batch_obj.save()
return render(request, 'beer/home.html',{'batches':BatchRecipe.objects.all()})
context = {
'recipes': BatchRecipe.objects.all(),
}
return render(request, 'beer/home.html', context)
def view_recipe(request, recipe_id):
recipe = get_object_or_404(BatchRecipe, pk=recipe_id)
context = {
'recipe': recipe,
'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)

View File

@ -7,33 +7,25 @@ from django.db.models import BigIntegerField
from django.urls import reverse
from reportlab.graphics import shapes
from reportlab.graphics.barcode import createBarcodeDrawing
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.lib.pagesizes import LETTER
from config.settings import DEBUG
BREWFATHER_APP_ROOT = 'https://web.brewfather.app'
def random_with_N_digits(n):
def random_with_n_digits(n):
range_start = 10**(n-1)
range_end = (10**n)-1
return randint(range_start, range_end)
class AveryLabel:
"""
test_labels = [
{'manufacturer':'SafAle', 'name':'S-04', 'date':'2024-05-01', 'id':'12345'},
{'manufacturer':'SafAle', 'name':'US-05', 'date':'2024-05-15', 'id':'23456'}
]
labelSheet = AveryLabel(18294, debug=True)
labelSheet.render(test_labels, 'filelabels.pdf', 5)
"""
""" Class to generate page of averty labels. """
AVERY = {
18294: ( 4, 15, LETTER, (44.45, 16.764), (7.62, 13.97)),
5263: ( 2, 5, LETTER, (101.6, 50.8), (3.95, 12.7)),
18294: (4, 15, LETTER, (44.45, 16.764), (7.62, 13.97)),
5263: (2, 5, LETTER, (101.6, 50.8), (3.95, 12.7)),
}
def __init__(self, label, **kwargs):
@ -51,70 +43,28 @@ class AveryLabel:
self.__dict__.update(kwargs)
self.specs = labels.Specification(
self.pagesize[0]*0.352778, self.pagesize[1]*0.352778, # Convert from PostScript points to mm
self.pagesize[0]*0.352778, self.pagesize[1] *
0.352778, # Convert from PostScript points to mm
self.across, self.down,
self.labelsize[0], self.labelsize[1],
corner_radius=self.corner_radius,
left_margin=self.margins[0], right_margin=self.margins[0], top_margin=self.margins[1],
left_margin=self.margins[0],
right_margin=self.margins[0],
top_margin=self.margins[1],
left_padding=2, right_padding=1, top_padding=1, bottom_padding=0,
row_gap=0)
# def draw_sample(self, label, width, height, obj):
# if not obj['blank']:
# obj['path'] = reverse("yeast:sample", kwargs={"sample_id": obj['id']})
# # Barcode
# if DEBUG:
# qrw = QrCodeWidget('https://yeast.giacofei.org/{path}'.format(**obj))
# else:
# qrw = QrCodeWidget('https://{host}/{path}'.format(**obj))
# b = qrw.getBounds()
# w=b[2]-b[0]
# h=b[3]-b[1]
# psSize = (self.labelsize[1]/0.352778)
# d = shapes.Drawing(w,h, transform=[(4.5/6) * (psSize/w),0,0,(4.5/6) * (psSize/h),0,0])
# d.add(qrw)
# label.add(d)
# # Title
# label.add(shapes.String(
# 0, # Left Position
# 5 * height / 6, # Bottom Position
# '{manufacturer} {name}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=10
# ))
# # Line
# line_pos = 4.5 * height / 6
# label.add(shapes.Line(0, line_pos, width,line_pos))
# # Metadata
# label.add(shapes.String(
# height*.75, # Left Position
# 3 * height / 6, # Bottom Position
# 'ID: {id}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=6
# ))
# label.add(shapes.String(
# height*.75, # Left Position
# 2 * height / 6, # Bottom Position
# 'Packaging Date: {date}'.format(**obj), # Text
# fontName="Helvetica",
# fontSize=6
# ))
def draw(self, label, width, height, obj):
if not obj['blank']:
obj['path'] = reverse(
"{}:{}".format(obj['ns'],obj['template']),
"{}:{}".format(obj['ns'], obj['template']),
kwargs={"{}_id".format(obj['template']): obj['id']}
)
# Barcode
if DEBUG:
qrw = QrCodeWidget('https://brewery.giacofei.org/{path}'.format(**obj))
qrw = QrCodeWidget(
'https://brewery.giacofei.org/{path}'.format(**obj))
else:
qrw = QrCodeWidget('https://{host}/{path}'.format(**obj))
@ -122,53 +72,49 @@ class AveryLabel:
font = height/num_lines
b = qrw.getBounds()
w=b[2]-b[0]
h=b[3]-b[1]
psSize = (self.labelsize[1]/0.352778)
d = shapes.Drawing(w,h, transform=[(psSize/w),0,0,(psSize/h),0,0])
w = b[2]-b[0]
h = b[3]-b[1]
ps_size = (self.labelsize[1]/0.352778)
d = shapes.Drawing(
w, h, transform=[(ps_size/w), 0, 0, (ps_size/h), 0, 0])
d.add(qrw)
label.add(d)
# Line
line_pos = (num_lines - 1) * height / num_lines
label.add(shapes.Line(height, line_pos, width,line_pos))
label.add(shapes.Line(height, line_pos, width, line_pos))
# Title
label.add(shapes.String(
height, # Left Position
line_pos * 1.05, # Bottom Position
'{title}'.format(**obj), # Text
'{title}'.format(**obj), # Text
fontName="Helvetica",
fontSize=font
))
for x, line in enumerate(obj['data']):
x=x+1
x = x+1
label.add(shapes.String(
height, # Left Position
line_pos - font * x, # Bottom Position
line, # Text
line, # Text
fontName="Helvetica",
fontSize=font
))
))
def render(self, objects, render_file, labels_used=0):
"""
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(mimetype='application/pdf')
response['Content-Disposition'] = 'attachment; filename=somefilename.pdf'
"""
""" Create the HttpResponse object with the appropriate PDF headers."""
# Create the sheet.
# if render_type == 'sample':
# sheet = labels.Sheet(self.specs, self.draw_sample, border=self.debug)
# sheet = labels.Sheet(self.specs, self.draw_sample, border=self.debug)
sheet = labels.Sheet(self.specs, self.draw, border=self.debug)
sheet = labels.Sheet(self.specs, self.draw, border=self.debug)
# Add a couple of labels.
for i in range(int(labels_used)):
sheet.add_label({'blank':True})
sheet.add_label({'blank': True})
for entry in objects:
sheet.add_label(entry)
@ -176,12 +122,14 @@ class AveryLabel:
# Save the file and we are done.
sheet.save(render_file)
class DateUUIDField(BigIntegerField):
"""
A field which stores a Short UUID value with prepended date. This may also have
the Boolean attribute 'auto' which will set the value on initial save to a
new UUID value (calculated using shortuuid's default (uuid4)). Note that while all
UUIDs are expected to be unique we enforce this with a DB constraint.
A field which stores a Short UUID value with prepended date.
This may also have the Boolean attribute 'auto' which will set the value
on initial save to a new UUID value (calculated using shortuuid's default
(uuid4)). Note that while all UUIDs are expected to be unique we enforce
this with a DB constraint.
"""
def __init__(self, auto=True, *args, **kwargs):
@ -190,7 +138,7 @@ class DateUUIDField(BigIntegerField):
# Do not let the user edit UUIDs if they are auto-assigned.
kwargs['editable'] = False
kwargs['blank'] = True
kwargs['unique'] = True # if you want to be paranoid, set unique=True in your instantiation of the field.
kwargs['unique'] = True
super(DateUUIDField, self).__init__(*args, **kwargs)
@ -204,11 +152,11 @@ class DateUUIDField(BigIntegerField):
# Assign a new value for this attribute if required.
x = datetime.datetime.now()
front = x.strftime("%Y%m%d")
value = int('{}{}'.format(front,random_with_N_digits(6)))
value = int('{}{}'.format(front, random_with_n_digits(6)))
setattr(model_instance, self.attname, value)
return value
def formfield(self, **kwargs):
if self.auto:
return None
return super(DateUUIDField, self).formfield(**kwargs)
return super(DateUUIDField, self).formfield(**kwargs)

View File

@ -1,6 +1,7 @@
from django import forms
class ChangePasswordForm(forms.Form):
old_password = forms.CharField(widget=forms.PasswordInput())
new_password = forms.CharField(widget=forms.PasswordInput())
confirm_password = forms.CharField(widget=forms.PasswordInput())
confirm_password = forms.CharField(widget=forms.PasswordInput())

View File

@ -1,217 +0,0 @@
# Sample Gunicorn configuration file.
# https://raw.githubusercontent.com/benoitc/gunicorn/master/examples/example_config.py
#
# Server socket
#
# bind - The socket to bind.
#
# A string of the form: 'HOST', 'HOST:PORT', 'unix:PATH'.
# An IP is a valid HOST.
#
# backlog - The number of pending connections. This refers
# to the number of clients that can be waiting to be
# served. Exceeding this number results in the client
# getting an error when attempting to connect. It should
# only affect servers under significant load.
#
# Must be a positive integer. Generally set in the 64-2048
# range.
#
bind = 'myserver.com:1234'
backlog = 2048
#
# Worker processes
#
# workers - The number of worker processes that this server
# should keep alive for handling requests.
#
# A positive integer generally in the 2-4 x $(NUM_CORES)
# range. You'll want to vary this a bit to find the best
# for your particular application's work load.
#
# worker_class - The type of workers to use. The default
# sync class should handle most 'normal' types of work
# loads. You'll want to read
# http://docs.gunicorn.org/en/latest/design.html#choosing-a-worker-type
# for information on when you might want to choose one
# of the other worker classes.
#
# A string referring to a Python path to a subclass of
# gunicorn.workers.base.Worker. The default provided values
# can be seen at
# http://docs.gunicorn.org/en/latest/settings.html#worker-class
#
# worker_connections - For the eventlet and gevent worker classes
# this limits the maximum number of simultaneous clients that
# a single process can handle.
#
# A positive integer generally set to around 1000.
#
# timeout - If a worker does not notify the master process in this
# number of seconds it is killed and a new worker is spawned
# to replace it.
#
# Generally set to thirty seconds. Only set this noticeably
# higher if you're sure of the repercussions for sync workers.
# For the non sync workers it just means that the worker
# process is still communicating and is not tied to the length
# of time required to handle a single request.
#
# keepalive - The number of seconds to wait for the next request
# on a Keep-Alive HTTP connection.
#
# A positive integer. Generally set in the 1-5 seconds range.
#
workers = 1
worker_class = 'sync'
worker_connections = 1000
timeout = 30
keepalive = 2
#
# spew - Install a trace function that spews every line of Python
# that is executed when running the server. This is the
# nuclear option.
#
# True or False
#
spew = False
#
# Server mechanics
#
# daemon - Detach the main Gunicorn process from the controlling
# terminal with a standard fork/fork sequence.
#
# True or False
#
# raw_env - Pass environment variables to the execution environment.
#
# pidfile - The path to a pid file to write
#
# A path string or None to not write a pid file.
#
# user - Switch worker processes to run as this user.
#
# A valid user id (as an integer) or the name of a user that
# can be retrieved with a call to pwd.getpwnam(value) or None
# to not change the worker process user.
#
# group - Switch worker process to run as this group.
#
# A valid group id (as an integer) or the name of a user that
# can be retrieved with a call to pwd.getgrnam(value) or None
# to change the worker processes group.
#
# umask - A mask for file permissions written by Gunicorn. Note that
# this affects unix socket permissions.
#
# A valid value for the os.umask(mode) call or a string
# compatible with int(value, 0) (0 means Python guesses
# the base, so values like "0", "0xFF", "0022" are valid
# for decimal, hex, and octal representations)
#
# tmp_upload_dir - A directory to store temporary request data when
# requests are read. This will most likely be disappearing soon.
#
# A path to a directory where the process owner can write. Or
# None to signal that Python should choose one on its own.
#
daemon = False
# raw_env = [
# 'DJANGO_SECRET_KEY=something',
# 'SPAM=eggs',
# ]
pidfile = None
umask = 0
user = None
group = None
tmp_upload_dir = None
#
# Logging
#
# logfile - The path to a log file to write to.
#
# A path string. "-" means log to stdout.
#
# loglevel - The granularity of log output
#
# A string of "debug", "info", "warning", "error", "critical"
#
errorlog = '-'
loglevel = 'info'
accesslog = '-'
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
#
# Process naming
#
# proc_name - A base to use with setproctitle to change the way
# that Gunicorn processes are reported in the system process
# table. This affects things like 'ps' and 'top'. If you're
# going to be running more than one instance of Gunicorn you'll
# probably want to set a name to tell them apart. This requires
# that you install the setproctitle module.
#
# A string or None to choose a default of something like 'gunicorn'.
#
proc_name = None
#
# Server hooks
#
# post_fork - Called just after a worker has been forked.
#
# A callable that takes a server and worker instance
# as arguments.
#
# pre_fork - Called just prior to forking the worker subprocess.
#
# A callable that accepts the same arguments as after_fork
#
# pre_exec - Called just prior to forking off a secondary
# master process during things like config reloading.
#
# A callable that takes a server instance as the sole argument.
#
def post_fork(server, worker):
server.log.info("Worker spawned (pid: %s)", worker.pid)
def pre_fork(server, worker):
pass
def pre_exec(server):
server.log.info("Forked child, re-executing.")
def when_ready(server):
server.log.info("Server is ready. Spawning workers")
def worker_int(worker):
worker.log.info("worker received INT or QUIT signal")
## get traceback info
import threading, sys, traceback
id2name = {th.ident: th.name for th in threading.enumerate()}
code = []
for threadId, stack in sys._current_frames().items():
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId,""),
threadId))
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append('File: "%s", line %d, in %s' % (filename,
lineno, name))
if line:
code.append(" %s" % (line.strip()))
worker.log.debug("\n".join(code))
def worker_abort(worker):
worker.log.info("worker received SIGABRT signal")

View File

@ -1,16 +1,14 @@
import os
import dj_database_url
from environs import Env
import json
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(BASE_DIR,'secrets.json')) as f:
with open(os.path.join(BASE_DIR, 'secrets.json')) as f:
secrets = json.load(f)
TIME_ZONE = 'America/New_York'
SECRET_KEY = secrets.get('SECRET_KEY',"default_secret")
SECRET_KEY = secrets.get('SECRET_KEY', "default_secret")
DEBUG = secrets.get('DEBUG', True)
SITE_ID = 1
@ -62,14 +60,14 @@ MIDDLEWARE = [
MEDIA_ROOT = '/tmp/media/'
MEDIA_URL = '/media/'
STATIC_ROOT = secrets.get('STATIC_ROOT',os.path.join(BASE_DIR,'sitestatic'))
STATIC_ROOT = secrets.get('STATIC_ROOT', os.path.join(BASE_DIR, 'sitestatic'))
STATIC_URL = '/static/'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR,'templates'),
os.path.join(BASE_DIR, 'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
@ -89,9 +87,8 @@ TEMPLATES = [
},
]
DEFAULT_AUTO_FIELD='django.db.models.AutoField'
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
from django.urls import reverse_lazy
LOGIN_REDIRECT_URL = 'home'
LOGIN_URL = 'login'
LOGOUT_REDIRECT_URL = 'home'

View File

@ -9,6 +9,5 @@ urlpatterns = [
path('yeast/', include(('yeast.urls', 'yeast'))),
path('beer/', include(('beer.urls', 'beer'))),
path('equipment/', include(('equipment.urls', 'equipment'))),
#path('change-password/', ChangePasswordView.as_view(), name='change_password'),
path('', home, name="home"),
]

View File

@ -1,7 +1,5 @@
from django.shortcuts import render
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.views import PasswordChangeView
from django.urls import reverse_lazy
def home(request):
return render(request, 'home.html',{})
return render(request, 'home.html', {})

View File

@ -1,13 +1,15 @@
from django.contrib import admin
from django.utils.html import format_html
from django.apps import apps
from django import forms
from equipment.models import Equipment, KegType, State, EquipmentMaintenanceLine, EquipmentMaintenance
from equipment.models import Equipment, KegType, State, \
EquipmentMaintenanceLine, EquipmentMaintenance
class AdminCreateFormMixin:
"""
Mixin to easily use a different form for the create case (in comparison to "edit") in the django admin
Mixin to easily use a different form for the create case
(in comparison to "edit") in the django admin
Logic copied from `django.contrib.auth.admin.UserAdmin`
"""
add_form = None
@ -19,22 +21,26 @@ class AdminCreateFormMixin:
defaults.update(kwargs)
return super().get_form(request, obj, **defaults)
class MainenanceLineInline(admin.TabularInline):
model = EquipmentMaintenanceLine
extra = 1
class EquipmentInline(admin.TabularInline):
model = Equipment
extra = 4
@admin.register(EquipmentMaintenance)
class MaintenanceAdmin(admin.ModelAdmin):
#list_display_links = ['equipment',]
list_display = ['created_date','equipment', 'notes',]
# list_display_links = ['equipment',]
list_display = ['created_date', 'equipment', 'notes',]
inlines = [
MainenanceLineInline,
]
@admin.register(Equipment)
class EquipmentAdmin(admin.ModelAdmin):
@ -42,9 +48,10 @@ class EquipmentAdmin(admin.ModelAdmin):
list_display = ['id', 'equipment_type', 'keg_type', 'state']
list_editable = ['state', 'state']
@admin.register(KegType)
class KegTypeAdmin(admin.ModelAdmin):
list_display = ['name','manufacturer','size_gal',]
list_display = ['name', 'manufacturer', 'size_gal',]
inlines = [
EquipmentInline,
]
@ -52,6 +59,7 @@ class KegTypeAdmin(admin.ModelAdmin):
# class EquipmentAdmin(admin.ModelAdmin):
# list_display = ['name','equipment_type','keg_type','state']
@admin.register(State)
class StateAdmin(admin.ModelAdmin):
list_display = ['name']

View File

@ -1,10 +1,10 @@
from django.db import models
from django.utils import timezone
from reportlab.graphics.barcode.qr import QrCodeWidget
import logging
logger = logging.getLogger('django')
class CustomModel(models.Model):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
@ -12,19 +12,23 @@ class CustomModel(models.Model):
class Meta:
abstract = True
class EquipmentType(CustomModel):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class State(CustomModel):
name = models.CharField(max_length=100)
EquipmentType = models.ForeignKey(EquipmentType, on_delete=models.PROTECT, null=True, blank=True)
EquipmentType = models.ForeignKey(
EquipmentType, on_delete=models.PROTECT, null=True, blank=True)
def __str__(self):
return self.name
class KegType(CustomModel):
name = models.CharField(max_length=100)
manufacturer = models.CharField(max_length=100, blank=True)
@ -33,6 +37,7 @@ class KegType(CustomModel):
def __str__(self):
return self.name
class Equipment(CustomModel):
name = models.CharField(max_length=100)
equipment_type = models.ForeignKey(EquipmentType, on_delete=models.PROTECT)
@ -61,12 +66,18 @@ class EquipmentMaintenance(CustomModel):
equipment = models.ForeignKey(Equipment, on_delete=models.CASCADE)
notes = models.TextField(max_length=500, blank=True, null=True)
class EquipmentMaintenanceLine(CustomModel):
maintenance = models.ForeignKey(EquipmentMaintenance, on_delete=models.CASCADE)
maintenance_type = models.ForeignKey(MaintenanceType, on_delete=models.PROTECT)
maintenance = models.ForeignKey(
EquipmentMaintenance, on_delete=models.CASCADE)
maintenance_type = models.ForeignKey(
MaintenanceType, on_delete=models.PROTECT)
notes = models.TextField(max_length=500, blank=True, null=True)
class EquipmentTransactions(CustomModel):
equipment = models.ForeignKey(Equipment, on_delete=models.CASCADE)
transaction_type = models.ForeignKey(TransactionType, on_delete=models.PROTECT)
beer_batch = models.ForeignKey('beer.Batch', on_delete=models.PROTECT, blank=True, null=True)
transaction_type = models.ForeignKey(
TransactionType, on_delete=models.PROTECT)
beer_batch = models.ForeignKey(
'beer.Batch', on_delete=models.PROTECT, blank=True, null=True)

View File

@ -1,6 +1,6 @@
from django.urls import path
from .views import equipment_labels, home, EquipmentListView, equipment
from .views import equipment_labels, EquipmentListView, equipment
urlpatterns = [
path('labels/', equipment_labels, name='labels'),

View File

@ -8,40 +8,56 @@ from config.extras import AveryLabel
import logging
logger = logging.getLogger('django')
def home(request):
return render(request, 'equipment/home.html',{'equipment':Equipment.objects.all()})
return render(
request,
'equipment/home.html',
{'equipment': Equipment.objects.all()}
)
class EquipmentListView(ListView):
model = Equipment
def equipment(request, equipment_id):
equipment = get_object_or_404(Equipment, pk=equipment_id)
return render(request, 'equipment/equipment.html', {'equipment': equipment})
return render(
request,
'equipment/equipment.html',
{'equipment': equipment}
)
def equipment_labels(request):
""" Create label PDF for selected equipment
"""
skip_count = request.POST.get("skip_count", "")
equipment_list = request.POST.getlist("equipment_list", "")
to_print = list(filter(lambda d: str(d.id) in equipment_list, Equipment.objects.all()))
to_print = list(filter(lambda d: str(
d.id) in equipment_list, Equipment.objects.all()))
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(content_type ='application/pdf')
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename=keglabels.pdf'
labelSheet = AveryLabel(5263, debug=False)
label_sheet = AveryLabel(5263, debug=False)
labels = []
for equipment in to_print:
labels.append({
'id': equipment.id,
'title': equipment.keg_type.name,
'data': ['{} gal'.format(equipment.keg_type.size_gal), 'ID: {}'.format(equipment.id)], # TODO: Update this fo rgeneric equipment
# TODO: Update this fo rgeneric equipment
'data': [
'{} gal'.format(equipment.keg_type.size_gal),
'ID: {}'.format(equipment.id)],
'blank': False,
'host': request.get_host(),
'template': 'equipment',
'ns': 'equipment'
})
labelSheet.render(labels, response, skip_count)
return response
label_sheet.render(labels, response, skip_count)
return response

View File

@ -5,40 +5,51 @@ from django.apps import apps
from yeast.models import Yeast, Strain, Manufacturer, Storage, Propogation
from yeast.forms import YeastModelForm
import beer
from config.extras import BREWFATHER_APP_ROOT
class PropogationInline(admin.TabularInline):
model = Propogation
extra = 0
class SampleInline(admin.TabularInline):
model = Yeast
extra = 0
class StrainInline(admin.TabularInline):
model = Strain
extra = 5
class ParentInline(admin.TabularInline):
verbose_name = 'Parent Samples'
verbose_name = 'Parent Samples'
model = Propogation.parent.through
@admin.register(Yeast)
class YeastAdmin(admin.ModelAdmin):
list_display = [ 'propogation', 'url', 'lot_number', 'age', 'storage', 'viability', 'generation_num', 'cellcount', 'pitched', 'date_pitched', 'pitched_batch']
list_display = ['propogation', 'url', 'lot_number', 'age', 'storage',
'viability', 'generation_num', 'cellcount', 'pitched',
'date_pitched', 'pitched_batch']
list_editable = ['pitched', 'date_pitched', 'pitched_batch', 'lot_number']
def batch_url(self, obj):
if obj.pitched_batch:
bf_id = obj.pitched_batch.brewfather_id
return format_html("<a href='https://web.brewfather.app/tabs/batches/batch/{batch_id}'>{batch_id}</a>", batch_id=bf_id)
url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">'
'{batch_id}</a>')
return format_html(
url_string,
batch_id=bf_id,
root=BREWFATHER_APP_ROOT)
def url(self, obj):
if obj.data_web:
return format_html("<a href='{url}'>{url}</a>", url=obj.data_web)
@admin.register(Strain)
class StrainAdmin(admin.ModelAdmin):
list_display = ['name', 'long_name', 'manufacturer', 'avilable_batches']
@ -53,13 +64,16 @@ class StrainAdmin(admin.ModelAdmin):
urls = []
for related_obj in related_objs:
url_text = '{}: {}'.format(related_obj.get_source_display(), related_obj.production_date.strftime("%Y-%m-%d"))
url = reverse('admin:yeast_batch_change', args=[related_obj.id]) # replace 'myapp' with your app name
url_text = '{}: {}'.format(related_obj.get_source_display(
), related_obj.production_date.strftime("%Y-%m-%d"))
# replace 'myapp' with your app name
url = reverse('admin:yeast_batch_change', args=[related_obj.id])
urls.append('<a href="{}">{}</a>'.format(url, url_text))
return format_html(', '.join(urls))
avilable_batches.short_description = 'Available Propogation'
@admin.register(Storage)
class StorageAdmin(admin.ModelAdmin):
list_display = ['name', 'viability_loss', 'viability_interval']
@ -67,6 +81,7 @@ class StorageAdmin(admin.ModelAdmin):
SampleInline,
]
@admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin):
list_display = ['name', 'url']
@ -78,20 +93,24 @@ class ManufacturerAdmin(admin.ModelAdmin):
if obj.website:
return format_html("<a href='{url}'>{url}</a>", url=obj.website)
@admin.register(Propogation)
class PropogationAdmin(admin.ModelAdmin):
list_display = ['strain', 'consumed', 'source', 'parent_samples', 'production_date', 'avilable_samples', 'used_samples']
list_display = ['strain', 'consumed', 'source', 'parent_samples',
'production_date', 'avilable_samples', 'used_samples']
form = YeastModelForm
filter_horizontal = ['parent']
filter_horizontal = ['parent']
inlines = [
#ParentInline,
# ParentInline,
SampleInline,
]
def save_related(self, request, form, formsets, change):
super(PropogationAdmin, self).save_related(request, form, formsets, change)
super(PropogationAdmin, self).save_related(
request, form, formsets, change)
if form.instance.source_batch:
relate_samples = [x for x in Yeast.objects.all() if x.pitched_batch==form.instance.source_batch]
relate_samples = [x for x in Yeast.objects.all(
) if x.pitched_batch == form.instance.source_batch]
for sample in relate_samples:
form.instance.parent.add(sample)
@ -99,7 +118,8 @@ class PropogationAdmin(admin.ModelAdmin):
obj.parent.all()
def used_samples(self, obj):
related_objs = list(set(obj.yeast_set.all()) - set(obj.remaining_samples))
related_objs = list(set(obj.yeast_set.all()) -
set(obj.remaining_samples))
urls = []
@ -116,7 +136,8 @@ class PropogationAdmin(admin.ModelAdmin):
urls = []
for related_obj in related_objs:
url = reverse('admin:yeast_yeast_change', args=[related_obj.id]) # replace 'myapp' with your app name
# replace 'myapp' with your app name
url = reverse('admin:yeast_yeast_change', args=[related_obj.id])
urls.append('<a href="{}">{}</a>'.format(url, related_obj))
return format_html(', '.join(urls))

View File

@ -1,7 +1,7 @@
import logging
from django import forms
from django.urls import reverse
from django.utils import timezone
from django.http import HttpResponse, HttpResponseRedirect
from .models import Yeast, Propogation, Strain, Storage
from beer.models import Batch
@ -11,12 +11,13 @@ class MyModelChoiceField(forms.ModelChoiceField):
return obj.name
import logging
logger = logging.getLogger('django')
class DateInput(forms.DateInput):
input_type = 'date'
class YeastModelForm(forms.ModelForm):
class Meta:
model = Yeast
@ -33,7 +34,9 @@ class YeastModelForm(forms.ModelForm):
class BatchAddForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(BatchAddForm, self).__init__(*args, **kwargs)
self.fields['strain'].help_text = '<a href="{}">Add a new strain</a>'.format(reverse('yeast:addstrain'))
href_string = '<a href="{}">Add a new strain</a>'
self.fields['strain'].help_text = href_string.format(
reverse('yeast:addstrain'))
# create meta class
class Meta:
@ -53,6 +56,7 @@ class BatchAddForm(forms.ModelForm):
num_samples = forms.IntegerField()
class StrainAddForm(forms.ModelForm):
# create meta class
@ -67,6 +71,7 @@ class StrainAddForm(forms.ModelForm):
'manufacturer',
]
class PitchIntoBeerForm(forms.Form):
batch = forms.ModelChoiceField(queryset=Batch.objects.all())
starter = forms.BooleanField(required=False)
@ -79,6 +84,7 @@ class PitchIntoBeerForm(forms.Form):
sample.date_pitched = timezone.now()
sample.save()
class PropogateSampleForm(forms.Form):
num = forms.IntegerField(min_value=1)
storage = forms.ModelChoiceField(queryset=Storage.objects.all())
@ -94,9 +100,9 @@ class PropogateSampleForm(forms.Form):
# send email using the self.cleaned_data dictionary
prop_obj = Propogation(
production_date=timezone.now(),
strain = strain,
source = 'PR',
notes = 'Auto generated from form.'
strain=strain,
source='PR',
notes='Auto generated from form.'
)
prop_obj.save()
@ -109,15 +115,14 @@ class PropogateSampleForm(forms.Form):
cells = 100
yeast = Yeast(
propogation = prop_obj,
generation_num = parent.generation_num + 1,
storage = storage,
pitched = False,
cellcount = cells,
notes = 'Auto generated from form.'
propogation=prop_obj,
generation_num=parent.generation_num + 1,
storage=storage,
pitched=False,
cellcount=cells,
notes='Auto generated from form.'
)
yeast.save()
return prop_obj

View File

@ -1,24 +1,24 @@
from django.db import models
from django.utils import timezone
from django.utils.html import format_html
from datetime import datetime
from django.contrib.auth.models import User
from django.db.models import Count
import math
from config.extras import DateUUIDField
from config.extras import BREWFATHER_APP_ROOT
import logging
logger = logging.getLogger('django')
from config.extras import BREWFATHER_APP_ROOT
def average(lst):
return sum(lst) / len(lst)
class AvailableYeastManager(models.Manager):
""" Special manager for filtering out pitched yeast."""
def get_queryset(self):
return super(AvailableYeastManager, self).get_queryset().filter(pitched=False)
return super(AvailableYeastManager, self)\
.get_queryset().filter(pitched=False)
class CustomModel(models.Model):
@ -38,6 +38,7 @@ class Manufacturer(CustomModel):
# Return a string that represents the instance
return self.name
class Strain(CustomModel):
"""
Store individual yeast strain data. :model:`yeast.Manufacturer`.
@ -48,12 +49,14 @@ class Strain(CustomModel):
@property
def batches_available(self):
return [x for x in Propogation.objects.all() if not x.consumed and x.strain==self]
return [x for x in Propogation.objects.all()
if not x.consumed and x.strain == self]
def __str__(self):
# Return a string that represents the instance
return '{}: {}'.format(self.manufacturer.name, self.name)
class Storage(CustomModel):
"""
Data for methods of yeast storage. Used for calculating viability
@ -86,31 +89,37 @@ class Propogation(CustomModel):
production_date = models.DateField()
strain = models.ForeignKey(Strain, on_delete=models.PROTECT, default=0)
source = models.CharField(max_length=3, choices=BATCH_TYPES, default='ST')
source_batch = models.ForeignKey('beer.Batch', null=True, blank=True, on_delete=models.PROTECT)
source_batch = models.ForeignKey(
'beer.Batch', null=True, blank=True, on_delete=models.PROTECT)
notes = models.TextField(max_length=500, blank=True, null=True)
def save(self, *args, **kwargs):
super(Propogation, self).save(*args, **kwargs)
if self.source_batch:
relate_samples = [x for x in Yeast.objects.all() if x.pitched_batch==self.source_batch]
relate_samples = [x for x in Yeast.objects.all()
if x.pitched_batch == self.source_batch]
for sample in relate_samples:
self.parent.add(sample)
@property
def age(self):
return int(average([x.age for x in Yeast.available.all() if x.propogation == self]))
return int(average([x.age for x in Yeast.available.all()
if x.propogation == self]))
@property
def max_viability(self):
return int(max([x.viability for x in Yeast.available.all() if x.propogation == self]) * 100)
return int(max([x.viability for x in Yeast.available.all()
if x.propogation == self]) * 100)
@property
def min_viability(self):
return int(min([x.viability for x in Yeast.available.all() if x.propogation == self]) * 100)
return int(min([x.viability for x in Yeast.available.all()
if x.propogation == self]) * 100)
@property
def generation(self):
return int(average([x.generation_num for x in Yeast.available.all() if x.propogation == self]))
return int(average([x.generation_num for x in Yeast.available.all()
if x.propogation == self]))
@property
def beer_name(self):
@ -125,7 +134,10 @@ class Propogation(CustomModel):
@property
def beer_url(self):
if self.source_batch:
return '{}/tabs/batches/batch/{}'.format(BREWFATHER_APP_ROOT, self.source_batch.brewfather_id)
return '{}/tabs/batches/batch/{}'.format(
BREWFATHER_APP_ROOT,
self.source_batch.brewfather_id
)
@property
def consumed(self):
@ -133,15 +145,19 @@ class Propogation(CustomModel):
@property
def remaining_samples(self):
return [x for x in Yeast.available.all() if x.propogation==self]
return [x for x in Yeast.available.all() if x.propogation == self]
@property
def used_samples(self):
return [x for x in Yeast.objects.all() if x.propogation==self and x.pitched]
return [x for x in Yeast.objects.all()
if x.propogation == self and x.pitched]
def __str__(self):
# Return a string that represents the instance
return '{} [{}]'.format(self.strain, self.production_date.strftime("%Y-%m-%d"))
return '{} [{}]'.format(
self.strain,
self.production_date.strftime("%Y-%m-%d")
)
class Yeast(CustomModel):
@ -155,7 +171,8 @@ class Yeast(CustomModel):
cellcount = models.IntegerField(default=100)
pitched = models.BooleanField(default=False)
date_pitched = models.DateField(blank=True, null=True)
pitched_batch = models.ForeignKey('beer.Batch', null=True, blank=True, on_delete=models.CASCADE)
pitched_batch = models.ForeignKey(
'beer.Batch', null=True, blank=True, on_delete=models.CASCADE)
data_web = models.URLField(blank=True, null=True)
lot_number = models.CharField(max_length=15, blank=True, null=True)
notes = models.TextField(max_length=500, blank=True, null=True)
@ -179,8 +196,12 @@ class Yeast(CustomModel):
@property
def viability(self):
"""Return the viability based on age and storage method (:model:`yeast.Storage`)."""
return 0.97 * math.exp(self.age * math.log(1-self.storage.viability_loss)/self.storage.viability_interval)
"""Return the viability based on age and storage method
(:model:`yeast.Storage`).
"""
return 0.97 * math.exp(self.age
* math.log(1-self.storage.viability_loss)
/ self.storage.viability_interval)
def __str__(self):
# Return a string that represents the instance

View File

@ -1,36 +1,36 @@
{% extends 'base.html' %}
{% block style %}
form { display: table; }
p { display: table-row; }
label { display: table-cell; }
input { display: table-cell; }
{% endblock %}
{% block jumbotron %}
Add Batch
{% endblock %}
{% block content %}
<div class="container">
<form action="" method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit" />
</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>
{% extends 'base.html' %}
{% block style %}
form { display: table; }
p { display: table-row; }
label { display: table-cell; }
input { display: table-cell; }
{% endblock %}
{% block jumbotron %}
Add Batch
{% endblock %}
{% block content %}
<div class="container">
<form action="" method="post">{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Submit" />
</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>
{% endblock %}

View File

@ -1,14 +1,16 @@
import logging
from django import template
register = template.Library()
import logging
logger = logging.getLogger('django')
@register.filter
def get_value_in_qs(queryset, key):
batch_list = ','.join([str(x.id) for x in queryset if x.strain.id==key.id])
batch_list = ','.join([str(x.id) for x in queryset
if x.strain.id == key.id])
if batch_list:
return batch_list
return ''
return ''

View File

@ -1,8 +1,8 @@
from django.urls import include, path
from django.urls import path
from django.contrib.auth.decorators import login_required
from django.contrib import admin
from yeast.views import YeastListView, BatchListView, home, sample, batch, batch_labels, addBatch, addStrain, get_batch, NewPropogation
from yeast.views import YeastListView, BatchListView, home, sample, batch, \
batch_labels, AddBatch, AddStrain, get_batch, NewPropogation
# app_name = 'yeast'
@ -10,12 +10,17 @@ urlpatterns = [
path('samples/<int:yeast_id>/', sample, name='yeast'),
path('samples/', YeastListView.as_view(), name='yeasts'),
path('batches/addbatch/', login_required(addBatch.as_view()), name='addbatch'),
path('batches/addstrain/', login_required(addStrain.as_view()), name='addstrain'),
path('batches/addbatch/',
login_required(AddBatch.as_view()),
name='addbatch'),
path('batches/addstrain/',
login_required(AddStrain.as_view()),
name='addstrain'),
path('batches/<int:propogation_id>/', batch, name='batch'),
path('batches/', BatchListView.as_view(), name='batches'),
path('batch_lookup/', get_batch, name='get_batch'),
path('batch_labels/<int:propogation_id>/', login_required(batch_labels), name='labels'),
path('batch_labels/<int:propogation_id>/',
login_required(batch_labels), name='labels'),
path('progation/add/', NewPropogation, name='propogate'),
path('', home, name='home'),
]

View File

@ -1,26 +1,28 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.views.generic import ListView
from django.views.generic.edit import CreateView, FormView
from django.views import View
from django.http import HttpResponse, HttpRequest, HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from yeast.models import Yeast, Propogation, Strain, Storage
from config.extras import AveryLabel
from yeast.forms import BatchAddForm, StrainAddForm, PropogateSampleForm, PitchIntoBeerForm
from yeast.forms import BatchAddForm, StrainAddForm, PropogateSampleForm, \
PitchIntoBeerForm
import logging
logger = logging.getLogger('django')
class YeastListView(ListView):
model = Yeast
class BatchListView(ListView):
model = Propogation
def sample(request, yeast_id):
if request.method == 'POST':
if 'pitch' in request.POST:
@ -31,18 +33,23 @@ def sample(request, yeast_id):
logger.critical(pitch_form.cleaned_data)
sample = get_object_or_404(Yeast, pk=yeast_id)
pitch_form.pitch_sample()
return HttpResponseRedirect(reverse('yeast:yeast', kwargs={'yeast_id': sample.id}))
return HttpResponseRedirect(
reverse('yeast:yeast', kwargs={'yeast_id': sample.id})
)
elif 'propogate' in request.POST:
propogate_form = PropogateSampleForm(request.POST)
if propogate_form.is_valid():
new_prop = propogate_form.create_propogation()
return HttpResponseRedirect(reverse('yeast:batch', kwargs={'propogation_id': new_prop.id}))
return HttpResponseRedirect(reverse(
'yeast:batch', kwargs={'propogation_id': new_prop.id}
))
else:
return redirect('yeast:samples', kwargs={'yeast_id':yeast_id})
return redirect('yeast:samples', kwargs={'yeast_id': yeast_id})
else:
propogate_form = PropogateSampleForm(initial={'sample': get_object_or_404(Yeast, pk=yeast_id)})
propogate_form = PropogateSampleForm(
initial={'sample': get_object_or_404(Yeast, pk=yeast_id)})
pitch_form = PitchIntoBeerForm()
sample = get_object_or_404(Yeast, pk=yeast_id)
@ -61,8 +68,14 @@ def get_batch(request):
re_url = reverse('yeast:batch', kwargs={'propogation_id': propogation_id})
return redirect(re_url)
def home(request):
return render(request, 'yeast/home.html',{'batches': Propogation.objects.all, 'strains': Strain.objects.all})
context = {
'batches': Propogation.objects.all,
'strains': Strain.objects.all,
}
return render(request, 'yeast/home.html', context)
def batch(request, propogation_id):
"""
@ -91,12 +104,13 @@ def batch_labels(request, propogation_id):
skip_count = request.POST.get("skip_count", "")
samples = request.POST.getlist("samples", "")
batch = get_object_or_404(Propogation, pk=propogation_id)
to_print = list(filter(lambda d: str(d.id) in samples, batch.yeast_set.all()))
to_print = list(filter(lambda d: str(
d.id) in samples, batch.yeast_set.all()))
# Create the HttpResponse object with the appropriate PDF headers.
response = HttpResponse(content_type ='application/pdf')
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename=samplelabels.pdf'
labelSheet = AveryLabel(18294, debug=False)
label_sheet = AveryLabel(18294, debug=False)
logger.critical(samples)
logger.critical(to_print)
@ -105,33 +119,38 @@ def batch_labels(request, propogation_id):
for sample in to_print:
labels.append({
'id': sample.id,
'title': '{} {}'.format(sample.propogation.strain.manufacturer.name, sample.propogation.strain.name),
'data': ['ID: {}'.format(sample.id), 'Date: {}'.format(sample.propogation.production_date)],
'title': '{} {}'.format(
sample.propogation.strain.manufacturer.name,
sample.propogation.strain.name),
'data': ['ID: {}'.format(sample.id), 'Date: {}'.format(
sample.propogation.production_date)],
'blank': False,
'host': request.get_host(),
'template': 'yeast',
'ns': 'yeast'
})
labelSheet.render(labels, response, skip_count)
label_sheet.render(labels, response, skip_count)
return response
class addBatch(CreateView):
class AddBatch(CreateView):
model = Propogation
form_class = BatchAddForm
def get_success_url(self):
id = self.object.id #gets id from created object
id = self.object.id # gets id from created object
return reverse('yeast:batch', kwargs={'propogation_id': id})
class addStrain(CreateView):
class AddStrain(CreateView):
model = Strain
form_class = StrainAddForm
def get_success_url(self):
id = self.object.id #gets id from created object
return reverse('yeast:batches')
@login_required
class NewPropogation(FormView):
# ~ template_name = "contact.html"