From a7e04c4ec6d9cd890b3b12fcbadac5784370465f Mon Sep 17 00:00:00 2001 From: Mike Shoup Date: Fri, 5 Jul 2019 19:57:16 -0600 Subject: [PATCH] Import/Export recipe JSON (#9) * Closes #5 * Add JSON export * Build a dev docker image * Add ability to import JSON file. --- .drone.yml | 17 +- src/humulus/recipes.py | 145 +++++++++++------- src/humulus/templates/_macros.html | 4 +- .../templates/recipes/create_json.html | 35 +++++ src/humulus/templates/recipes/index.html | 1 + src/humulus/templates/recipes/info.html | 3 +- tests/test_recipes.py | 43 ++++++ 7 files changed, 189 insertions(+), 59 deletions(-) create mode 100644 src/humulus/templates/recipes/create_json.html diff --git a/.drone.yml b/.drone.yml index 9193253..73164af 100644 --- a/.drone.yml +++ b/.drone.yml @@ -33,7 +33,22 @@ kind: pipeline name: publish steps: -- name: docker +- name: docker-dev + image: plugins/docker + settings: + username: + from_secret: DOCKER_USERNAME + password: + from_secret: DOCKER_PASSWORD + repo: shouptech/humulus + tags: + - ${DRONE_COMMIT_SHA} + when: + event: + exclude: + - pull_request + +- name: docker-latest image: plugins/docker settings: username: diff --git a/src/humulus/recipes.py b/src/humulus/recipes.py index 97d0978..887cd75 100644 --- a/src/humulus/recipes.py +++ b/src/humulus/recipes.py @@ -14,10 +14,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json from decimal import Decimal -from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask import (Blueprint, flash, redirect, render_template, jsonify, + request, url_for) from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList, FormField, SelectField) from wtforms.validators import DataRequired, Optional @@ -185,6 +188,65 @@ class RecipeForm(FlaskForm): recipe['yeast'] = self.yeast.doc return recipe + def copyfrom(self, data): + """Copies from a dictionary (data) into the current object""" + self.name.data = data['name'] + self.efficiency.data = Decimal(data['efficiency']) + self.volume.data = Decimal(data['volume']) + self.notes.data = data['notes'] + + for fermentable in data['fermentables']: + self.fermentables.append_entry({ + 'name': fermentable['name'], + 'type': fermentable['type'], + 'amount': Decimal(fermentable['amount']), + 'ppg': Decimal(fermentable['ppg']), + 'color': Decimal(fermentable['color']) + }) + + for hop in data['hops']: + self.hops.append_entry({ + 'name': hop['name'], + 'use': hop['use'], + 'alpha': Decimal(hop['alpha']), + 'duration': Decimal(hop['duration']), + 'amount': Decimal(hop['amount']), + }) + + if 'yeast' in data: + yeast = data['yeast'] + self.yeast.form.name.data = yeast['name'] + self.yeast.form.low_attenuation.data = ( + Decimal(yeast['low_attenuation']) + ) + self.yeast.form.high_attenuation.data = ( + Decimal(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: + self.yeast.form.min_temperature.data = ( + Decimal(yeast['min_temperature']) + ) + if 'max_temperature' in yeast: + self.yeast.form.max_temperature.data = ( + Decimal(yeast['max_temperature']) + ) + if 'abv_tolerance' in yeast: + self.yeast.form.abv_tolerance.data = ( + Decimal(yeast['abv_tolerance']) + ) + + +class ImportForm(FlaskForm): + upload = FileField(validators=[FileRequired()]) + @bp.route('/') def index(): @@ -218,11 +280,37 @@ def create(): return render_template('recipes/create.html', form=form) +@bp.route('/create/json', methods=('GET', 'POST')) +@login_required +def create_json(): + form = ImportForm() + if form.validate_on_submit(): + recipe = RecipeForm() + try: + recipe.copyfrom(json.load(form.upload.data)) + except Exception as e: + flash('Error converting data from JSON: {}'.format(e), 'warning') + return render_template('recipes/create_json.html', form=form) + response = put_doc(recipe.doc) + return redirect(url_for('recipes.info', id=response['_id'])) + return render_template('recipes/create_json.html', form=form) + + @bp.route('/info/') def info(id): return render_template('recipes/info.html', recipe=get_doc_or_404(id)) +@bp.route('/info//json') +def info_json(id): + recipe = get_doc_or_404(id) + # Remove fields specific not intended for export + recipe.pop('_id') + recipe.pop('_rev') + recipe.pop('$type') + return jsonify(recipe) + + @bp.route('/delete/', methods=('POST',)) @login_required def delete(id): @@ -255,60 +343,7 @@ 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'] - - 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']) - ) + form.copyfrom(recipe) return render_template('recipes/update.html', form=form, id=id, rev=recipe['_rev']) diff --git a/src/humulus/templates/_macros.html b/src/humulus/templates/_macros.html index 8ce57ea..7027bd0 100644 --- a/src/humulus/templates/_macros.html +++ b/src/humulus/templates/_macros.html @@ -18,9 +18,9 @@ Macro for rendering WTF fields. field is a FormField, and class is an additional class that gets added onto the field. #} -{% macro render_field_with_errors(field, class='') %} +{% macro render_field_with_errors(field, class='', base_class='form-control') %}
- {{ field.label }} {{ field(class_='form-control ' + class, **kwargs)|safe }} + {{ field.label }} {{ field(class_=base_class + ' ' + class, **kwargs)|safe }} {% if field.errors %} {% for error in field.errors %}
diff --git a/src/humulus/templates/recipes/create_json.html b/src/humulus/templates/recipes/create_json.html new file mode 100644 index 0000000..46b55b3 --- /dev/null +++ b/src/humulus/templates/recipes/create_json.html @@ -0,0 +1,35 @@ +{#- + 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 %} + +{% extends '_base.html' %} +{% block title %}Import Recipe{% endblock %} + +{% block body %} +

Import JSON Recipe

+ +
+ {{ form.hidden_tag() }} + {{ render_field_with_errors(form.upload, base_class='form-control-file') }} + +
+ + +{% endblock %} diff --git a/src/humulus/templates/recipes/index.html b/src/humulus/templates/recipes/index.html index 11e06ab..47db4d2 100644 --- a/src/humulus/templates/recipes/index.html +++ b/src/humulus/templates/recipes/index.html @@ -24,6 +24,7 @@ {% if session.logged_in %} {% endif %}
diff --git a/src/humulus/templates/recipes/info.html b/src/humulus/templates/recipes/info.html index 28df021..13252ba 100644 --- a/src/humulus/templates/recipes/info.html +++ b/src/humulus/templates/recipes/info.html @@ -173,5 +173,6 @@
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }} {% endif %} -
Recipe revision: {{ recipe._rev }}
+ +
Recipe revision: {{ recipe._rev }}
{% endblock %} diff --git a/tests/test_recipes.py b/tests/test_recipes.py index d5ec43e..d860fcd 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -12,7 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json from decimal import Decimal +from io import BytesIO from humulus.couch import get_db, get_doc, put_doc from humulus.recipes import FermentableForm, HopForm, RecipeForm, YeastForm @@ -168,6 +170,19 @@ def test_info(client): assert b'Awesome Lager' in response.data +def test_info_json(client): + """Test success in retrieving a JSON recipe.""" + # Validate 404 + response = client.get('/recipes/info/thisdoesnotexist/json') + assert response.status_code == 404 + + # Validate response for existing doc + response = client.get('/recipes/info/awesome-lager/json') + assert response.status_code == 200 + assert response.is_json + assert response.get_json()['name'] == 'Awesome Lager' + + def test_yeast_form_doc(app): """Evaluates conditionals in generation of doc from a yeast form.""" yeast = YeastForm() @@ -291,3 +306,31 @@ def test_recipe_delete(client, auth): response = client.post('/recipes/delete/awesome-lager') response = client.get('/recipes/info/awesome-lager') assert response.status_code == 404 + + +def test_recipe_create_json(client, sample_recipes, auth): + """Test uploading JSON recipe.""" + # Test GET without logging in + response = client.get('/recipes/create/json') + assert response.status_code == 302 + + # Test GET after logging in + auth.login() + response = client.get('/recipes/create/json') + assert response.status_code == 200 + + # Test upload some good data + data = { + 'upload': (BytesIO(json.dumps(sample_recipes['sweetstout']).encode()), + 'sweetstout.json') + } + response = client.post('/recipes/create/json', buffered=True, + content_type='multipart/form-data', data=data) + assert response.status_code == 302 + assert 'recipes/info/sweet-stout' in response.headers['Location'] + + # Test upload with some bad data + data = {'upload': (BytesIO(b'NOT JSON'), 'file')} + response = client.post('/recipes/create/json', buffered=True, + content_type='multipart/form-data', data=data) + assert response.status_code == 200