From 7cccb8fe8458be584b780848d7ccee4ae1fbcb8f Mon Sep 17 00:00:00 2001 From: Mike Shoup Date: Mon, 22 Jul 2019 09:02:45 -0600 Subject: [PATCH] Add mashing steps (#37) Closes #11 --- src/humulus/recipes.py | 119 ++++++++++++--- src/humulus/static/recipes.js | 59 ++++++++ src/humulus/templates/recipes/_macros.html | 30 ++++ src/humulus/templates/recipes/info.html | 30 ++++ tests/conftest.py | 10 ++ tests/test_recipes.py | 167 ++++++++++++++++++++- 6 files changed, 393 insertions(+), 22 deletions(-) diff --git a/src/humulus/recipes.py b/src/humulus/recipes.py index 525f16c..e854b14 100644 --- a/src/humulus/recipes.py +++ b/src/humulus/recipes.py @@ -37,7 +37,7 @@ bp = Blueprint('recipes', __name__, url_prefix='/recipes') class FermentableForm(Form): """Form for fermentables. - CSRF is disabled for this form.yeast.form (using `Form as parent class) + CSRF is disabled for this form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[DataRequired()]) @@ -67,7 +67,7 @@ class FermentableForm(Form): class HopForm(Form): """Form for hops. - CSRF is disabled for this form.yeast.form (using `Form as parent class) + CSRF is disabled for this form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[DataRequired()]) @@ -96,7 +96,7 @@ class HopForm(Form): class YeastForm(Form): """Form for yeast. - CSRF is disabled for this form.yeast.form (using `Form as parent class) + CSRF is disabled for this form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[Optional()]) @@ -148,6 +148,63 @@ class YeastForm(Form): return yeast +class MashStepForm(Form): + """Form for mash steps. + + CSRF is disabled for this form (using `Form as parent class) + because it is never used by itself. + """ + name = StringField('Step Name', validators=[DataRequired()]) + type = SelectField('Type', + choices=[(c, c) for c in ['Infusion', + 'Temperature', + 'Decoction']]) + temp = DecimalField('Temperature (°F)', validators=[DataRequired()]) + time = DecimalField('Time (min)', validators=[DataRequired()]) + amount = DecimalField('Water Amount (gal)') + + @property + def doc(self): + """Returns a dictionary that can be deserialized into JSON. + + Used for putting into CouchDB. + """ + step = { + 'name': self.name.data, + 'type': self.type.data, + 'temp': str(self.temp.data), + 'time': str(self.time.data), + } + if self.amount.data: + step['amount'] = str(self.amount.data) + return step + + +class MashForm(Form): + """Form for mash. + + CSRF is disabled for this form (using `Form as parent class) + because it is never used by itself. + """ + name = StringField('Mash Name', validators=[Optional()]) + steps = FieldList( + FormField(MashStepForm), + min_entries=0, + max_entries=20 + ) + + @property + def doc(self): + """Returns a dictionary that can be deserialized into JSON. + + Used for putting into CouchDB. + """ + return { + 'name': self.name.data, + 'steps': [s.doc for s in self.steps] + } + + class RecipeForm(FlaskForm): """Form for recipes.""" name = StringField('Name', validators=[DataRequired()]) @@ -171,6 +228,7 @@ class RecipeForm(FlaskForm): max_entries=20 ) yeast = FormField(YeastForm) + mash = FormField(MashForm) style = SelectField('Style', choices=[], validators=[Optional()]) @property @@ -197,6 +255,8 @@ class RecipeForm(FlaskForm): self.yeast.doc['high_attenuation'] != "None" ): recipe['yeast'] = self.yeast.doc + if self.mash.doc['name']: + recipe['mash'] = self.mash.doc return recipe def copyfrom(self, data): @@ -227,35 +287,52 @@ class RecipeForm(FlaskForm): }) if 'yeast' in data: - yeast = data['yeast'] - self.yeast.form.name.data = yeast['name'] + self.yeast.form.name.data = data['yeast']['name'] self.yeast.form.low_attenuation.data = ( - Decimal(yeast['low_attenuation']) + Decimal(data['yeast']['low_attenuation']) ) self.yeast.form.high_attenuation.data = ( - Decimal(yeast['high_attenuation']) + Decimal(data['yeast']['high_attenuation']) ) - if 'type' in yeast: - self.yeast.form.type.data = yeast['type'] - if 'lab' in yeast: - self.yeast.form.lab.data = yeast['lab'] - if 'code' in yeast: - self.yeast.form.code.data = yeast['code'] - if 'flocculation' in yeast: - self.yeast.form.flocculation.data = yeast['flocculation'] - if 'min_temperature' in yeast: + if 'type' in data['yeast']: + self.yeast.form.type.data = data['yeast']['type'] + if 'lab' in data['yeast']: + self.yeast.form.lab.data = data['yeast']['lab'] + if 'code' in data['yeast']: + self.yeast.form.code.data = data['yeast']['code'] + if 'flocculation' in data['yeast']: + self.yeast.form.flocculation.data = ( + data['yeast']['flocculation'] + ) + if 'min_temperature' in data['yeast']: self.yeast.form.min_temperature.data = ( - Decimal(yeast['min_temperature']) + Decimal(data['yeast']['min_temperature']) ) - if 'max_temperature' in yeast: + if 'max_temperature' in data['yeast']: self.yeast.form.max_temperature.data = ( - Decimal(yeast['max_temperature']) + Decimal(data['yeast']['max_temperature']) ) - if 'abv_tolerance' in yeast: + if 'abv_tolerance' in data['yeast']: self.yeast.form.abv_tolerance.data = ( - Decimal(yeast['abv_tolerance']) + Decimal(data['yeast']['abv_tolerance']) ) + if 'mash' in data: + if 'name' in data['mash']: + self.mash.form.name.data = data['mash']['name'] + if 'steps' in data['mash']: + for step in data['mash']['steps']: + new_step = { + 'name': step['name'], + 'type': step['type'], + 'temp': Decimal(step['temp']), + 'time': Decimal(step['time']) + } + if 'amount' in step: + new_step['amount'] = Decimal(step['amount']) + print(new_step) + self.mash.steps.append_entry(new_step) + class ImportForm(FlaskForm): upload = FileField(validators=[FileRequired()]) diff --git a/src/humulus/static/recipes.js b/src/humulus/static/recipes.js index c105df7..1053f65 100644 --- a/src/humulus/static/recipes.js +++ b/src/humulus/static/recipes.js @@ -90,6 +90,11 @@ function removeHop() { removeForm($(this), '.hop-form', '#hops'); } +// Remove a mash step +function removeMashStep() { + removeForm($(this), '.mash-form', '#mash'); +} + // Add a fermentable function addFerm() { var $fermsDiv = $('#ferms'); @@ -212,6 +217,58 @@ function addHop() { rebindChangeEvents(); } +// Add a new mash step +function addMashStep() { + var $mashDiv = $('#mash'); + var stepsLength = $mashDiv.data('length'); + if (stepsLength == 20) { + window.alert("Can't have more than 20 mash steps."); + return; + } + + var newStep = `
` + + '
' + + // Step Name Field + `` + + `` + + '
' + // End Step Name field + // Type field + '
' + + `` + + `' + + '
' + // End Type field + // Temperature field + '
' + + `` + + `` + + '
' + // End temperature field + //Time field + '
' + + `` + + `` + + '
' + // End time field + // Amount field + '
' + + `` + + `` + + '
' + // End amount field + '
' + + '' + + '
'; + + $mashDiv.append(newStep); + $('.rem-step').unbind('click'); + $('.rem-step').click(removeMashStep); +} + // Calculate recipe's original gravity function calculateOG() { var fermsLength = $('#ferms').data('length'); @@ -414,6 +471,8 @@ $(document).ready(function() { $('.rem-ferm').click(removeFerm); $('#add-hop').click(addHop); $('.rem-hop').click(removeHop); + $('#add-step').click(addMashStep); + $('.rem-step').click(removeMashStep); // Register change events rebindChangeEvents(); diff --git a/src/humulus/templates/recipes/_macros.html b/src/humulus/templates/recipes/_macros.html index a7f99a6..59d88ef 100644 --- a/src/humulus/templates/recipes/_macros.html +++ b/src/humulus/templates/recipes/_macros.html @@ -130,6 +130,36 @@ function getSpecsURL() { + {#- + Mash & Steps + -#} +

Mash

+
+ {{ render_field_with_errors(form.mash.form.name, 'form-control-sm') }} + {% for step in form.mash.form.steps %} +
+
+
{{ render_field_with_errors(step.form.name, 'form-control-sm') }}
+
{{ render_field_with_errors(step.form.type, 'custom-select-sm', base_class='custom-select') }}
+
+
+
{{ render_field_with_errors(step.form.temp, 'form-control-sm') }}
+
{{ render_field_with_errors(step.form.time, 'form-control-sm') }}
+
{{ render_field_with_errors(step.form.amount, 'form-control-sm') }}
+
+
+
+ +
+
+
+ {% endfor %} +
+
+
+ +
+
{#- Recipe Notes -#} diff --git a/src/humulus/templates/recipes/info.html b/src/humulus/templates/recipes/info.html index 6264a85..ee58f2e 100644 --- a/src/humulus/templates/recipes/info.html +++ b/src/humulus/templates/recipes/info.html @@ -200,11 +200,41 @@ {% endif %} +{% if recipe.mash and recipe.mash.name %} +

Mash

+
{{ recipe.mash.name }}
+
+ + + + + + + + + + + + {% for step in recipe.mash.steps %} + + + + + + + + + {% endfor %} +
#Step NameTypeTempTimeAmount
{{ loop.index }}{{ step.name }}{{ step.type }}{% if step.temp %}{{ step.temp }} °F{% endif %}{% if step.time %}{{ step.time }} min.{% endif %}{% if step.amount %}{{ step.amount }} gal.{% endif %}
+
+{% endif %} {#- Recipe Notes -#} +{% if recipe.notes %}

Recipe Notes

{{ recipe.notes }}
+{% endif %} {#- Buttons to do things -#} diff --git a/tests/conftest.py b/tests/conftest.py index 21043e4..743111f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,6 +123,16 @@ def app(): 'min_temperature': '60', 'max_temperature': '72', 'abv_tolerance': '10' + }, + 'mash': { + 'name': 'Single Infusion', + 'steps': [{ + 'name': 'Infusion', + 'type': 'Infusion', + 'temp': '152', + 'time': '60', + 'amount': '3.5' + }] } }) diff --git a/tests/test_recipes.py b/tests/test_recipes.py index b1c69f1..7f3f740 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -17,7 +17,8 @@ from decimal import Decimal from io import BytesIO from humulus.couch import get_doc -from humulus.recipes import FermentableForm, HopForm, RecipeForm, YeastForm +from humulus.recipes import (FermentableForm, HopForm, RecipeForm, YeastForm, + MashForm, MashStepForm) def test_index(client): @@ -247,6 +248,30 @@ def test_info_json(client): assert response.get_json()['name'] == 'Awesome Lager' +def test_step_form_doc(app): + """Evaluates conditionals in generation of doc from a step form.""" + step = MashStepForm() + step.name.data = 'Test Mash Step' + step.type.data = 'Infusion' + step.temp.data = Decimal('152') + step.time.data = Decimal('60') + assert step.doc == { + 'name': 'Test Mash Step', + 'type': 'Infusion', + 'temp': '152', + 'time': '60' + } + + step.amount.data = Decimal('3.5') + assert step.doc == { + 'name': 'Test Mash Step', + 'type': 'Infusion', + 'temp': '152', + 'time': '60', + 'amount': '3.5' + } + + def test_yeast_form_doc(app): """Evaluates conditionals in generation of doc from a yeast form.""" yeast = YeastForm() @@ -329,8 +354,19 @@ def test_recipe_form_doc(app): yeast.low_attenuation.data = '70' yeast.high_attenuation.data = '75' + step = MashStepForm() + step.name.data = 'Test Mash Step' + step.type.data = 'Infusion' + step.temp.data = Decimal('152') + step.time.data = Decimal('60') + step.amount.data = Decimal('3.5') + mash = MashForm() + mash.name.data = 'Single Infusion' + mash.steps = [step] + recipe.fermentables = [ferm] recipe.hops = [hop] + recipe.mash = mash recipe.yeast = yeast assert recipe.doc == { @@ -359,6 +395,16 @@ def test_recipe_form_doc(app): 'name': 'Test', 'low_attenuation': '70', 'high_attenuation': '75' + }, + 'mash': { + 'name': 'Single Infusion', + 'steps': [{ + 'name': 'Test Mash Step', + 'type': 'Infusion', + 'temp': '152', + 'time': '60', + 'amount': '3.5' + }] } } @@ -404,3 +450,122 @@ def test_recipe_create_json(client, sample_recipes, auth): response = client.post('/recipes/create/json', buffered=True, content_type='multipart/form-data', data=data) assert response.status_code == 200 + + +def test_copyfrom(app, sample_recipes): + recipe = { + 'name': 'Test', + 'type': 'All-Grain', + 'efficiency': '65', + 'volume': '5.5', + 'notes': 'Notes', + 'style': '18A', + 'fermentables': [{ + 'name': 'Test', + 'type': 'Grain', + 'amount': '1', + 'ppg': '36', + 'color': '4' + }], + 'hops': [{ + 'name': 'Test', + 'use': 'Boil', + 'alpha': '5.5', + 'duration': '30', + 'amount': '1' + }] + } + + with app.app_context(): + form = RecipeForm() + form.copyfrom(recipe) + assert form.name.data == recipe['name'] + assert form.type.data == recipe['type'] + assert form.efficiency.data == Decimal(recipe['efficiency']) + assert form.volume.data == Decimal(recipe['volume']) + assert form.notes.data == recipe['notes'] + assert len(form.fermentables) == len(recipe['fermentables']) + assert form.fermentables[0].form.name.data == \ + recipe['fermentables'][0]['name'] + assert form.fermentables[0].form.type.data == \ + recipe['fermentables'][0]['type'] + assert form.fermentables[0].form.amount.data == \ + Decimal(recipe['fermentables'][0]['amount']) + assert form.fermentables[0].form.ppg.data == \ + Decimal(recipe['fermentables'][0]['ppg']) + assert form.fermentables[0].form.color.data == \ + Decimal(recipe['fermentables'][0]['color']) + assert len(form.hops) == len(recipe['hops']) + assert form.hops[0].form.name.data == recipe['hops'][0]['name'] + assert form.hops[0].form.use.data == recipe['hops'][0]['use'] + assert form.hops[0].form.alpha.data == Decimal(recipe['hops'][0]['alpha']) + assert form.hops[0].form.duration.data == \ + Decimal(recipe['hops'][0]['duration']) + assert form.hops[0].form.amount.data == \ + Decimal(recipe['hops'][0]['amount']) + + recipe['yeast'] = { + 'name': 'Test', 'low_attenuation': '65', 'high_attenuation': '68' + } + recipe['mash'] = {} + with app.app_context(): + form = RecipeForm() + form.copyfrom(recipe) + assert form.yeast.form.name.data == recipe['yeast']['name'] + assert form.yeast.form.low_attenuation.data == \ + Decimal(recipe['yeast']['low_attenuation']) + assert form.yeast.form.high_attenuation.data == \ + Decimal(recipe['yeast']['high_attenuation']) + + recipe['yeast'].update({ + 'type': 'Liquid', + 'lab': 'Test', + 'code': 'Test', + 'flocculation': 'Low', + 'min_temperature': '65', + 'max_temperature': '68', + 'abv_tolerance': '15' + }) + with app.app_context(): + form = RecipeForm() + form.copyfrom(recipe) + assert form.yeast.form.type.data == recipe['yeast']['type'] + assert form.yeast.form.lab.data == recipe['yeast']['lab'] + assert form.yeast.form.code.data == recipe['yeast']['code'] + assert form.yeast.form.flocculation.data == recipe['yeast']['flocculation'] + assert form.yeast.form.min_temperature.data == \ + Decimal(recipe['yeast']['min_temperature']) + assert form.yeast.form.max_temperature.data == \ + Decimal(recipe['yeast']['max_temperature']) + assert form.yeast.form.abv_tolerance.data == \ + Decimal(recipe['yeast']['abv_tolerance']) + + recipe['mash'] = { + 'name': 'Test', + 'steps': [{ + 'name': 'Infusion', + 'type': 'Infusion', + 'temp': '152', + 'time': '60' + }] + } + with app.app_context(): + form = RecipeForm() + form.copyfrom(recipe) + assert form.mash.form.name.data == recipe['mash']['name'] + assert len(form.mash.form.steps) == len(recipe['mash']['steps']) + assert form.mash.form.steps[0].form.name.data == \ + recipe['mash']['steps'][0]['name'] + assert form.mash.form.steps[0].form.type.data == \ + recipe['mash']['steps'][0]['type'] + assert form.mash.form.steps[0].form.temp.data == \ + Decimal(recipe['mash']['steps'][0]['temp']) + assert form.mash.form.steps[0].form.time.data == \ + Decimal(recipe['mash']['steps'][0]['time']) + + recipe['mash']['steps'][0]['amount'] = '3.5' + with app.app_context(): + form = RecipeForm() + form.copyfrom(recipe) + assert form.mash.form.steps[0].form.amount.data == \ + Decimal(recipe['mash']['steps'][0]['amount'])