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

Create a recipe

This commit is contained in:
Emma 2019-06-22 08:36:35 -06:00
parent 02b15956d9
commit 5af167e394
8 changed files with 298 additions and 51 deletions

View file

@ -12,32 +12,4 @@
# 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.
import os from humulus.app import create_app
from flask import Flask
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__, instance_relative_config=True)
if test_config is not None:
# Load the test config if provided
app.config.from_mapping(test_config)
else:
# Load config from configuration provided via ENV
app.config.from_envvar('HUMULUS_SETTINGS')
from . import couch
couch.init_app(app)
# Register blueprint for index page
from . import home
app.register_blueprint(home.bp)
app.add_url_rule('/', endpoint='index')
# Register blueprint for recipes
from . import recipes
app.register_blueprint(recipes.bp)
return app

43
src/humulus/app.py Normal file
View file

@ -0,0 +1,43 @@
# 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.
import os
from flask import Flask
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__, instance_relative_config=True)
if test_config is not None:
# Load the test config if provided
app.config.from_mapping(test_config)
else:
# Load config from configuration provided via ENV
app.config.from_envvar('HUMULUS_SETTINGS')
from . import couch
couch.init_app(app)
# Register blueprint for index page
from . import home
app.register_blueprint(home.bp)
app.add_url_rule('/', endpoint='index')
# Register blueprint for recipes
from . import recipes
app.register_blueprint(recipes.bp)
return app

View file

@ -18,19 +18,58 @@ from decimal import Decimal
from flask import Blueprint, flash, redirect, render_template, url_for from flask import Blueprint, flash, redirect, render_template, url_for
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, DecimalField, TextAreaField from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList,
FormField, SelectField)
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from humulus.couch import get_doc_or_404, put_doc from humulus.couch import get_doc_or_404, put_doc
bp = Blueprint('recipes', __name__, url_prefix='/recipes') bp = Blueprint('recipes', __name__, url_prefix='/recipes')
class RecipeForm(FlaskForm):
class FermentableForm(Form):
"""Form for fermentables.
CSRF is disabled for this subform (using `Form as parent class) because it
is never used by itself.
"""
name = StringField('Name', validators=[DataRequired()]) name = StringField('Name', validators=[DataRequired()])
efficiency = DecimalField('Batch Efficiency', validators=[DataRequired()]) type = SelectField('Type', validators=[DataRequired()],
volume = DecimalField('Batch Volume', validators=[DataRequired()]) choices=[(c, c) for c in ['Grain', 'LME', 'DME', 'Sugar',
'Non-fermentable', 'Other']])
amount = DecimalField('Amount (lb)', validators=[DataRequired()])
ppg = DecimalField('PPG', validators=[DataRequired()])
color = DecimalField('Color (°L)', validators=[DataRequired()])
@property
def doc(self):
"""Returns a dictionary that can be deserialized into JSON.
Used for putting into CouchDB.
"""
return {
'name': self.name.data,
'type': self.type.data,
'amount': str(self.amount.data),
'ppg': str(self.ppg.data),
'color': str(self.color.data)
}
class RecipeForm(FlaskForm):
"""Form for recipes."""
name = StringField('Name', validators=[DataRequired()])
efficiency = DecimalField('Batch Efficiency (%)', validators=[DataRequired()])
volume = DecimalField('Batch Volume (gal)', validators=[DataRequired()])
notes = TextAreaField('Notes') notes = TextAreaField('Notes')
fermentables = FieldList(
FormField(FermentableForm),
min_entries=0,
max_entries=20
)
@property @property
def doc(self): def doc(self):
"""Returns a dictionary that can be deserialized into JSON. """Returns a dictionary that can be deserialized into JSON.
@ -41,7 +80,8 @@ class RecipeForm(FlaskForm):
'name': self.name.data, 'name': self.name.data,
'efficiency': str(self.efficiency.data), 'efficiency': str(self.efficiency.data),
'volume': str(self.volume.data), 'volume': str(self.volume.data),
'notes': self.notes.data 'notes': self.notes.data,
'fermentables': [f.doc for f in self.fermentables]
} }

View file

