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

Add ability to import styles into couch. (#17)

Closes #4
This commit is contained in:
Emma 2019-07-09 12:41:07 -06:00 committed by GitHub
parent e11fd79deb
commit 8e949d18e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 716 additions and 10 deletions

View file

@ -46,6 +46,11 @@ def create_app(test_config=None):
from . import auth from . import auth
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
# Register styles blueprint and cli commands
from . import styles
styles.init_app(app)
app.register_blueprint(styles.bp)
# Register custom filters # Register custom filters
from . import filters from . import filters
filters.create_filters(app) filters.create_filters(app)

View file

@ -13,6 +13,9 @@
}, },
"by-type": { "by-type": {
"map": "function (doc) {\n if (doc.$type == \"recipe\" && doc.type && doc.name) {\n emit(doc.type, doc.name)\n }\n}" "map": "function (doc) {\n if (doc.$type == \"recipe\" && doc.type && doc.name) {\n emit(doc.type, doc.name)\n }\n}"
},
"by-style": {
"map": "function (doc) {\n if (doc.$type == \"recipe\" && doc.style && doc.name) {\n emit(doc.style, doc.name)\n }\n}"
} }
}, },
"lists": {}, "lists": {},

View file

@ -0,0 +1,15 @@
{
"_id": "_design/styles",
"language": "javascript",
"views": {
"by-category": {
"map": "function (doc) {\n if (doc.$type == \"style\") {\n category = doc._id.match(/[0-9]+|[a-zA-Z]+/g)\n category[0] = parseInt(category[0])\n emit(category, doc.name)\n }\n}"
},
"by-name": {
"map": "function (doc) {\n if (doc.$type == \"style\") {\n emit(doc.name, doc.name)\n }\n}"
}
},
"lists": {},
"indexes": {},
"shows": {}
}

View file

