diff --git a/src/humulus/app.py b/src/humulus/app.py index 5bf78c5..df149fb 100644 --- a/src/humulus/app.py +++ b/src/humulus/app.py @@ -46,6 +46,11 @@ def create_app(test_config=None): from . import auth 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 from . import filters filters.create_filters(app) diff --git a/src/humulus/designs/recipes.json b/src/humulus/designs/recipes.json index 28aca01..6ea08ac 100644 --- a/src/humulus/designs/recipes.json +++ b/src/humulus/designs/recipes.json @@ -13,6 +13,9 @@ }, "by-type": { "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": {}, diff --git a/src/humulus/designs/styles.json b/src/humulus/designs/styles.json new file mode 100644 index 0000000..d216671 --- /dev/null +++ b/src/humulus/designs/styles.json @@ -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": {} +} diff --git a/src/humulus/recipes.py b/src/humulus/recipes.py index d147532..fde93f2 100644 --- a/src/humulus/recipes.py +++ b/src/humulus/recipes.py @@ -27,7 +27,9 @@ from wtforms import (Form, StringField, DecimalField, TextAreaField, FieldList, from wtforms.validators import DataRequired, Optional 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') @@ -169,6 +171,7 @@ class RecipeForm(FlaskForm): max_entries=20 ) yeast = FormField(YeastForm) + style = SelectField('Style', choices=[], validators=[Optional()]) @property def doc(self): @@ -182,7 +185,8 @@ class RecipeForm(FlaskForm): 'volume': str(self.volume.data), 'notes': self.notes.data, '$type': 'recipe', - 'type': self.type.data + 'type': self.type.data, + 'style': self.style.data } recipe['fermentables'] = [f.doc for f in self.fermentables] @@ -202,6 +206,7 @@ class RecipeForm(FlaskForm): self.efficiency.data = Decimal(data['efficiency']) self.volume.data = Decimal(data['volume']) self.notes.data = data['notes'] + self.style.data = data['style'] for fermentable in data['fermentables']: self.fermentables.append_entry({ @@ -282,6 +287,7 @@ def index(): @login_required def create(): form = RecipeForm() + form.style.choices = get_styles_list() if form.validate_on_submit(): response = put_doc(form.doc) @@ -308,7 +314,17 @@ def create_json(): @bp.route('/info/') 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//json') @@ -334,6 +350,7 @@ def delete(id): def update(id): # Get the recipe from the database and validate it is the same revision form = RecipeForm() + form.style.choices = get_styles_list() recipe = get_doc_or_404(id) if form.validate_on_submit(): if recipe['_rev'] != request.args.get('rev', None): diff --git a/src/humulus/styles.py b/src/humulus/styles.py new file mode 100644 index 0000000..c7c4334 --- /dev/null +++ b/src/humulus/styles.py @@ -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 ``, 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/') +@login_required +def info(id): + return render_template('styles/info.html', style=get_doc_or_404(id)) + + +@bp.route('/info//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) diff --git a/src/humulus/templates/_base.html b/src/humulus/templates/_base.html index 4b0f264..678401e 100644 --- a/src/humulus/templates/_base.html +++ b/src/humulus/templates/_base.html @@ -40,6 +40,11 @@ + {% if session.logged_in %} + + {% endif %}