@ -1,4 +1,4 @@
.bd-placeholder-img { .placeholder-img {
font-size: 1.125rem; font-size: 1.125rem;
text-anchor: middle; text-anchor: middle;
-webkit-user-select: none; -webkit-user-select: none;
@ -8,7 +8,7 @@
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.bd-placeholder-img-lg { .placeholder-img-lg {
font-size: 3.5rem; font-size: 3.5rem;
} }
} }

View file

@ -40,7 +40,7 @@ limitations under the License.
</ul> </ul>
</div> </div>
</nav> </nav>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<main role="main" class="container"> <main role="main" class="container">
{% block alert %} {% block alert %}
{% for category, message in get_flashed_messages(with_categories=true) %} {% for category, message in get_flashed_messages(with_categories=true) %}
@ -54,7 +54,6 @@ limitations under the License.
<!-- Bootstrap JS --> <!-- Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</body> </body>

View file

@ -13,9 +13,9 @@
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.
-#} -#}
{% macro render_field_with_errors(field) %} {% macro render_field_with_errors(field, size='') %}
<div class="form-group"> <div class="form-group">
{{ field.label }} {{ field(class_='form-control', **kwargs)|safe }} {{ field.label }} {{ field(class_='form-control ' + size, **kwargs)|safe }}
{% if field.errors %} {% if field.errors %}
{% for error in field.errors %} {% for error in field.errors %}
<div class="container"> <div class="container">

View file

@ -20,14 +20,165 @@ limitations under the License.
{% block body %} {% block body %}
<div class="row"><h1>Create a new recipe</h1></div> <div class="row"><h1>Create a new recipe</h1></div>
<div class="row">
<form method="POST" action="{{ url_for('recipes.create') }}"> <form method="POST" action="{{ url_for('recipes.create') }}">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }} <div class="row">
{{ render_field_with_errors(form.efficiency) }} <div class="col-sm-6">{{ render_field_with_errors(form.name) }}</div>
{{ render_field_with_errors(form.volume) }} <div class="col-sm-3">{{ render_field_with_errors(form.efficiency) }}</div>
{{ render_field_with_errors(form.notes) }} <div class="col-sm-3">{{ render_field_with_errors(form.volume) }}</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div> </div>
<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>
<div class="row">
<div class="col">{{ render_field_with_errors(form.notes) }}</div>
</div>
<div class="row">
<div class="col"><button type="submit" class="btn btn-primary">Create Recipe</button></div>
</div>
</form>
<script>
// Remove a fermentable
function removeFerm() {
var $removedForm = $(this).closest('.ferm-form');
var removedIndex = parseInt($removedForm.data('index'));
$removedForm.remove();
var $fermsDiv = $('#ferms');
$fermsDiv.data('length', $fermsDiv.data('length') - 1);
console.log('calling adjust');
adjustFermIndices(removedIndex);
}
// Add a fermentable
function addFerm() {
var $fermsDiv = $('#ferms');
var fermsLength = $fermsDiv.data('length');
if (fermsLength == 20) {
window.alert("Can't have more than 20 fermentables.");
return;
}
var newFerm = `<div class="border pl-2 pr-2 pt-1 pb-1 ferm-form" data-index="${fermsLength}">` +
// Name field
'<div class="row"><div class="col"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-name">Name</label>` +
`<input class="form-control form-control-sm" id="fermentables-${fermsLength}-name"` +
` name="fermentables-${fermsLength}-name" required type="text" value="">` +
'</div></div></div>' + // End name field
'<div class="row">' +
// Type field
'<div class="col-sm"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-type">Type</label>` +
`<select class="form-control form-control-sm" id="fermentables-${fermsLength}-type"` +
` name="fermentables-${fermsLength}-type" required>` +
'<option value="Grain">Grain</option>' +
'<option value="LME">LME</option>' +
'<option value="DME">DME</option>' +
'<option value="Sugar">Sugar</option>' +
'<option value="Non-fermentable">Non-fermentable</option>' +
'<option value="Other">Other</option></select>' +
'</div></div>' + // End type field
// Amount field
'<div class="col-sm"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-amount">Amount (lb)</label>` +
`<input class="form-control form-control-sm" id="fermentables-${fermsLength}-amount"` +
` name="fermentables-${fermsLength}-amount" required type="text" value="">` +
'</div></div>' + // End amount field
// PPG field
'<div class="col-sm"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-ppg">PPG</label>` +
`<input class="form-control form-control-sm" id="fermentables-${fermsLength}-ppg"` +
` name="fermentables-${fermsLength}-ppg" required type="text" value="">` +
'</div></div>' + // End PPG field
// Color field
'<div class="col-sm"><div class="form-group">' +
`<label for="fermentables-${fermsLength}-color">Color (°L)</label>` +
`<input class="form-control form-control-sm" id="fermentables-${fermsLength}-color"` +
` name="fermentables-${fermsLength}-color" required type="text" value="">` +
'</div></div>' + // End PPG field
'</div>' +
// Remove button
'<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>' + // End remove button
'</div>';
$fermsDiv.append(newFerm);
$fermsDiv.data('length', fermsLength + 1);
$('.rem-ferm').unbind('click');
$('.rem-ferm').click(removeFerm);
}
// Correct all the indexes for the removed form
function adjustFermIndices(removedIndex) {
var $forms = $('.ferm-form')
console.log($forms)
$forms.each(function(i) {
var $form = $(this);
var index = parseInt($form.data('index'));
var newIndex = index - 1;
console.log('index: ' + index)
console.log('newIndex:' + newIndex);
console.log('removedIndex: ' + removedIndex);
if (index <= removedIndex) {
// Skip this one
console.log('index < removedIndex is true');
return true;
}
console.log("I didn't quit");
// Change form index
$form.data('index', newIndex);
// Change ID in form input, select, and labels
$form.find('input').each(function(j) {
var $item = $(this)
$item.attr('id', $item.attr('id').replace(index, newIndex));
$item.attr('name', $item.attr('name').replace(index, newIndex));
});
$form.find('select').each(function(j) {
var $item = $(this)
$item.attr('id', $item.attr('id').replace(index, newIndex));
$item.attr('name', $item.attr('name').replace(index, newIndex));
});
//$form.find('label').each(function(j) {
// var $item = $(this)
// $item.attr('for', $item.attr('id').replace(index, newIndex));
//});
});
}
$(document).ready(function() {
$('#add-ferm').click(addFerm);
$('.rem-ferm').click(removeFerm);
});
</script>
{% endblock %} {% endblock %}

