1
0
Fork 0
mirror of https://github.com/shouptech/humulus.git synced 2026-02-03 15:59:43 +00:00

Add complete recipe update capability

This commit is contained in:
Emma 2019-06-23 21:18:53 -06:00
parent 6233b089cf
commit 5f7639232c
5 changed files with 290 additions and 141 deletions

View file

@ -30,7 +30,7 @@ bp = Blueprint('recipes', __name__, url_prefix='/recipes')
class FermentableForm(Form): class FermentableForm(Form):
"""Form for fermentables. """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. is never used by itself.
""" """
name = StringField('Name', validators=[DataRequired()]) name = StringField('Name', validators=[DataRequired()])
@ -60,7 +60,7 @@ class FermentableForm(Form):
class HopForm(Form): class HopForm(Form):
"""Form for hops. """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. is never used by itself.
""" """
name = StringField('Name', validators=[DataRequired()]) name = StringField('Name', validators=[DataRequired()])
@ -89,7 +89,7 @@ class HopForm(Form):
class YeastForm(Form): class YeastForm(Form):
"""Form for yeast. """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. is never used by itself.
""" """
name = StringField('Name', validators=[Optional()]) name = StringField('Name', validators=[Optional()])
@ -173,10 +173,8 @@ class RecipeForm(FlaskForm):
'notes': self.notes.data 'notes': self.notes.data
} }
if len(self.fermentables) > 0: recipe['fermentables'] = [f.doc for f in self.fermentables]
recipe['fermentables'] = [f.doc for f in self.fermentables] recipe['hops'] = [h.doc for h in self.hops]
if len(self.hops) > 0:
recipe['hops'] = [h.doc for h in self.hops]
if self.yeast.doc['name']: if self.yeast.doc['name']:
recipe['yeast'] = self.yeast.doc recipe['yeast'] = self.yeast.doc
return recipe return recipe
@ -208,9 +206,8 @@ def delete(id):
@bp.route('/update/<id>', methods=('GET', 'POST')) @bp.route('/update/<id>', methods=('GET', 'POST'))
def update(id): def update(id):
# Get the recipe from the database and validate it is the same revision # Get the recipe from the database and validate it is the same revision
recipe = get_doc_or_404(id)
form = RecipeForm() form = RecipeForm()
recipe = get_doc_or_404(id)
if form.validate_on_submit(): if form.validate_on_submit():
if recipe['_rev'] != request.args.get('rev', None): if recipe['_rev'] != request.args.get('rev', None):
flash( flash(
@ -218,7 +215,7 @@ def update(id):
'Update conflict for recipe: {}. ' 'Update conflict for recipe: {}. '
'Your changes have been lost.'.format(recipe['name']) 'Your changes have been lost.'.format(recipe['name'])
), ),
'error' 'danger'
) )
return redirect(url_for('recipes.info', id=id)) return redirect(url_for('recipes.info', id=id))
# Copy values from submitted form to the existing recipe and save # 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') flash('Updated recipe: {}'.format(form.name.data), 'success')
return redirect(url_for('recipes.info', id=id)) 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'])

View file

