diff --git a/app/controllers/base.py b/app/controllers/base.py index 330d4e6055585ccc2d981e3783c19be76496c57f..bbea4d71fd54712fed3ed5cd194800886617d2b7 100644 --- a/app/controllers/base.py +++ b/app/controllers/base.py @@ -114,12 +114,17 @@ class RestController(object): raise BadRequest(form.errors) for field in self.constant_fields: - if not getattr(model, field) == getattr(form, field).data: - raise BadRequest( - { - '{}'.format(field): \ - 'The {} field cannot be modified.'.format(field) - }) + form_field = getattr(form, field, None) + e = BadRequest( + { + '{}'.format(field): \ + 'The {} field cannot be modified.'.format(field) + }) + if not form_field: + if getattr(model, field): + raise e + elif getattr(model, field) != form_field.data: + raise e form.populate_obj(model) db.session.add(model) diff --git a/app/controllers/note.py b/app/controllers/note.py new file mode 100644 index 0000000000000000000000000000000000000000..829ed51986c29269cb80a868a578d628c045b282 --- /dev/null +++ b/app/controllers/note.py @@ -0,0 +1,15 @@ +"""Controllers for making small notes on a project.""" +from app.controllers.base import RestController +from app.models.note import Note +from app.forms.note import NoteForm + + +class NoteController(RestController): + """The note controller.""" + Model = Note + constant_fields = [ + 'sales_force_id', 'project_id', 'posted', 'poster_uuid', 'poster_name'] + + def get_form(self, filter_data): + """Return the note form.""" + return NoteForm diff --git a/app/controllers/project.py b/app/controllers/project.py index c9827ae714d1c9e07c12df47e32bea3eb7b8a31d..be3dd412f7a8f60ff4b1ec64c66311d3df412063 100644 --- a/app/controllers/project.py +++ b/app/controllers/project.py @@ -14,6 +14,7 @@ from app.controllers.client import ClientController from app.controllers.contact import ( ContactController, ContactMethodController, ProjectContactController) from app.controllers.place import PlaceController, AddressController +from app.controllers.note import NoteController from app.forms.project import ProjectForm @@ -106,6 +107,7 @@ class ProjectController(RestController): try: contacts_data = data['contacts'] contact_methods_data = data['contact_methods'] + notes_data = data['notes'] except KeyError: raise BadRequest( 'SalesForce projects require embedded contacts.') @@ -163,6 +165,12 @@ class ProjectController(RestController): contact_method_data, {}) + for note_data in notes_data: + # Create the note. We know it doesn't exist previously + # because the project didn't exist previously. + note_data.update({'project_id': model.id}) + note = NoteController().post(note_data, {}) + except BadRequest as e: # Do not keep the project if contacts failed to upload. db.session.delete(model) diff --git a/app/forms/fields.py b/app/forms/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..be879ff04fc423d1e23f38d19576addd68494d72 --- /dev/null +++ b/app/forms/fields.py @@ -0,0 +1,39 @@ +"""Custom WTForms fields.""" +import uuid +import arrow +import wtforms as wtf + + +class Arrow(wtf.Field): + """A field that accepts any input that arrow finds an acceptable datetime. + """ + widget = wtf.widgets.TextInput() + + def _value(self): + """Process pythonic data into a string.""" + return self.data and self.data.isoformat() + + def process_formdata(self, valuelist): + """Parse a string into a pythonic value.""" + if valuelist and valuelist[0]: + try: + self.data = arrow.get(valuelist[0]) + except arrow.parser.ParserError: + self.data = None + raise ValueError('Not a valid datetime') + + +class UUID(wtf.Field): + """A field that accepts UUID strings.""" + def _value(self): + """Process pythonic data into a string.""" + return self.data and str(self.data) + + def process_formdata(self, valuelist): + """Parse a string into a pythonic value.""" + if valuelist and valuelist[0]: + try: + self.data = uuid.UUID(valuelist[0]) + except ValueError: + self.data = None + raise ValueError('Not a valid UUID') diff --git a/app/forms/note.py b/app/forms/note.py new file mode 100644 index 0000000000000000000000000000000000000000..b092c6466499d874bb0a64aaaa7bcd561a01bcb3 --- /dev/null +++ b/app/forms/note.py @@ -0,0 +1,20 @@ +"""Forms for making small notes on a project.""" +import wtforms as wtf +from app.forms import validators, fields +from app.forms.base import Form + + +class NoteForm(Form): + """A form for validating notes.""" + sales_force_id = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) + project_id = wtf.IntegerField(validators=[wtf.validators.DataRequired()]) + + posted = fields.Arrow() + title = wtf.StringField(validators=[wtf.validators.Length(max=255)]) + content = wtf.StringField() + + poster_uuid = fields.UUID() + poster_name = wtf.StringField(validators=[ + wtf.validators.Length(max=255), + validators.RequiredIfNot('poster_uuid')]) diff --git a/app/forms/validators.py b/app/forms/validators.py index da9c2ec0df52b8ab1dd86db0eed0032eda44432d..0d9123428459e3268365c8ee7ceaa98da3c475bf 100644 --- a/app/forms/validators.py +++ b/app/forms/validators.py @@ -1,4 +1,5 @@ """Custom validators.""" +import uuid import wtforms as wtf @@ -20,6 +21,46 @@ class Map(object): validator(form, field) +class RequiredIfNot(object): + """A validator indicating that the field is required if another field is + false-y. + """ + def __init__(self, condition_field_name, message=None): + """Initialize the validator with the other field's name.""" + self.condition_field_name = condition_field_name + self.message = ( + message or + 'Field is required if {} is not provided.'.format( + condition_field_name)) + + def __call__(self, form, field): + """Check the value if the other field's value is false-y.""" + condition_field = form._fields.get(self.condition_field_name) + if not condition_field.data and not field.data: + raise wtf.ValidationError(self.message) + + +class UUID(object): + """A validator for UUIDs similar to that provided by wtforms except that + the field can be null. + """ + def __init__(self, message=None): + """Initialize the validator with an option custom message.""" + self.message = message or 'Invalid UUID.' + + def __call__(self, form, field): + """Check that the field has a value first, then validate the uuid + normally. + """ + if not field.data: + return + + try: + uuid.UUID(field.data) + except ValueError: + raise wtf.ValidationError(self.message) + + # A phone number validator. phone = wtf.validators.Regexp(r'^[0-9]{10,}$') diff --git a/app/models/base.py b/app/models/base.py index b63c56e58b79f166ba36c38cd268c83eee943393..53a7771d4697175c8378b35e295392eb4b30c356 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,7 +1,10 @@ +import arrow +from uuid import UUID from datetime import datetime from sqlalchemy.sql import func from sqlalchemy.ext.declarative import declared_attr from app.lib.database import db +from app.models import columns class Model(object): @@ -30,17 +33,20 @@ class Model(object): value = getattr(self, key) # Custom rendering can go here or in a custom JSON parser. - if isinstance(value, datetime): + if isinstance(value, arrow.Arrow): value = value.isoformat() + if isinstance(value, UUID): + value = str(value) + d[key] = value return d class Tracked(object): """A mixin to include tracking datetime fields.""" - created = db.Column(db.DateTime, default=func.now()) - updated = db.Column(db.DateTime, onupdate=func.now()) + created = db.Column(columns.Arrow, default=func.now()) + updated = db.Column(columns.Arrow, onupdate=func.now()) class SalesForce(object): diff --git a/app/models/columns.py b/app/models/columns.py index 747db8390aad0e8f6b74b7a4fe3cfc10789aba17..5b715e7f4bc640dc311b5f060aad4e251205aae2 100644 --- a/app/models/columns.py +++ b/app/models/columns.py @@ -1,7 +1,9 @@ """Custom SQLAlchemy column data types.""" -from sqlalchemy.types import TypeDecorator, CHAR +import uuid +import bcrypt +import arrow +from sqlalchemy.types import TypeDecorator, CHAR, DateTime from sqlalchemy.dialects.postgresql import UUID -import uuid, bcrypt class GUID(TypeDecorator): """GUID data type. @@ -25,3 +27,21 @@ class GUID(TypeDecorator): def process_result_value(self, value, dialect): """Parse the database value into a uuid.""" return uuid.UUID(value) if value else None + + +class Arrow(TypeDecorator): + """An arrow object type. This is a wrapper around DateTime.""" + impl = DateTime + python_type = arrow.Arrow + + def process_bind_param(self, value, dialect): + """Parse the value to store it on the database. Value should be an + arrow object. + """ + if isinstance(value, str): + value = arrow.get(value) + return value.datetime if value else None + + def process_result_value(self, value, dialect): + """Parse the datetime value into an arrow object.""" + return arrow.get(value) if value else None diff --git a/app/models/note.py b/app/models/note.py new file mode 100644 index 0000000000000000000000000000000000000000..470c1dc24567b0b23f8639e9a56859e8d03ed67e --- /dev/null +++ b/app/models/note.py @@ -0,0 +1,23 @@ +"""Models for making small notes on the project.""" +from app.lib.database import db +from app.models.base import Model, Tracked, SalesForce +from app.models import columns + + +class Note(Model, Tracked, SalesForce, db.Model): + """A note.""" + project_id = db.Column( + db.Integer, + db.ForeignKey('project.id', ondelete='CASCADE'), + nullable=False) + + posted = db.Column(columns.Arrow()) + title = db.Column(db.Unicode(255)) + content = db.Column(db.UnicodeText()) + + # SalesForce notes have no user UUID since the user is not provided by the + # user service, but by SalesForce. Instead, we store the name. + # + # At least one of these must be provided. We can confirm that on the form. + poster_uuid = db.Column(columns.GUID()) + poster_name = db.Column(db.Unicode(255)) diff --git a/app/tests/environment.py b/app/tests/environment.py index b75aec73c1ba6c3204ae957fa4c89c58b07bae52..4c0d50fcf31d45091120f82801e0ab264b1d2a7a 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -3,6 +3,8 @@ This provides several example models which can be used as needed in the test cases. """ +import arrow +import uuid import requests from datetime import datetime, timedelta @@ -14,6 +16,7 @@ from app.models.place import Address, Place from app.models.project import Project from app.models.client import Client from app.models.contact import Contact, ContactMethod, ProjectContact +from app.models.note import Note class Environment(object): @@ -131,3 +134,23 @@ class Environment(object): return self.add(ProjectContact( project_id=self.project.id, contact_id=self.contact.id)) + + @property + def note(self): + """A note with a poster uuid.""" + return self.add(Note( + project_id=self.project.id, + title='Test', + content='This is a test note.', + poster_uuid=str(uuid.uuid4()))) + + @property + def note_with_name(self): + """A note with a poster name.""" + return self.add(Note( + sales_force_id='test', + project_id=self.project.id, + posted=arrow.get(), + title='Test', + content='This is a test note.', + poster_name='test')) diff --git a/app/tests/test_note.py b/app/tests/test_note.py new file mode 100644 index 0000000000000000000000000000000000000000..e15d689e4717f600de3f30d8bcbd30e3cb316e32 --- /dev/null +++ b/app/tests/test_note.py @@ -0,0 +1,161 @@ +"""Unit tests for notes.""" +import arrow +import uuid +from sqlalchemy import and_ +from app.lib.database import db +from app.tests.base import RestTestCase +from app.models.note import Note + + +class TestNote(RestTestCase): + """Tests the /note/ endpoints.""" + url = '/note/' + Model = Note + + def test_index(self): + """Tests /note/ GET.""" + model = self.env.note + self._test_index() + + def test_get(self): + """Tests /note/ GET.""" + model = self.env.note + self._test_get(model.id) + + def test_post_uuid(self): + """Tests /note/ POST with a poster uuid.""" + data = { + 'project_id': self.env.project.id, + 'title': 'Test', + 'content': 'This is a test note.', + 'poster_uuid': str(uuid.uuid4())} + response_data = self._test_post(data) + filters = [Note.id == response_data['id']] + filters += [getattr(Note, k) == v for k, v in data.items()] + self.assertTrue(db.session.query(Note).filter(and_(*filters)).first()) + + def test_post_name(self): + """Tests /note/ POST with a poster name.""" + data = { + 'sales_force_id': 'test', + 'project_id': self.env.project.id, + 'posted': arrow.get().isoformat(), + 'title': 'Test', + 'content': 'This is a test note.', + 'poster_name': 'test'} + response_data = self._test_post(data) + filters = [Note.id == response_data['id']] + filters += [getattr(Note, k) == v for k, v in data.items()] + self.assertTrue(db.session.query(Note).filter(and_(*filters)).first()) + + def test_post_no_project_id(self): + """Tests /note/ POST with no project id. It should 400.""" + data = { + 'title': 'Test', + 'content': 'This is a test note.', + 'user_uuid': str(uuid.uuid4())} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_project_id(self): + """Tests /note/ POST with a bad project id. It should 400.""" + data = { + 'project_id': 1, + 'title': 'Test', + 'content': 'This is a test note.', + 'user_uuid': str(uuid.uuid4())} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_no_uuid_no_name(self): + """Tests /note/ POST with no poster uuid or name. It should 400.""" + data = { + 'project_id': self.env.project.id, + 'title': 'Test', + 'content': 'This is a test note.'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_uuid(self): + """Tests /note/ POST with a bad poster uuid. It should 400.""" + data = { + 'project_id': self.env.project.id, + 'title': 'Test', + 'content': 'This is a test note.', + 'poster_uuid': 'test'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_posted(self): + """Tests /note/ POST with a bad posted datetime. It should 400.""" + data = { + 'project_id': self.env.project.id, + 'posted': 'foo', + 'title': 'Test', + 'content': 'This is a test note.', + 'post_uuid': str(uuid.uuid4())} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /note/ PUT.""" + model = self.env.note + new_data = { + 'title': 'Foo', + 'content': 'This is some new content for a test note.'} + data = model.get_dictionary() + data.update(new_data) + response_data = self._test_put(model.id, data) + filters = [Note.id == response_data['id']] + filters += [getattr(Note, k) == v for k, v in new_data.items()] + self.assertTrue(db.session.query(Note).filter(and_(*filters)).first()) + + def test_put_salesforce_id(self): + """Tests /note/ PUT with a salesforce id. It should 400.""" + model = self.env.note_with_name + new_data = {'sales_force_id': 'foo'} + data = model.get_dictionary() + data.update(new_data) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_project_id(self): + """Tests /note/ PUT with a project id. It should 400.""" + model = self.env.note_with_name + new_data = {'project_id': self.env.project.id} + data = model.get_dictionary() + data.update(new_data) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_posted(self): + """Tests /note/ PUT with a posted datetime. It should 400.""" + model = self.env.note_with_name + new_data = {'posted': arrow.get().replace(seconds=1).isoformat()} + data = model.get_dictionary() + data.update(new_data) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_poster_uuid(self): + """Tests /note/ PUT with a poster uuid. It should 400.""" + model = self.env.note + new_data = {'poster_uuid': 'foo'} + data = model.get_dictionary() + data.update(new_data) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_poster_name(self): + """Tests /note/ PUT with a poster name. It should 400.""" + model = self.env.note_with_name + new_data = {'poster_name': 'foo'} + data = model.get_dictionary() + data.update(new_data) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_delete(self): + """Tests /note/ DELETE.""" + model = self.env.note + self._test_delete(model.id) diff --git a/app/tests/test_project.py b/app/tests/test_project.py index d6240d4a3c56c0f2632f742c5565c6f57d209bcb..628daa935038f439e0db8598858804ead06f04f8 100644 --- a/app/tests/test_project.py +++ b/app/tests/test_project.py @@ -1,4 +1,6 @@ """Unit tests for projects.""" +import arrow + from sqlalchemy import and_ from app.lib.database import db @@ -6,6 +8,7 @@ from app.tests.base import RestTestCase from app.models.client import Client from app.models.contact import Contact, ContactMethod, ProjectContact from app.models.place import Place, Address +from app.models.note import Note from app.models.project import Project, ProjectStateChange @@ -69,7 +72,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) # Check that a client was posted with the new data. self.assertTrue( @@ -95,7 +99,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) # Check that the client was modified as expected. self.assertTrue( @@ -119,7 +124,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response = self.post( self.url, data=data, @@ -149,7 +155,8 @@ class TestProject(RestTestCase): 'place': place_data, 'address': address_data, 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) filters = [Project.id == response_data['id']] @@ -191,7 +198,8 @@ class TestProject(RestTestCase): 'place': place_data, 'address': address_data, 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) filters = [ @@ -229,7 +237,8 @@ class TestProject(RestTestCase): 'client': self.env.client.get_dictionary(), 'address': address_data, 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response = self.post( self.url, data=data, @@ -247,7 +256,8 @@ class TestProject(RestTestCase): 'client': self.env.client.get_dictionary(), 'place': place_data, 'contacts': [], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response = self.post( self.url, data=data, @@ -270,7 +280,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [contact_data], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) self.assertTrue( db.session.query(Contact)\ @@ -296,7 +307,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [contact_data], - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) self.assertTrue( db.session.query(Contact)\ @@ -321,7 +333,8 @@ class TestProject(RestTestCase): 'client': self.env.client.get_dictionary(), 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), - 'contact_methods': []} + 'contact_methods': [], + 'notes': []} response = self.post( self.url, data=data, @@ -351,7 +364,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [contact_data], - 'contact_methods': [contact_method_data]} + 'contact_methods': [contact_method_data], + 'notes': []} response_data = self._test_post(data, {'form': 'salesforce'}) self.assertTrue( db.session.query(ContactMethod)\ @@ -381,7 +395,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [], - 'contact_methods': [contact_method_data]} + 'contact_methods': [contact_method_data], + 'notes': []} response = self.post( self.url, data=data, @@ -407,7 +422,8 @@ class TestProject(RestTestCase): 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), 'contacts': [], - 'contact_methods': [contact_method_data]} + 'contact_methods': [contact_method_data], + 'notes': []} response = self.post( self.url, data=data, @@ -426,7 +442,8 @@ class TestProject(RestTestCase): 'client': self.env.client.get_dictionary(), 'place': place.get_dictionary(), 'address': place.address.get_dictionary(), - 'contacts': []} + 'contacts': [], + 'notes': []} response = self.post( self.url, data=data, @@ -437,6 +454,56 @@ class TestProject(RestTestCase): # but those are already tested in the /contact/method/ endpoint, so # there's no need to test them here. + def test_post_sf_notes(self): + """Tests /project/?form=salesforce POST with a note.""" + place = self.env.place + note_data = { + 'sales_force_id': 'test', + 'posted': arrow.get().isoformat(), + 'title': 'Test', + 'content': 'This is a test note.', + 'poster_name': 'test'} + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'place': place.get_dictionary(), + 'address': place.address.get_dictionary(), + 'contacts': [], + 'contact_methods': [], + 'notes': [note_data]} + response_data = self._test_post(data, {'form': 'salesforce'}) + filters = [Project.id == response_data['id']] + filters += [getattr(Note, k) == v for k, v in note_data.items()] + self.assertTrue( + db.session.query(Note)\ + .join(Project, Project.id == Note.project_id)\ + .filter(and_(*filters))\ + .first()) + + def test_post_sf_no_notes(self): + """Tests /project/?form=salesforce POST with a missing notes field. It + should 400. + """ + place = self.env.place + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'place': place.get_dictionary(), + 'address': place.address.get_dictionary(), + 'contacts': [], + 'contact_methods': []} + response = self.post( + self.url, data=data, query_string={'form': 'salesforce'}) + self.assertEqual(response.status_code, 400) + + # There are additional ways that posting with a note could fail, but those + # are already tested in the /note/ endpoint, so there's no need to test + # them here. + def test_post_missing_name(self): """Tests /project/ POST with a missing name.""" response = self.post(self.url, diff --git a/app/views/__init__.py b/app/views/__init__.py index 7908ceb226dd214a91981a4632cd08c8d59f9155..fbc891bee967584c7982ebe24a38861430fc7dd5 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -1,5 +1,5 @@ """Flask-classy views for the flask application.""" -from app.views import application, auth, project, client, contact, place +from app.views import application, auth, project, client, contact, place, note def register(app): @@ -22,3 +22,5 @@ def register(app): place.PlaceView.register(app) place.AddressView.register(app) + + note.NoteView.register(app) diff --git a/app/views/note.py b/app/views/note.py new file mode 100644 index 0000000000000000000000000000000000000000..4a76d7b76273ed6a843fed456fdbaa18c5ae61a9 --- /dev/null +++ b/app/views/note.py @@ -0,0 +1,10 @@ +"""Views for managing notes.""" +from app.views.base import RestView +from app.controllers.note import NoteController + + +class NoteView(RestView): + """The note view.""" + def get_controller(self): + """Return an instance of the note controller.""" + return NoteController() diff --git a/requirements.txt b/requirements.txt index 71b304e1f2c6f625b8f73c02c49fc9ad2d5be783..d10ab9cbfef1d11c2c4f5814a6d9716cd94d5147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +arrow==0.7.0 bcrypt==2.0.0 blessed==1.9.5 botocore==1.3.28 @@ -13,6 +14,7 @@ Flask==0.10.1 Flask-Classy==0.6.10 Flask-SQLAlchemy==2.1 Flask-Testing==0.4.2 +GeoAlchemy2==0.2.6 httplib2==0.9.2 itsdangerous==0.24 Jinja2==2.8 @@ -40,4 +42,3 @@ wcwidth==0.1.6 websocket-client==0.35.0 Werkzeug==0.11.4 WTForms==2.1 -geoalchemy2==0.2.6