View file

@ -12,10 +12,14 @@
# 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 decimal import Decimal
from humulus.couch import get_db from humulus.couch import get_db
from humulus.recipes import FermentableForm, RecipeForm
def test_create(client, app): def test_create(client, app):
"""Test success in creating a recipe document."""
# Test GET # Test GET
response = client.get('/recipes/create') response = client.get('/recipes/create')
assert response.status_code == 200 assert response.status_code == 200
@ -40,6 +44,7 @@ def test_create(client, app):
def test_info(client): def test_info(client):
"""Test success in retrieving a recipe document."""
# Validate 404 # Validate 404
response = client.get('/recipes/info/thisdoesnotexist') response = client.get('/recipes/info/thisdoesnotexist')
assert response.status_code == 404 assert response.status_code == 404
@ -48,3 +53,40 @@ def test_info(client):
response = client.get('/recipes/info/awesome-lager') response = client.get('/recipes/info/awesome-lager')
assert response.status_code == 200 assert response.status_code == 200
assert b'Awesome Lager' in response.data assert b'Awesome Lager' in response.data
def test_recipe_form_doc(app):
"""Test if a recipeform can be turned into a document.
This test also tests that subforms can be turned into a document. Subforms
are not tested individually since they will never be used individually.
"""
with app.app_context():
recipe = RecipeForm()
ferm = FermentableForm()
ferm.name.data = 'Test'
ferm.type.data = 'Grain'
ferm.amount.data = Decimal('5.5')
ferm.ppg.data = Decimal('37')
ferm.color.data = Decimal('1.8')
recipe.name.data = 'Test'
recipe.efficiency.data = Decimal('65')
recipe.volume.data = Decimal('5.5')
recipe.notes.data = 'This is a test'
recipe.fermentables = [ferm]
assert recipe.doc == {
'name': 'Test',
'efficiency': '65',
'volume': '5.5',
'notes': 'This is a test',
'fermentables': [{
'name': 'Test',
'type': 'Grain',
'amount': '5.5',
'ppg': '37',
'color': '1.8',
}],
}