@ -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 method="POST" action="{{ action_url }}">
{{ form.hidden_tag() }}
{#-
Recipe Details
-#}
<div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.name) }}</div>
<div class="col-sm-3">{{ render_field_with_errors(form.efficiency) }}</div>
<div class="col-sm-3">{{ render_field_with_errors(form.volume) }}</div>
</div>
{#-
Fermentable Ingredients
-#}
<div class="row"><div class="col"><h3>Fermentables</h3></div></div>
<div id="ferms" data-length="{{ form.fermentables|length }}">
{% for fermentable in form.fermentables %}
<div class="border pl-2 pr-2 pt-1 pb-1 ferm-form" data-index="{{ loop.index0 }}">
<div class="row">
<div class="col">
{{ render_field_with_errors(fermentable.form.name, 'form-control-sm') }}
</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(fermentable.form.type, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.color, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="float-right btn btn-sm btn-outline-danger rem-ferm">Remove fermentable</button>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pt-1 pb-1">
<div class="col">
<button type="button" id="add-ferm" class="btn btn-secondary btn-sm">Add a fermentable</button>
</div>
</div>
{#-
Hop ingredients
-#}
<div class="row"><div class="col"><h3>Hops</h3></div></div>
<div id="hops" data-length="{{ form.hops|length }}">
{% for hop in form.hops %}
<div class="border pl-2 pr-2 pt-1 pb-1 hop-form" data-index="{{ loop.index0 }}">
<div class="row">
<div class="col">
{{ render_field_with_errors(hop.form.name, 'form-control-sm') }}
</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(hop.form.use, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.alpha, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.duration, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.amount, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="float-right btn btn-sm btn-outline-danger rem-hop">Remove Hop</button>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pt-1 pb-1">
<div class="col">
<button type="button" id="add-hop" class="btn btn-secondary btn-sm">Add a hop</button>
</div>
</div>
{#-
Recipe Yeast
-#}
<div class="row"><div class="col"><h3>Yeast</h3></div></div>
<div id="yeast">
<div class="border pl-2 pr-2 pt-1 pb-1 yeast-form">
<div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.type, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}</div>
</div>
</div>
</div>
{#-
Recipe Notes
-#}
<div class="row">
<div class="col">{{ render_field_with_errors(form.notes) }}</div>
</div>
{#-
Submit recipe
-#}
<div class="row">
<div class="col"><button type="submit" class="btn btn-primary">{{ button_content }}</button></div>
</div>
</form>
<script src="{{ url_for('static', filename='recipes.js') }}"></script>
{% endmacro %}

View file

@ -13,119 +13,12 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
-#} -#}
{% from "_macros.html" import render_field_with_errors %} {% from "recipes/_macros.html" import render_recipe_form %}
{% extends '_base.html' %} {% extends '_base.html' %}
{% block title %}Create Recipe{% endblock %} {% block title %}Create Recipe{% endblock %}
{% block body %} {% block body %}
<div class="row"><h1>Create a new recipe</h1></div> <div class="row"><h1>Create a new recipe</h1></div>
<form method="POST" action="{{ url_for('recipes.create') }}"> {{ render_recipe_form(form, url_for('recipes.create'), 'Create recipe') }}
{{ form.hidden_tag() }}
{#-
Recipe Details
-#}
<div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.name) }}</div>
<div class="col-sm-3">{{ render_field_with_errors(form.efficiency) }}</div>
<div class="col-sm-3">{{ render_field_with_errors(form.volume) }}</div>
</div>
{#-
Fermentable Ingredients
-#}
<div class="row"><div class="col"><h3>Fermentables</h3></div></div>
<div id="ferms" data-length="{{ form.fermentables|length }}">
{% for fermentable in form.fermentables %}
<div class="border pl-2 pr-2 pt-1 pb-1 ferm-form" data-index="{{ loop.index0 }}">
<div class="row">
<div class="col">
{{ render_field_with_errors(fermentable.form.name, 'form-control-sm') }}
</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(fermentable.form.type, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.amount, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.ppg, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(fermentable.form.color, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="float-right btn btn-sm btn-outline-danger rem-ferm">Remove fermentable</button>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pt-1 pb-1">
<div class="col">
<button type="button" id="add-ferm" class="btn btn-secondary btn-sm">Add a fermentable</button>
</div>
</div>
{#-
Hop ingredients
-#}
<div class="row"><div class="col"><h3>Hops</h3></div></div>
<div id="hops" data-length="{{ form.hops|length }}">
{% for hop in form.hops %}
<div class="border pl-2 pr-2 pt-1 pb-1 hop-form" data-index="{{ loop.index0 }}">
<div class="row">
<div class="col">
{{ render_field_with_errors(hop.form.name, 'form-control-sm') }}
</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(hop.form.use, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.alpha, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.duration, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(hop.form.amount, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="float-right btn btn-sm btn-outline-danger rem-hop">Remove Hop</button>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row pt-1 pb-1">
<div class="col">
<button type="button" id="add-hop" class="btn btn-secondary btn-sm">Add a hop</button>
</div>
</div>
{#-
Recipe Yeast
-#}
<div class="row"><div class="col"><h3>Yeast</h3></div></div>
<div id="yeast">
<div class="border pl-2 pr-2 pt-1 pb-1 yeast-form">
<div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.yeast.form.name, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.type, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.lab, 'form-control-sm') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.yeast.form.code, 'form-control-sm') }}</div>
</div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.low_attenuation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.high_attenuation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.flocculation, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.min_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.max_temperature, 'form-control-sm') }}</div>
<div class="col-sm">{{ render_field_with_errors(form.yeast.form.abv_tolerance, 'form-control-sm') }}</div>
</div>
</div>
</div>
{#-
Recipe Notes
-#}
<div class="row">
<div class="col">{{ render_field_with_errors(form.notes) }}</div>
</div>
{#-
Submit recipe
-#}
<div class="row">
<div class="col"><button type="submit" class="btn btn-primary">Create Recipe</button></div>
</div>
</form>
<script src="{{ url_for('static', filename='recipes.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -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 %}
<div class="row"><h1>{{ form.name.data }}</h1></div>
{{ render_recipe_form(form, url_for('recipes.update', id=id, rev=rev), 'Update recipe') }}
{% endblock %}

View file

@ -14,7 +14,7 @@
from decimal import Decimal 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 from humulus.recipes import FermentableForm, HopForm, RecipeForm, YeastForm
@ -45,37 +45,84 @@ def test_create(client, app):
def test_update(client, app): def test_update(client, app):
"""Test success in updating a recipe document.""" """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(): with app.app_context():
data = get_doc(id) doc = put_doc({
# Remove values that should not be posted with the data '_id': 'test-update',
rev = data.pop('_rev') # Save the revision, will be used later 'name': 'Test Update',
data.pop('_id') 'efficiency': '60',
# Make a change to the data 'volume': '5.5',
data['name'] = '{} TEST'.format(data['name']) 'notes': 'This is a test',
'fermentables': [],
'hops': []
})
# Test valid response # Test GET
response = client.post('/recipes/update/{}'.format(id), response = client.get('/recipes/update/test-update')
query_string={'rev': rev}, data=data) 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 assert response.status_code == 302
with client.session_transaction() as session: 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 assert flash_message is None
# Validate document update
with app.app_context(): with app.app_context():
updated = get_doc(id) updated = get_doc('test-update')
assert updated['name'] == data['name'] 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 # 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) query_string={'rev': ''}, data=data)
assert response.status_code == 302 assert response.status_code == 302
with client.session_transaction() as session: 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 assert 'Update conflict' in flash_message
@ -145,6 +192,8 @@ def test_recipe_form_doc(app):
'efficiency': '65', 'efficiency': '65',
'volume': '5.5', 'volume': '5.5',
'notes': 'This is a test', 'notes': 'This is a test',
'fermentables': [],
'hops': [],
} }
ferm = FermentableForm() ferm = FermentableForm()