@ -27,7 +27,9 @@ from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList,
from wtforms.validators import DataRequired, Optional from wtforms.validators import DataRequired, Optional
from humulus.auth import login_required from humulus.auth import login_required
from humulus.couch import get_doc_or_404, put_doc, update_doc, get_view from humulus.couch import (get_doc, get_doc_or_404, put_doc, update_doc,
get_view)
from humulus.styles import get_styles_list
bp = Blueprint('recipes', __name__, url_prefix='/recipes') bp = Blueprint('recipes', __name__, url_prefix='/recipes')
@ -169,6 +171,7 @@ class RecipeForm(FlaskForm):
max_entries=20 max_entries=20
) )
yeast = FormField(YeastForm) yeast = FormField(YeastForm)
style = SelectField('Style', choices=[], validators=[Optional()])
@property @property
def doc(self): def doc(self):
@ -182,7 +185,8 @@ class RecipeForm(FlaskForm):
'volume': str(self.volume.data), 'volume': str(self.volume.data),
'notes': self.notes.data, 'notes': self.notes.data,
'$type': 'recipe', '$type': 'recipe',
'type': self.type.data 'type': self.type.data,
'style': self.style.data
} }
recipe['fermentables'] = [f.doc for f in self.fermentables] recipe['fermentables'] = [f.doc for f in self.fermentables]
@ -202,6 +206,7 @@ class RecipeForm(FlaskForm):
self.efficiency.data = Decimal(data['efficiency']) self.efficiency.data = Decimal(data['efficiency'])
self.volume.data = Decimal(data['volume']) self.volume.data = Decimal(data['volume'])
self.notes.data = data['notes'] self.notes.data = data['notes']
self.style.data = data['style']
for fermentable in data['fermentables']: for fermentable in data['fermentables']:
self.fermentables.append_entry({ self.fermentables.append_entry({
@ -282,6 +287,7 @@ def index():
@login_required @login_required
def create(): def create():
form = RecipeForm() form = RecipeForm()
form.style.choices = get_styles_list()
if form.validate_on_submit(): if form.validate_on_submit():
response = put_doc(form.doc) response = put_doc(form.doc)
@ -308,7 +314,17 @@ def create_json():
@bp.route('/info/<id>') @bp.route('/info/<id>')
def info(id): def info(id):
return render_template('recipes/info.html', recipe=get_doc_or_404(id)) recipe = get_doc_or_404(id)
style = None
if recipe['style'] != '':
try:
style = get_doc(recipe['style'])
except KeyError:
flash('Could not find style `{}`.'.format(recipe['style']),
'warning')
return render_template('recipes/info.html', recipe=recipe, style=style)
@bp.route('/info/<id>/json') @bp.route('/info/<id>/json')
@ -334,6 +350,7 @@ def delete(id):
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
form = RecipeForm() form = RecipeForm()
form.style.choices = get_styles_list()
recipe = get_doc_or_404(id) 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):

176
src/humulus/styles.py Normal file
View file

@ -0,0 +1,176 @@
"""This module has functions for working with BJCP styles."""
# 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 math
import xml.etree.ElementTree as ET
import click
import requests
from flask import Blueprint, abort, current_app, render_template, request
from flask.cli import with_appcontext
from humulus.auth import login_required
from humulus.couch import get_db, put_doc, get_view, get_doc_or_404
bp = Blueprint('styles', __name__, url_prefix='/styles')
def sub_to_doc(sub):
"""Coverts sub (XML) to a dictionary document.
The returned dictionary can be placed right into CouchDB if you want.
"""
doc = {
'_id': '{}'.format(sub.attrib['id']),
'$type': 'style',
'name': sub.find('name').text,
'aroma': sub.find('aroma').text,
'appearance': sub.find('appearance').text,
'flavor': sub.find('flavor').text,
'mouthfeel': sub.find('mouthfeel').text,
'impression': sub.find('impression').text,
'ibu': {},
'og': {},
'fg': {},
'srm': {},
'abv': {}
}
if sub.find('comments') is not None:
doc['comments'] = sub.find('comments').text
if sub.find('history') is not None:
doc['history'] = sub.find('history').text
if sub.find('ingredients') is not None:
doc['ingredients'] = sub.find('ingredients').text
if sub.find('comparison') is not None:
doc['comparison'] = sub.find('comparison').text
if sub.find('examples') is not None:
doc['examples'] = sub.find('examples').text
if sub.find('tags') is not None:
doc['tags'] = sub.find('tags').text.split(', ')
doc['ibu']['low'] = (sub.find('./stats/ibu/low').text
if sub.find('./stats/ibu/low') is not None else '0')
doc['ibu']['high'] = (sub.find('./stats/ibu/high').text
if sub.find('./stats/ibu/high') is not None else '100')
doc['og']['low'] = (sub.find('./stats/og/low').text
if sub.find('./stats/og/low') is not None else '1.0')
doc['og']['high'] = (sub.find('./stats/og/high').text
if sub.find('./stats/og/high') is not None else '1.2')
doc['fg']['low'] = (sub.find('./stats/fg/low').text
if sub.find('./stats/fg/low') is not None else '1.0')
doc['fg']['high'] = (sub.find('./stats/fg/high').text
if sub.find('./stats/fg/high') is not None else '1.2')
doc['srm']['low'] = (sub.find('./stats/srm/low').text
if sub.find('./stats/srm/low') is not None else '0')
doc['srm']['high'] = (sub.find('./stats/srm/high').text
if sub.find('./stats/srm/high') is not None else '100')
doc['abv']['low'] = (sub.find('./stats/abv/low').text
if sub.find('./stats/abv/low') is not None else '0')
doc['abv']['high'] = (sub.find('./stats/abv/high').text
if sub.find('./stats/abv/high') is not None else '100')
return doc
def import_styles(url):
"""Parses BJCP styles in XML format from `url`.
`url` defaults to the official BJCP XML styleguide.
Each subcategory is converted to JSON and then put to a couchdb. The _id of
the subsequent document will be `<number><letter>`, i.e., 1A.
If the style already exists in the database, it will be skipped.
"""
db = get_db()
root = ET.fromstring(requests.get(url).text)
subs = root.findall('./class[@type="beer"]/category/subcategory')
for sub in subs:
doc = sub_to_doc(sub)
if doc['_id'] not in db:
put_doc(doc)
def get_styles_list():
"""Returns a list containing id and names of all styles."""
view = get_view('_design/styles', 'by-category')
styles = [['', '']]
for row in view(include_docs=False)['rows']:
print(row)
styles.append([row['id'], '{}{} {}'.format(
row['key'][0],
row['key'][1],
row['value']
)])
return styles
@click.command('import-styles')
@with_appcontext
def import_command():
"""CLI command to import BJCP styles."""
url = current_app.config.get(
'BJCP_STYLES_URL',
('https://raw.githubusercontent.com/meanphil'
'/bjcp-guidelines-2015/master/styleguide.xml')
)
import_styles(url)
click.echo("Imported BJCP styles.")
def init_app(app):
"""Register the CLI command with the app."""
app.cli.add_command(import_command)
@bp.route('/')
@login_required
def index():
descending = (
request.args.get('descending', default='false', type=str).lower() in
['true', 'yes']
)
sort_by = request.args.get('sort_by', default='category', type=str)
page = request.args.get('page', default=1, type=int)
limit = request.args.get('limit', default=20, type=int)
view = get_view('_design/styles', 'by-{}'.format(sort_by))
try:
rows = view(include_docs=True, descending=descending)['rows']
except requests.exceptions.HTTPError:
abort(400)
return render_template(
'styles/index.html',
rows=rows[(page-1)*limit:page*limit],
descending=descending,
sort_by=sort_by,
page=page,
num_pages=math.ceil(len(rows)/limit),
limit=limit
)
@bp.route('/info/<id>')
@login_required
def info(id):
return render_template('styles/info.html', style=get_doc_or_404(id))
@bp.route('/info/<id>/recipes')
def recipes(id):
style = get_doc_or_404(id)
view = get_view('_design/recipes', 'by-style')
rows = view(include_docs=True, descending=True, key=id)['rows']
return render_template('styles/recipes.html', style=style, rows=rows)

View file

@ -40,6 +40,11 @@
<li class="nav-item {% if request.url_rule.endpoint == 'recipes.index' %}active{% endif %}"> <li class="nav-item {% if request.url_rule.endpoint == 'recipes.index' %}active{% endif %}">
<a class="nav-link" href="{{ url_for('recipes.index') }}">Recipes</a> <a class="nav-link" href="{{ url_for('recipes.index') }}">Recipes</a>
</li> </li>
{% if session.logged_in %}
<li class="nav-item {% if request.url_rule.endpoint == 'styles.index' %}active{% endif %}">
<a class="nav-link" href="{{ url_for('styles.index') }}">Styles</a>
</li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
<li class="nav-item active"> <li class="nav-item active">

View file

@ -26,9 +26,12 @@
-#} -#}
<div class="row"> <div class="row">
<div class="col-sm-6">{{ render_field_with_errors(form.name) }}</div> <div class="col-sm-6">{{ render_field_with_errors(form.name) }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.type) }}</div> <div class="col-sm-3">{{ render_field_with_errors(form.efficiency, 'ingredient-field') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.efficiency, 'ingredient-field') }}</div> <div class="col-sm-3">{{ render_field_with_errors(form.volume, 'ingredient-field') }}</div>
<div class="col-sm-2">{{ render_field_with_errors(form.volume, 'ingredient-field') }}</div> </div>
<div class="row">
<div class="col-sm">{{ render_field_with_errors(form.style) }}</div>
<div class="col-sm">{{ render_field_with_errors(form.type) }}</div>
</div> </div>
{#- {#-
Fermentable Ingredients Fermentable Ingredients

View file

@ -20,7 +20,11 @@
{% block body %} {% block body %}
<div class="row"><h1>{{ recipe.name }}</h1></div> <div class="row"><h1>{{ recipe.name }}</h1></div>
{% if style %}
<div class="row">
<h2><a href="{{ url_for('styles.recipes', id=style._id) }}">{{ style._id }} {{ style.name }}</a></h2>
</div>
{% endif %}
{#- {#-
Recipe Details Recipe Details
-#} -#}

View file

@ -0,0 +1,84 @@
{#-
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.
-#}
{% extends '_base.html' %}
{% block title %}Styles{% endblock %}
{% block body %}
<div class="row"><h1>Styles</h1></div>
<div class="row">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>
{% if sort_by == 'category' and descending %}
<a class="text-dark" href="{{ url_for('styles.index', descending='false', sort_by='category', limit=limit) }}">Category &darr;</a>
{% elif sort_by == 'category' %}
<a class="text-dark" href="{{ url_for('styles.index', descending='true', sort_by='category', limit=limit) }}">Category &uarr;</a>
{% else %}
<a class="text-dark" href="{{ url_for('styles.index', descending='false', sort_by='category', limit=limit) }}">Category</a>
{% endif %}
</th>
<th>
{% if sort_by == 'name' and descending %}
<a class="text-dark" href="{{ url_for('styles.index', descending='false', sort_by='name', limit=limit) }}">Name &darr;</a>
{% elif sort_by == 'name' %}
<a class="text-dark" href="{{ url_for('styles.index', descending='true', sort_by='name', limit=limit) }}">Name &uarr;</a>
{% else %}
<a class="text-dark" href="{{ url_for('styles.index', descending='false', sort_by='name', limit=limit) }}">Name</a>
{% endif %}
</th>
</tr>
</thead>
{% for row in rows %}
<tr>
<td>{{ row.doc._id }}</td>
<td><a href="{{ url_for('styles.info', id=row.doc._id) }}">{{ row.doc.name }}</a></td>
</tr>
{% endfor %}
</table>
</div>
<div class="row">
<nav>
<ul class="pagination flex-wrap">
<li class="page-item{% if page == 1 %} disabled {% endif %}">
<a class="page-link" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page-1, limit=limit) }}">Previous</a>
</li>
{% for num in range(1, num_pages+1) %}
<li class="page-item{% if num == page %} active{% endif %}">
<a class="page-link" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=num, limit=limit) }}">{{ num }}</a>
</li>
{% endfor %}
<li class="page-item{% if page == num_pages %} disabled {% endif %}">
<a class="page-link" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page+1, limit=limit) }}">Next</a>
</li>
</ul>
</nav>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle ml-2" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Limit
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item{% if limit == 5 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=5) }}">5</a>
<a class="dropdown-item{% if limit == 10 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=10) }}">10</a>
<a class="dropdown-item{% if limit == 20 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=20) }}">20</a>
<a class="dropdown-item{% if limit == 50 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=50) }}">50</a>
<a class="dropdown-item{% if limit == 100 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=100) }}">100</a>
<a class="dropdown-item{% if limit == 200 %} active {% endif %}" href="{{ url_for('styles.index', descending=descending, sort_by=sort_by, page=page, limit=200) }}">200</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,85 @@
{#-
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 moment %}
{% macro render_detail(field, header) %}
<div class="row">
<h2>{{ header }}</h2>
</div>
<div class="row">
<p>{{ field }}</p>
</div>
{% endmacro %}
{% extends '_base.html' %}
{% block title %}{{ style._id }} {{ style.name }}{% endblock %}
{% block body %}
<div class="row mb-3"><h1>{{ style._id }} {{ style.name }}</h1></div>
<p><a href="{{ url_for('styles.recipes', id=style._id) }}">View recipes using this style</a></p>
{{ render_detail(style.impression, 'Overall Impression') }}
{{ render_detail(style.appearance, 'Appearance') }}
{{ render_detail(style.aroma, 'Aroma') }}
{{ render_detail(style.flavor, 'Flavor') }}
{{ render_detail(style.mouthfeel, 'Mouthfeel') }}
{% if style.comments %}
{{ render_detail(style.comments, 'Comments') }}
{% endif %}
{% if style.history %}
{{ render_detail(style.history, 'History') }}
{% endif %}
{% if style.ingredients %}
{{ render_detail(style.ingredients, 'Characteristic Ingredients') }}
{% endif %}
{% if style.comparison %}
{{ render_detail(style.comparison, 'Style Comparisons') }}
{% endif %}
<div class="row">
<h2>Vital Statistics</h2>
<table class="table">
<tr>
<th>IBU</th>
<td>{{ style.ibu.low }} - {{ style.ibu.high }}</td>
</tr>
<tr>
<th>OG</th>
<td>{{ '%.3f' | format(style.og.low|float) }} - {{ '%.3f' | format(style.og.high|float) }}</td>
</tr>
<tr>
<th>FG</th>
<td>{{ '%.3f' | format(style.fg.low|float) }} - {{ '%.3f' | format(style.fg.high|float) }}</td>
</tr>
<tr>
<th>SRM</th>
<td>{{ style.srm.low }} - {{ style.srm.high }}</td>
</tr>
<tr>
<th>ABV</th>
<td>{{ style.abv.low }} - {{ style.abv.high }}</td>
</tr>
</table>
</div>
{% if style.examples %}
{{ render_detail(style.examples, 'Commercial Examples') }}
{% endif %}
<div class="row"><a href="{{ url_for('styles.index') }}">Back to styles list</a></div>
{% endblock %}

