mirror of
https://github.com/shouptech/humulus.git
synced 2026-02-03 14:59:43 +00:00
Import/Export recipe JSON (#9)
* Closes #5 * Add JSON export * Build a dev docker image * Add ability to import JSON file.
This commit is contained in:
parent
022d36041c
commit
a7e04c4ec6
7 changed files with 189 additions and 59 deletions
17
.drone.yml
17
.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:
|
||||
|
|
|
|||
|
|
@ -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/<id>')
|
||||
def info(id):
|
||||
return render_template('recipes/info.html', recipe=get_doc_or_404(id))
|
||||
|
||||
|
||||
@bp.route('/info/<id>/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/<id>', 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'])
|
||||
|
|
|
|||
|
|
@ -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') %}
|
||||
<div class="form-group">
|
||||
{{ 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 %}
|
||||
<div class="container">
|
||||
|
|
|
|||
35
src/humulus/templates/recipes/create_json.html
Normal file
35
src/humulus/templates/recipes/create_json.html
Normal file
|
|
@ -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 %}
|
||||
<div class="row"><h1>Import JSON Recipe</h1></div>
|
||||
|
||||
<form action="{{ url_for('recipes.create_json') }}" method="POST" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field_with_errors(form.upload, base_class='form-control-file') }}
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</form>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="{{ url_for('recipes.index') }}" class="mt-2 btn btn-secondary btn-sm">Back to recipe list</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
{% if session.logged_in %}
|
||||
<div class="row">
|
||||
<a href="{{ url_for('recipes.create') }}" class="btn btn-primary btn-sm mt-1 mb-2 ml-auto">Create a recipe</a>
|
||||
<a href="{{ url_for('recipes.create_json') }}" class="btn btn-secondary btn-sm mt-1 mb-2 ml-1">Import JSON recipe</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
|
|
|
|||
|
|
@ -173,5 +173,6 @@
|
|||
</div>
|
||||
{{ render_delete_modal(url_for('recipes.delete', id=recipe._id), 'deleteRecipe', recipe.name) }}
|
||||
{% endif %}
|
||||
<div class="row small mt-4">Recipe revision: {{ recipe._rev }}</div>
|
||||
<div class="row mt-4"><a href="{{ url_for('recipes.info_json', id=recipe._id) }}">Export JSON</a></div>
|
||||
<div class="row">Recipe revision: {{ recipe._rev }}</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue