1
0
Fork 0
mirror of https://github.com/shouptech/humulus.git synced 2026-02-03 17:09:44 +00:00

Add basic stuff with creating recipes.

This commit is contained in:
Emma 2019-06-20 16:04:27 -06:00
parent e4dc793e9a
commit 02b15956d9
12 changed files with 463 additions and 5 deletions

View file

@ -1,11 +1,11 @@
# 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.
@ -22,6 +22,10 @@ with open(path.join(this_directory, 'README.rst'), encoding='utf-8') as f:
install_requires = [
'Flask==1.0.3',
'Flask-WTF==0.14.2',
'simplejson==3.16.0',
'python-slugify==3.0.2',
'cloudant==2.12.0',
]
setup(

View file

@ -20,11 +20,24 @@ from flask import Flask
def create_app(test_config=None):
# create and configure the app
app = Flask(__name__, instance_relative_config=True)
app.config.from_envvar('HUMULUS_SETTINGS')
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

108
src/humulus/couch.py Normal file
View file

@ -0,0 +1,108 @@
"""This module has functions for interacting with CouchDB"""
# 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 time
import uuid
import click
from cloudant import CouchDB
from flask import abort, current_app, g
from flask.cli import with_appcontext
from slugify import slugify
def get_couch():
"""Connect to the configured CouchDB."""
if 'couch' not in g:
g.couch = CouchDB(
current_app.config['COUCH_USERNAME'],
current_app.config['COUCH_PASSWORD'],
url=current_app.config['COUCH_URL'],
connect=True,
auto_renew=True
)
return g.couch
def get_db():
"""Returns a database to interact with."""
return get_couch()[current_app.config['COUCH_DATABASE']]
def close_couch(e=None):
"""Disconnect from CouchDB."""
couch = g.pop('couch', None)
if couch is not None:
couch.disconnect()
def build_couch():
"""Create any necessary databases and design documents."""
couch = get_couch()
dbname = current_app.config['COUCH_DATABASE']
couch.create_database(dbname, throw_on_exists=False)
@click.command('build-couch')
@with_appcontext
def build_couch_command():
"""Builds the couch for easy relaxing."""
build_couch()
click.echo('Built a couch. Please have a seat.')
def init_app(app):
"""Register the teardown and CLI command with the app."""
app.teardown_appcontext(close_couch)
app.cli.add_command(build_couch_command)
def put_doc(doc):
"""Put a doc on the couch.
If doc has a name field, the name will be slufigied and used as an id.
Otherwise, the id will be a random UUID.
"""
db = get_db()
if 'name' in doc and '_id' not in doc:
# Slugify the name, use that for id
slug = slugify(doc['name'])
doc['_id'] = slug
i = 1
# Check if id exists and append/increment a number until it doesn't.
while doc['_id'] in db:
doc['_id'] = slug + '-{}'.format(i)
i += 1
elif '_id' not in doc:
# Use a UUID for name
doc['_id'] = str(uuid.uuid4())
return db.create_document(doc)
def get_doc(id):
"""Gets a doc from CouchDB and returns it."""
db = get_db()
return db[id]
def get_doc_or_404(id):
"""Tries to get a doc, otherwise abort with 404."""
try:
doc = get_doc(id)
except KeyError:
abort(404)
return doc

62
src/humulus/recipes.py Normal file
View file

@ -0,0 +1,62 @@
"""This module handles routes for the recipes"""
# 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 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.validators import DataRequired
from humulus.couch import get_doc_or_404, put_doc
bp = Blueprint('recipes', __name__, url_prefix='/recipes')
class RecipeForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
efficiency = DecimalField('Batch Efficiency', validators=[DataRequired()])
volume = DecimalField('Batch Volume', validators=[DataRequired()])
notes = TextAreaField('Notes')
@property
def doc(self):
"""Returns a dictionary that can be deserialized into JSON.
Used for putting into CouchDB.
"""
return {
'name': self.name.data,
'efficiency': str(self.efficiency.data),
'volume': str(self.volume.data),
'notes': self.notes.data
}
@bp.route('/create', methods=('GET', 'POST'))
def create():
form = RecipeForm()
if form.validate_on_submit():
response = put_doc(form.doc)
flash('Created recipe: {}'.format(form.name.data), 'success')
return redirect(url_for('recipes.info', id=response['_id']))
return render_template('recipes/create.html', form=form)
@bp.route('/info/<id>')
def info(id):
return render_template('recipes/info.html', recipe=get_doc_or_404(id))

View file

@ -42,7 +42,14 @@ limitations under the License.
</nav>
<main role="main" class="container">
{% block body %}{% endblock %}
{% block alert %}
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="container">
<div class="alert alert-{{ category }}" role="alert">{{ message }}</div>
</div>
{% endfor %}
{% endblock %}
{% block body %}{% endblock %}
</main><!-- /.container -->

View file

@ -0,0 +1,27 @@
{#-
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.
-#}
{% macro render_field_with_errors(field) %}
<div class="form-group">
{{ field.label }} {{ field(class_='form-control', **kwargs)|safe }}
{% if field.errors %}
{% for error in field.errors %}
<div class="container">
<div class="alert alert-warning" role="alert">{{ error }}</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endmacro %}

View file

@ -0,0 +1,33 @@
{#-
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 %}Create Recipe{% endblock %}
{% block body %}
<div class="row"><h1>Create a new recipe</h1></div>
<div class="row">
<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>
{% endblock %}

View file

@ -0,0 +1,23 @@
{#-
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 %}{{ recipe.name }}{% endblock %}
{% block body %}
<div class="row"><h1>{{ recipe.name }}</h1></div>
{% endblock %}

62
tests/conftest.py Normal file
View file

@ -0,0 +1,62 @@
# 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 uuid
import pytest
from humulus import create_app
from humulus.couch import build_couch, get_couch, put_doc
@pytest.fixture
def app():
dbname = 'test_{}'.format(str(uuid.uuid4()))
app = create_app({
'COUCH_URL': 'http://127.0.0.1:5984',
'COUCH_USERNAME': 'admin',
'COUCH_PASSWORD': 'password',
'COUCH_DATABASE': dbname,
'WTF_CSRF_ENABLED': False,
'SECRET_KEY': 'testing'
})
with app.app_context():
# Create the database
build_couch()
# Add a test doc
put_doc({'data': 'test', '_id': 'foobar'})
# Add a test recipe
put_doc({
'_id': 'awesome-lager',
'efficiency': '65',
'name': 'Awesome Lager',
'notes': 'Test',
'volume': '5.5'
})
yield app
with app.app_context():
get_couch().delete_database(dbname)
@pytest.fixture
def runner(app):
return app.test_cli_runner()
@pytest.fixture
def client(app):
return app.test_client()

51
tests/test_couch.py Normal file
View file

@ -0,0 +1,51 @@
# 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 uuid
from humulus.couch import get_doc, put_doc
def test_put_doc(app):
with app.app_context():
data = {'foo': 'bar'}
response = put_doc(data)
assert '_id' in response
response = put_doc({'name': 'test'})
assert response['_id'] == 'test'
response = put_doc({'name': 'test'})
assert response['_id'] == 'test-1'
response = put_doc({'name': 'test'})
assert response['_id'] == 'test-2'
def test_build_couch_command(runner, monkeypatch):
class Recorder(object):
called = False
def fake_build_couch():
Recorder.called = True
monkeypatch.setattr('humulus.couch.build_couch', fake_build_couch)
result = runner.invoke(args=['build-couch'])
assert 'Built a couch. Please have a seat.' in result.output
assert Recorder.called
def test_get_doc(app):
with app.app_context():
assert get_doc('foobar')['data'] == 'test'

18
tests/test_home.py Normal file
View file

@ -0,0 +1,18 @@
# 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.
def test_home(client):
response = client.get('/')
assert b'Home' in response.data

50
tests/test_recipes.py Normal file
View file

@ -0,0 +1,50 @@
# 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 humulus.couch import get_db
def test_create(client, app):
# Test GET
response = client.get('/recipes/create')
assert response.status_code == 200
# Test POST
data = {
'efficiency': '65',
'name': 'Test',
'notes': 'Test',
'volume': '5.5'
}
response = client.post('/recipes/create', data=data)
assert response.status_code == 302
with app.app_context():
doc = get_db()['test']
assert doc['name'] == 'Test'
assert doc['notes'] == 'Test'
assert doc['volume'] == '5.5'
assert doc['efficiency'] == '65'
def test_info(client):
# Validate 404
response = client.get('/recipes/info/thisdoesnotexist')
assert response.status_code == 404
# Validate response for existing doc
response = client.get('/recipes/info/awesome-lager')
assert response.status_code == 200
assert b'Awesome Lager' in response.data