From 5f7639232c5722bd1ba5dc0c9c16b3a0d8350f14 Mon Sep 17 00:00:00 2001 From: Mike Shoup Date: Sun, 23 Jun 2019 21:18:53 -0600 Subject: [PATCH] Add complete recipe update capability --- src/humulus/recipes.py | 75 ++++++++++-- src/humulus/templates/recipes/_macros.html | 130 +++++++++++++++++++++ src/humulus/templates/recipes/create.html | 111 +----------------- src/humulus/templates/recipes/update.html | 24 ++++ tests/test_recipes.py | 91 +++++++++++---- 5 files changed, 290 insertions(+), 141 deletions(-) create mode 100644 src/humulus/templates/recipes/_macros.html diff --git a/src/humulus/recipes.py b/src/humulus/recipes.py index ca08c1c..931149d 100644 --- a/src/humulus/recipes.py +++ b/src/humulus/recipes.py @@ -30,7 +30,7 @@ bp = Blueprint('recipes', __name__, url_prefix='/recipes') class FermentableForm(Form): """Form for fermentables. - CSRF is disabled for this subform (using `Form as parent class) because it + CSRF is disabled for this form.yeast.form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[DataRequired()]) @@ -60,7 +60,7 @@ class FermentableForm(Form): class HopForm(Form): """Form for hops. - CSRF is disabled for this subform (using `Form as parent class) because it + CSRF is disabled for this form.yeast.form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[DataRequired()]) @@ -89,7 +89,7 @@ class HopForm(Form): class YeastForm(Form): """Form for yeast. - CSRF is disabled for this subform (using `Form as parent class) because it + CSRF is disabled for this form.yeast.form (using `Form as parent class) because it is never used by itself. """ name = StringField('Name', validators=[Optional()]) @@ -173,10 +173,8 @@ class RecipeForm(FlaskForm): 'notes': self.notes.data } - if len(self.fermentables) > 0: - recipe['fermentables'] = [f.doc for f in self.fermentables] - if len(self.hops) > 0: - recipe['hops'] = [h.doc for h in self.hops] + recipe['fermentables'] = [f.doc for f in self.fermentables] + recipe['hops'] = [h.doc for h in self.hops] if self.yeast.doc['name']: recipe['yeast'] = self.yeast.doc return recipe @@ -208,9 +206,8 @@ def delete(id): @bp.route('/update/', methods=('GET', 'POST')) def update(id): # Get the recipe from the database and validate it is the same revision - recipe = get_doc_or_404(id) - form = RecipeForm() + recipe = get_doc_or_404(id) if form.validate_on_submit(): if recipe['_rev'] != request.args.get('rev', None): flash( @@ -218,7 +215,7 @@ def update(id): 'Update conflict for recipe: {}. ' 'Your changes have been lost.'.format(recipe['name']) ), - 'error' + 'danger' ) return redirect(url_for('recipes.info', id=id)) # Copy values from submitted form to the existing recipe and save @@ -228,5 +225,61 @@ def update(id): flash('Updated recipe: {}'.format(form.name.data), 'success') return redirect(url_for('recipes.info', id=id)) + else: + # Copy the recipe's data into the form. + # Is there an easier way to do this? + form.name.data = recipe['name'] + form.efficiency.data = Decimal(recipe['efficiency']) + form.volume.data = Decimal(recipe['volume']) + form.notes.data = recipe['notes'] - return render_template('recipes/update.html', form=form) + for fermentable in recipe['fermentables']: + form.fermentables.append_entry({ + 'name': fermentable['name'], + 'type': fermentable['type'], + 'amount': Decimal(fermentable['amount']), + 'ppg': Decimal(fermentable['ppg']), + 'color': Decimal(fermentable['color']) + }) + + for hop in recipe['hops']: + form.hops.append_entry({ + 'name': hop['name'], + 'use': hop['use'], + 'alpha': Decimal(hop['alpha']), + 'duration': Decimal(hop['duration']), + 'amount': Decimal(hop['amount']), + }) + + if 'yeast' in recipe: + yeast = recipe['yeast'] + form.yeast.form.name.data = yeast['name'] + form.yeast.form.low_attenuation.data = ( + Decimal(yeast['low_attenuation']) + ) + form.yeast.form.high_attenuation.data = ( + Decimal(yeast['high_attenuation']) + ) + if 'type' in yeast: + form.yeast.form.type.data = yeast['type'] + if 'lab' in yeast: + form.yeast.form.lab.data = yeast['lab'] + if 'code' in yeast: + form.yeast.form.code.data = yeast['code'] + if 'flocculation' in yeast: + form.yeast.form.flocculation.data = yeast['flocculation'] + if 'min_temperature' in yeast: + form.yeast.form.min_temperature.data = ( + Decimal(yeast['min_temperature']) + ) + if 'max_temperature' in yeast: + form.yeast.form.max_temperature.data = ( + Decimal(yeast['max_temperature']) + ) + if 'abv_tolerance' in yeast: + form.yeast.form.abv_tolerance.data = ( + Decimal(yeast['abv_tolerance']) + ) + + return render_template('recipes/update.html', form=form, + id=id, rev=recipe['_rev']) diff --git a/src/humulus/templates/recipes/_macros.html b/src/humulus/templates/recipes/_macros.html new file mode 100644 index 0000000..ec2a358 --- /dev/null +++ b/src/humulus/templates/recipes/_macros.html @@ -0,0 +1,130 @@ +{#- + Copyright 2019 Mike Shoup + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-#} +{% from "_macros.html" import render_field_with_errors %} + +{# + Used to render a form for creating/updating a recipe +#} +{% macro render_recipe_form(form, action_url, button_content) %} +
+ {{ form.hidden_tag() }} + {#- + Recipe Details + -#} +
+
{{ render_field_with_errors(form.name) }}
+
{{ render_field_with_errors(form.efficiency) }}
+
{{ render_field_with_errors(form.volume) }}
+
+ {#- + Fermentable Ingredients + -#} +

Fermentables

+
+ {% for fermentable in form.fermentables %} +
+
+
+ {{ render_field_with_errors(fermentable.form.name, 'form-control-sm') }} +
+
+
+
{{ render_field_with_errors(fermentable.form.type, 'form-control-sm') }}
+
{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm') }}
+
{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm') }}
+
{{ render_field_with_errors(fermentable.form.color, 'form-control-sm') }}
+
+
+
+ +
+
+
+ {% endfor %} +
+
+
+ +
+
+ {#- + Hop ingredients + -#} +

Hops

+
+ {% for hop in form.hops %} +
+
+
+ {{ render_field_with_errors(hop.form.name, 'form-control-sm') }} +
+
+
+
{{ render_field_with_errors(hop.form.use, 'form-control-sm') }}
+
{{ render_field_with_errors(hop.form.alpha, 'form-control-sm') }}
+
{{ render_field_with_errors(hop.form.duration, 'form-control-sm') }}
+
{{ render_field_with_errors(hop.form.amount, 'form-control-sm') }}
+
+
+
+ +
+
+
+ {% endfor %} +
+
+
+ +
+
+ {#- + Recipe Yeast + -#} +

Yeast

+
+
+
+
{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.type, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}
+
+
+
{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}
+
{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}
+
+
+
+ {#- + Recipe Notes + -#} +
+
{{ render_field_with_errors(form.notes) }}
+
+ {#- + Submit recipe + -#} +
+
+
+
+ +{% endmacro %} diff --git a/src/humulus/templates/recipes/create.html b/src/humulus/templates/recipes/create.html index 1485cec..417288e 100644 --- a/src/humulus/templates/recipes/create.html +++ b/src/humulus/templates/recipes/create.html @@ -13,119 +13,12 @@ See the License for the specific language governing permissions and limitations under the License. -#} -{% from "_macros.html" import render_field_with_errors %} +{% from "recipes/_macros.html" import render_recipe_form %} {% extends '_base.html' %} {% block title %}Create Recipe{% endblock %} {% block body %}

Create a new recipe

-
- {{ form.hidden_tag() }} - {#- - Recipe Details - -#} -
-
{{ render_field_with_errors(form.name) }}
-
{{ render_field_with_errors(form.efficiency) }}
-
{{ render_field_with_errors(form.volume) }}
-
- {#- - Fermentable Ingredients - -#} -

Fermentables

-
- {% for fermentable in form.fermentables %} -
-
-
- {{ render_field_with_errors(fermentable.form.name, 'form-control-sm') }} -
-
-
-
{{ render_field_with_errors(fermentable.form.type, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm') }}
-
{{ render_field_with_errors(fermentable.form.color, 'form-control-sm') }}
-
-
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- {#- - Hop ingredients - -#} -

Hops

-
- {% for hop in form.hops %} -
-
-
- {{ render_field_with_errors(hop.form.name, 'form-control-sm') }} -
-
-
-
{{ render_field_with_errors(hop.form.use, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.alpha, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.duration, 'form-control-sm') }}
-
{{ render_field_with_errors(hop.form.amount, 'form-control-sm') }}
-
-
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- {#- - Recipe Yeast - -#} -

Yeast

-
-
-
-
{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.type, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}
-
-
-
{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}
-
{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}
-
-
-
- {#- - Recipe Notes - -#} -
-
{{ render_field_with_errors(form.notes) }}
-
- {#- - Submit recipe - -#} -
-
-
-
- +{{ render_recipe_form(form, url_for('recipes.create'), 'Create recipe') }} {% endblock %} diff --git a/src/humulus/templates/recipes/update.html b/src/humulus/templates/recipes/update.html index e69de29..ac4b53a 100644 --- a/src/humulus/templates/recipes/update.html +++ b/src/humulus/templates/recipes/update.html @@ -0,0 +1,24 @@ +{#- + Copyright 2019 Mike Shoup + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +-#} +{% from "recipes/_macros.html" import render_recipe_form %} + +{% extends '_base.html' %} +{% block title %}Update | {{ form.name.data }}{% endblock %} + +{% block body %} +

{{ form.name.data }}

+{{ render_recipe_form(form, url_for('recipes.update', id=id, rev=rev), 'Update recipe') }} +{% endblock %} diff --git a/tests/test_recipes.py b/tests/test_recipes.py index cc40215..b687b34 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -14,7 +14,7 @@ from decimal import Decimal -from humulus.couch import get_db, get_doc +from humulus.couch import get_db, get_doc, put_doc from humulus.recipes import FermentableForm, HopForm, RecipeForm, YeastForm @@ -45,37 +45,84 @@ def test_create(client, app): def test_update(client, app): """Test success in updating a recipe document.""" - # Test GET - id = 'awesome-lager' - response = client.get('/recipes/update/{}'.format(id)) - - # Get the doc, test a change, then post the update with app.app_context(): - data = get_doc(id) - # Remove values that should not be posted with the data - rev = data.pop('_rev') # Save the revision, will be used later - data.pop('_id') - # Make a change to the data - data['name'] = '{} TEST'.format(data['name']) + doc = put_doc({ + '_id': 'test-update', + 'name': 'Test Update', + 'efficiency': '60', + 'volume': '5.5', + 'notes': 'This is a test', + 'fermentables': [], + 'hops': [] + }) - # Test valid response - response = client.post('/recipes/update/{}'.format(id), - query_string={'rev': rev}, data=data) + # Test GET + response = client.get('/recipes/update/test-update') + assert response.status_code == 200 + assert b'Test Update' in response.data + + # Make a change to the doc + data = { + 'name': 'New Name', + 'efficiency': '60', + 'volume': '5.5', + 'notes': 'This is a test' + } + response = client.post('/recipes/update/test-update', + query_string={'rev': doc['_rev']}, data=data) assert response.status_code == 302 with client.session_transaction() as session: - flash_message = dict(session['_flashes']).get('error') + flash_message = dict(session['_flashes']).pop('danger', None) assert flash_message is None - # Validate document update with app.app_context(): - updated = get_doc(id) - assert updated['name'] == data['name'] + updated = get_doc('test-update') + assert updated['name'] == 'New Name' + + # Test form is filled correctly with ingredients + with app.app_context(): + doc = put_doc({ + '_id': 'test-update-1', + 'name': 'Test Update With Ingredients', + 'efficiency': '60', + 'volume': '5.5', + 'notes': 'This is a test', + 'fermentables': [{ + 'name': '2-row', + 'type': 'Grain', + 'amount': '5', + 'ppg': '37', + 'color': '1.8' + }], + 'hops': [{ + 'name': 'Nugget', + 'use': 'Boil', + 'alpha': '5.5', + 'duration': '60', + 'amount': '1' + }], + 'yeast': { + 'name': 'California Ale', + 'low_attenuation': '70', + 'high_attenuation': '80', + 'type': 'Liquid', + 'lab': 'Inland Island', + 'code': 'INIS-001', + 'flocculation': 'Medium', + 'min_temperature': '60', + 'max_temperature': '70', + 'abv_tolerance': '15' + } + }) + response = client.get('/recipes/update/test-update-1') + assert b'Test Update With Ingredients' in response.data + # Test response without valid/conflicted rev - response = client.post('/recipes/update/{}'.format(id), + response = client.post('/recipes/update/test-update', query_string={'rev': ''}, data=data) assert response.status_code == 302 with client.session_transaction() as session: - flash_message = dict(session['_flashes']).get('error') + flash_message = dict(session['_flashes']).pop('danger', None) assert 'Update conflict' in flash_message @@ -145,6 +192,8 @@ def test_recipe_form_doc(app): 'efficiency': '65', 'volume': '5.5', 'notes': 'This is a test', + 'fermentables': [], + 'hops': [], } ferm = FermentableForm()