View file

@ -0,0 +1,39 @@
{#-
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.
-#}
{% extends '_base.html' %}
{% block title %}Recipes for {{ style._id }} {{ style.name }}{% endblock %}
{% block body %}
{% if session.logged_in %}
<div class="row mb-3"><h1>Recipes for <a href="{{ url_for('styles.info', id=style._id) }}">{{ style._id }} {{ style.name }}</a></h1></div>
{% else %}
<div class="row mb-3"><h1>Recipes for {{ style._id }} {{ style.name }}</h1></div>
{% endif %}
<div class="row">
<table class="table table-sm">
<thead>
<th>Name</th>
</thead>
{% for row in rows %}
<tr>
<td><a href="{{ url_for('recipes.info', id=row.doc._id) }}">{{ row.doc.name }}</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -50,7 +50,8 @@ def app():
'notes': 'Test', 'notes': 'Test',
'volume': '5.5', 'volume': '5.5',
'fermentables': [], 'fermentables': [],
'hops': [] 'hops': [],
'style': ''
}) })
put_doc({ put_doc({
'_id': 'partial-yeast-recipe', '_id': 'partial-yeast-recipe',
@ -66,7 +67,8 @@ def app():
'name': 'US-05', 'name': 'US-05',
'low_attenuation': '60', 'low_attenuation': '60',
'high_attenuation': '72', 'high_attenuation': '72',
} },
'style': ''
}) })
put_doc({ put_doc({
'_id': 'full-recipe', '_id': 'full-recipe',
@ -76,6 +78,7 @@ def app():
'name': 'Awesome Beer', 'name': 'Awesome Beer',
'notes': 'This is a test beer that contains most possible fields.', 'notes': 'This is a test beer that contains most possible fields.',
'volume': '2.5', 'volume': '2.5',
'style': '1A',
'fermentables': [ 'fermentables': [
{ {
'name': '2row', 'name': '2row',
@ -122,6 +125,23 @@ def app():
} }
}) })
# Add a test style
put_doc({'$type': 'style',
'_id': '1A',
'abv': {'high': '100', 'low': '0'},
'appearance': 'Good looking',
'aroma': 'Smelly',
'fg': {'high': '1.2', 'low': '1.0'},
'flavor': 'Good tasting',
'ibu': {'high': '100', 'low': '0'},
'id': '1A',
'impression': 'Refreshing',
'mouthfeel': 'Good feeling',
'name': 'Test Style',
'og': {'high': '1.2', 'low': '1.0'},
'srm': {'high': '100', 'low': '0'}
})
yield app yield app
with app.app_context(): with app.app_context():
@ -164,6 +184,7 @@ def sample_recipes():
'lager': { 'lager': {
'efficiency': '72', 'efficiency': '72',
'type': 'All-Grain', 'type': 'All-Grain',
'style': '',
'fermentables': [ 'fermentables': [
{ {
'amount': '9.5', 'amount': '9.5',
@ -222,6 +243,7 @@ def sample_recipes():
'sweetstout': { 'sweetstout': {
'efficiency': '72', 'efficiency': '72',
'type': 'All-Grain', 'type': 'All-Grain',
'style': '',
'fermentables': [ 'fermentables': [
{ {
'amount': '2.75', 'amount': '2.75',

View file

@ -136,6 +136,7 @@ def test_create(client, app, auth):
'name': 'Test', 'name': 'Test',
'notes': 'Test', 'notes': 'Test',
'volume': '5.5', 'volume': '5.5',
'style': '1A'
} }
response = client.post('/recipes/create', data=data) response = client.post('/recipes/create', data=data)
assert response.status_code == 302 assert response.status_code == 302
@ -147,6 +148,7 @@ def test_create(client, app, auth):
assert doc['notes'] == 'Test' assert doc['notes'] == 'Test'
assert doc['volume'] == '5.5' assert doc['volume'] == '5.5'
assert doc['efficiency'] == '65' assert doc['efficiency'] == '65'
assert doc['style'] == '1A'
def test_update(client, app, auth): def test_update(client, app, auth):
@ -205,8 +207,12 @@ def test_update(client, app, auth):
assert 'Update conflict' in flash_message assert 'Update conflict' in flash_message
def test_info(client): def test_info(client, monkeypatch):
"""Test success in retrieving a recipe document.""" """Test success in retrieving a recipe document."""
def mock_get_doc(id):
# This function always raises KeyError
raise KeyError(id)
# 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
@ -216,6 +222,17 @@ def test_info(client):
assert response.status_code == 200 assert response.status_code == 200
assert b'Awesome Lager' in response.data assert b'Awesome Lager' in response.data
# Validate response for recipe with style
response = client.get('/recipes/info/full-recipe')
assert response.status_code == 200
assert b'Awesome Beer' in response.data
assert b'Test Style' in response.data
# Validate warning is flashed when style cannot be found
monkeypatch.setattr('humulus.recipes.get_doc', mock_get_doc)
response = client.get('/recipes/info/full-recipe')
assert b'Could not find style' in response.data
def test_info_json(client): def test_info_json(client):
"""Test success in retrieving a JSON recipe.""" """Test success in retrieving a JSON recipe."""
@ -279,6 +296,7 @@ def test_recipe_form_doc(app):
recipe.volume.data = Decimal('5.5') recipe.volume.data = Decimal('5.5')
recipe.notes.data = 'This is a test' recipe.notes.data = 'This is a test'
recipe.type.data = 'All-Grain' recipe.type.data = 'All-Grain'
recipe.style.data = '1A'
assert recipe.doc == { assert recipe.doc == {
'name': 'Test', 'name': 'Test',
@ -289,6 +307,7 @@ def test_recipe_form_doc(app):
'fermentables': [], 'fermentables': [],
'hops': [], 'hops': [],
'$type': 'recipe', '$type': 'recipe',
'style': '1A'
} }
ferm = FermentableForm() ferm = FermentableForm()
@ -321,6 +340,7 @@ def test_recipe_form_doc(app):
'volume': '5.5', 'volume': '5.5',
'notes': 'This is a test', 'notes': 'This is a test',
'$type': 'recipe', '$type': 'recipe',
'style': '1A',
'fermentables': [{ 'fermentables': [{
'name': 'Test', 'name': 'Test',
'type': 'Grain', 'type': 'Grain',

228
tests/test_styles.py Normal file
View file

@ -0,0 +1,228 @@
# 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 xml.etree.ElementTree as ET
from humulus.styles import import_styles, sub_to_doc, get_styles_list
COMPLETE_STYLE = '''<subcategory id="1A">
<name>Test Style</name>
<aroma>Smelly</aroma>
<appearance>Good looking</appearance>
<flavor>Good tasting</flavor>
<mouthfeel>Good feeling</mouthfeel>
<impression>Refreshing</impression>
<comments>Comments</comments>
<history>Old</history>
<ingredients>Grains, Hops, and Water</ingredients>
<comparison>Comparison</comparison>
<examples>Examples</examples>
<tags>one, two</tags>
<stats>
<ibu flexible="false">
<low>1</low>
<high>2</high>
</ibu>
<og flexible="false">
<low>1.010</low>
<high>1.020</high>
</og>
<fg flexible="false">
<low>1.000</low>
<high>1.010</high>
</fg>
<srm flexible="false">
<low>1</low>
<high>2</high>
</srm>
<abv flexible="false">
<low>1</low>
<high>2</high>
</abv>
</stats>
</subcategory>
'''
INCOMPLETE_STYLE = '''<subcategory id="2B">
<name>Test Style</name>
<aroma>Smelly</aroma>
<appearance>Good looking</appearance>
<flavor>Good tasting</flavor>
<mouthfeel>Good feeling</mouthfeel>
<impression>Refreshing</impression>
</subcategory>
'''
TEST_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<styleguide>
<class type="beer">
<category id="1">
<subcategory id="1A">
<name>Test Style</name>
<aroma>Smelly</aroma>
<appearance>Good looking</appearance>
<flavor>Good tasting</flavor>
<mouthfeel>Good feeling</mouthfeel>
<impression>Refreshing</impression>
</subcategory>
</category>
</class>
</styleguide>
'''
def test_sub_to_doc():
assert sub_to_doc(ET.fromstring(COMPLETE_STYLE)) == {
'_id': '1A',
'$type': 'style',
'name': 'Test Style',
'aroma': 'Smelly',
'appearance': 'Good looking',
'flavor': 'Good tasting',
'mouthfeel': 'Good feeling',
'impression': 'Refreshing',
'comments': 'Comments',
'history': 'Old',
'ingredients': 'Grains, Hops, and Water',
'comparison': 'Comparison',
'examples': 'Examples',
'tags': ['one', 'two'],
'ibu': {'low': '1', 'high': '2'},
'og': {'low': '1.010', 'high': '1.020'},
'fg': {'low': '1.000', 'high': '1.010'},
'srm': {'low': '1', 'high': '2'},
'abv': {'low': '1', 'high': '2'}
}
assert sub_to_doc(ET.fromstring(INCOMPLETE_STYLE)) == {
'_id': '2B',
'$type': 'style',
'name': 'Test Style',
'aroma': 'Smelly',
'appearance': 'Good looking',
'flavor': 'Good tasting',
'mouthfeel': 'Good feeling',
'impression': 'Refreshing',
'ibu': {'low': '0', 'high': '100'},
'og': {'low': '1.0', 'high': '1.2'},
'fg': {'low': '1.0', 'high': '1.2'},
'srm': {'low': '0', 'high': '100'},
'abv': {'low': '0', 'high': '100'}
}
def test_import_styles(monkeypatch):
class PutRecorder:
doc = None
class MockDB:
db = {}
def fake_get_db():
return MockDB.db
def fake_put_doc(doc):
PutRecorder.doc = doc
def fake_requests_get(url):
class TestXML:
text = TEST_XML
return TestXML
monkeypatch.setattr('requests.get', fake_requests_get)
monkeypatch.setattr('humulus.styles.get_db', fake_get_db)
monkeypatch.setattr('humulus.styles.put_doc', fake_put_doc)
import_styles(None)
assert PutRecorder.doc == {'$type': 'style',
'_id': '1A',
'abv': {'high': '100', 'low': '0'},
'appearance': 'Good looking',
'aroma': 'Smelly',
'fg': {'high': '1.2', 'low': '1.0'},
'flavor': 'Good tasting',
'ibu': {'high': '100', 'low': '0'},
'impression': 'Refreshing',
'mouthfeel': 'Good feeling',
'name': 'Test Style',
'og': {'high': '1.2', 'low': '1.0'},
'srm': {'high': '100', 'low': '0'}
}
MockDB.db = {'1A': ''}
PutRecorder.doc = None
import_styles(None)
assert PutRecorder.doc is None
def test_import_command(runner, monkeypatch):
class Recorder:
called = False
def fake_import_styles(url):
Recorder.called = True
monkeypatch.setattr('humulus.styles.import_styles', fake_import_styles)
result = runner.invoke(args=['import-styles'])
assert Recorder.called
assert 'Imported BJCP styles.' in result.output
def test_get_styles_choices(app):
"""Test success in getting list of styles."""
with app.app_context():
styles = get_styles_list()
assert styles == [
['', ''],
['1A', '1A Test Style']
]
def test_index(auth, client):
"""Test success in retrieving index."""
# Test not logged in
response = client.get('/styles/')
assert response.status_code == 302
# Login and test get
auth.login()
response = client.get('/styles/')
assert response.status_code == 200
assert b'1A' in response.data
assert b'Test Style' in response.data
# Test for bad request
response = client.get('/styles/?sort_by=foobar')
assert response.status_code == 400
def test_info(auth, client):
"""Test success in retrieving a style's info page"""
# Test not logged in
response = client.get('/styles/info/1A')
assert response.status_code == 302
# Login and test
auth.login()
response = client.get('/styles/info/1A')
assert response.status_code == 200
assert b'1A' in response.data
assert b'Test Style' in response.data
def test_recipes(client):
"""Test success in retrieving list of recipes matching style."""
response = client.get('/styles/info/1A/recipes')
assert response.status_code == 200
assert b'Awesome Beer' in response.data