1
0
Fork 0
mirror of https://github.com/shouptech/humulus.git synced 2026-02-03 17:09:44 +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
# 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
from humulus.app import create_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_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 humulus.couch import get_doc_or_404, put_doc
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()])
efficiency = DecimalField('Batch Efficiency', validators=[DataRequired()])
volume = DecimalField('Batch Volume', validators=[DataRequired()])
type = SelectField('Type', 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')
fermentables = FieldList(
FormField(FermentableForm),
min_entries=0,
max_entries=20
)
@property
def doc(self):
"""Returns a dictionary that can be deserialized into JSON.
@ -41,7 +80,8 @@ class RecipeForm(FlaskForm):
'name': self.name.data,
'efficiency': str(self.efficiency.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;
text-anchor: middle;
-webkit-user-select: none;
@ -8,7 +8,7 @@
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
.placeholder-img-lg {
font-size: 3.5rem;
}
}

View file

@ -40,7 +40,7 @@ limitations under the License.
</ul>
</div>
</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">
{% block alert %}
{% for category, message in get_flashed_messages(with_categories=true) %}
@ -54,7 +54,6 @@ limitations under the License.
<!-- 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://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</body>

View file

@ -13,9 +13,9 @@
See the License for the specific language governing permissions and
limitations under the License.
-#}
{% macro render_field_with_errors(field) %}
{% macro render_field_with_errors(field, size='') %}
<div class="form-group">
{{ field.label }} {{ field(class_='form-control', **kwargs)|safe }}
{{ field.label }} {{ field(class_='form-control ' + size, **kwargs)|safe }}
{% if field.errors %}
{% for error in field.errors %}
<div class="container">

View file

@ -20,14 +20,165 @@ limitations under the License.
{% block body %}
<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() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.efficiency) }}
{{ render_field_with_errors(form.volume) }}
{{ render_field_with_errors(form.notes) }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
<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>
<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 %}

View file

@ -12,10 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from decimal import Decimal
from humulus.couch import get_db
from humulus.recipes import FermentableForm, RecipeForm
def test_create(client, app):
"""Test success in creating a recipe document."""
# Test GET
response = client.get('/recipes/create')
assert response.status_code == 200
@ -40,6 +44,7 @@ def test_create(client, app):
def test_info(client):
"""Test success in retrieving a recipe document."""
# Validate 404
response = client.get('/recipes/info/thisdoesnotexist')
assert response.status_code == 404
@ -48,3 +53,40 @@ def test_info(client):
response = client.get('/recipes/info/awesome-lager')
assert response.status_code == 200
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',
}],
}