From aa403bd801c0dfd3d926bf9095003d802b612203 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:31:10 -0400 Subject: [PATCH 01/21] Add support for nullable constant fields. --- app/controllers/base.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/controllers/base.py b/app/controllers/base.py index 330d4e6..2fb7149 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 not str(getattr(model, field)) == str(form_field.data): + raise e form.populate_obj(model) db.session.add(model) -- GitLab From dc5a7ad5e9f36f6c67b7ad2fe88ba5cfec218a24 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:31:54 -0400 Subject: [PATCH 02/21] Add a custom uuid validator. --- app/forms/validators.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/forms/validators.py b/app/forms/validators.py index da9c2ec..9cf691b 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,27 @@ class Map(object): validator(form, field) +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,}$') -- GitLab From da4bc9926494c04ef589d8196c3e7fa13baa3be5 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:32:16 -0400 Subject: [PATCH 03/21] Add a "required if not" validator. --- app/forms/validators.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/forms/validators.py b/app/forms/validators.py index 9cf691b..0d91234 100644 --- a/app/forms/validators.py +++ b/app/forms/validators.py @@ -21,6 +21,25 @@ 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. -- GitLab From 6cc29d5a102a5e5cd5676f6686116e136f92126f Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:32:35 -0400 Subject: [PATCH 04/21] Make UUIDs jsonifiable. --- app/models/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/base.py b/app/models/base.py index b63c56e..7bb2721 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,3 +1,4 @@ +from uuid import UUID from datetime import datetime from sqlalchemy.sql import func from sqlalchemy.ext.declarative import declared_attr @@ -33,6 +34,9 @@ class Model(object): if isinstance(value, datetime): value = value.isoformat() + if isinstance(value, UUID): + value = str(value) + d[key] = value return d -- GitLab From 562786b4faf8a9ed0b5ac906141c1ae2515080df Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:07 -0400 Subject: [PATCH 05/21] Add a note model. --- app/models/note.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/models/note.py diff --git a/app/models/note.py b/app/models/note.py new file mode 100644 index 0000000..a4e80ad --- /dev/null +++ b/app/models/note.py @@ -0,0 +1,17 @@ +"""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.columns import GUID + + +class Note(Model, Tracked, SalesForce, db.Model): + """A note.""" + 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(GUID()) + poster_name = db.Column(db.Unicode(255)) -- GitLab From 2fd6059772f461554bd8b9eef25a48349ba6a639 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:20 -0400 Subject: [PATCH 06/21] Add note models to the test environment. --- app/tests/environment.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/tests/environment.py b/app/tests/environment.py index b75aec7..bb754ad 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -3,6 +3,7 @@ This provides several example models which can be used as needed in the test cases. """ +import uuid import requests from datetime import datetime, timedelta @@ -14,6 +15,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 +133,20 @@ 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( + 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', + title='Test', + content='This is a test note.', + poster_name='test')) -- GitLab From 7f5627010c110494f91d1540e027e7739ad93e34 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:29 -0400 Subject: [PATCH 07/21] Add a note form. --- app/forms/note.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 app/forms/note.py diff --git a/app/forms/note.py b/app/forms/note.py new file mode 100644 index 0000000..4c05b85 --- /dev/null +++ b/app/forms/note.py @@ -0,0 +1,17 @@ +"""Forms for making small notes on a project.""" +import wtforms as wtf +from app.forms import validators +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)]) + title = wtf.StringField(validators=[wtf.validators.Length(max=255)]) + content = wtf.StringField() + + poster_uuid = wtf.StringField(validators=[validators.UUID()]) + poster_name = wtf.StringField(validators=[ + wtf.validators.Length(max=255), + validators.RequiredIfNot('poster_uuid')]) -- GitLab From b21c3109feb464d7a7f618e50638b907e6ce26dc Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:40 -0400 Subject: [PATCH 08/21] Add a note controller. --- app/controllers/note.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/controllers/note.py diff --git a/app/controllers/note.py b/app/controllers/note.py new file mode 100644 index 0000000..bc68af9 --- /dev/null +++ b/app/controllers/note.py @@ -0,0 +1,14 @@ +"""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', 'poster_uuid', 'poster_name'] + + def get_form(self, filter_data): + """Return the note form.""" + return NoteForm -- GitLab From 21d91ef003c93eb334bd77488d9c5cf2ef924afb Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:47 -0400 Subject: [PATCH 09/21] Add a note view. --- app/views/note.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/views/note.py diff --git a/app/views/note.py b/app/views/note.py new file mode 100644 index 0000000..4a76d7b --- /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() -- GitLab From f92819cf8cbbaaa5b0b432b1906c23ef73bcb377 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:33:56 -0400 Subject: [PATCH 10/21] Register the note view. --- app/views/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/__init__.py b/app/views/__init__.py index 7908ceb..fbc891b 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) -- GitLab From 6cda0d828b7020ea799f7e65ec38a3ca0fe7f326 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 12:34:07 -0400 Subject: [PATCH 11/21] Add unit tests for the /note/ endpoints. --- app/tests/test_note.py | 107 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 app/tests/test_note.py diff --git a/app/tests/test_note.py b/app/tests/test_note.py new file mode 100644 index 0000000..3b63358 --- /dev/null +++ b/app/tests/test_note.py @@ -0,0 +1,107 @@ +"""Unit tests for notes.""" +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 = { + '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', + '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_uuid_no_name(self): + """Tests /note/ POST with no poster uuid or name. It should 400.""" + data = { + '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 = { + '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_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_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) -- GitLab From 0528fc39e90f91ef82ba8d88f24e81d0bf4a8945 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 13:37:37 -0400 Subject: [PATCH 12/21] Add a project id to the note model. --- app/controllers/note.py | 3 ++- app/forms/note.py | 1 + app/models/note.py | 2 ++ app/tests/environment.py | 2 ++ app/tests/test_note.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/controllers/note.py b/app/controllers/note.py index bc68af9..f1806cd 100644 --- a/app/controllers/note.py +++ b/app/controllers/note.py @@ -7,7 +7,8 @@ from app.forms.note import NoteForm class NoteController(RestController): """The note controller.""" Model = Note - constant_fields = ['sales_force_id', 'poster_uuid', 'poster_name'] + constant_fields = [ + 'sales_force_id', 'project_id', 'poster_uuid', 'poster_name'] def get_form(self, filter_data): """Return the note form.""" diff --git a/app/forms/note.py b/app/forms/note.py index 4c05b85..75cc836 100644 --- a/app/forms/note.py +++ b/app/forms/note.py @@ -8,6 +8,7 @@ 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()]) title = wtf.StringField(validators=[wtf.validators.Length(max=255)]) content = wtf.StringField() diff --git a/app/models/note.py b/app/models/note.py index a4e80ad..a3e956c 100644 --- a/app/models/note.py +++ b/app/models/note.py @@ -6,6 +6,8 @@ from app.models.columns import GUID class Note(Model, Tracked, SalesForce, db.Model): """A note.""" + project_id = db.Column( + db.Integer, db.ForeignKey('project.id'), nullable=False) title = db.Column(db.Unicode(255)) content = db.Column(db.UnicodeText()) diff --git a/app/tests/environment.py b/app/tests/environment.py index bb754ad..db24cf7 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -138,6 +138,7 @@ class Environment(object): 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()))) @@ -147,6 +148,7 @@ class Environment(object): """A note with a poster name.""" return self.add(Note( sales_force_id='test', + project_id=self.project.id, 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 index 3b63358..724e8ea 100644 --- a/app/tests/test_note.py +++ b/app/tests/test_note.py @@ -24,6 +24,7 @@ class TestNote(RestTestCase): 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())} @@ -36,6 +37,7 @@ class TestNote(RestTestCase): """Tests /note/ POST with a poster name.""" data = { 'sales_force_id': 'test', + 'project_id': self.env.project.id, 'title': 'Test', 'content': 'This is a test note.', 'poster_name': 'test'} @@ -44,9 +46,29 @@ class TestNote(RestTestCase): 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) @@ -55,6 +77,7 @@ class TestNote(RestTestCase): 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'} @@ -83,6 +106,15 @@ class TestNote(RestTestCase): 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_poster_uuid(self): """Tests /note/ PUT with a poster uuid. It should 400.""" model = self.env.note -- GitLab From 39b36200c1887be07d244f979dca17d3b98217bc Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 13:49:46 -0400 Subject: [PATCH 13/21] Add arrow module requirement. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71b304e..d10ab9c 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 -- GitLab From 37dc7b8c81a4c1ba37400464f3616e8148076fbf Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 16:31:39 -0400 Subject: [PATCH 14/21] Add a uuid form field. --- app/forms/fields.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/forms/fields.py diff --git a/app/forms/fields.py b/app/forms/fields.py new file mode 100644 index 0000000..c8d0aef --- /dev/null +++ b/app/forms/fields.py @@ -0,0 +1,20 @@ +"""Custom WTForms fields.""" +import uuid +import arrow +import wtforms as wtf + + +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') -- GitLab From a0c930f0ed01a18e836d316452f1a22a06013e77 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 16:32:01 -0400 Subject: [PATCH 15/21] Add an arrow form field. --- app/forms/fields.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/forms/fields.py b/app/forms/fields.py index c8d0aef..be879ff 100644 --- a/app/forms/fields.py +++ b/app/forms/fields.py @@ -4,6 +4,25 @@ 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): -- GitLab From a06d1620f198858d3c7bd94aa922485ef21b0b91 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 16:32:43 -0400 Subject: [PATCH 16/21] Switch poster uuid to a uuid field in the note form. --- app/forms/note.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/forms/note.py b/app/forms/note.py index 75cc836..3093562 100644 --- a/app/forms/note.py +++ b/app/forms/note.py @@ -1,6 +1,6 @@ """Forms for making small notes on a project.""" import wtforms as wtf -from app.forms import validators +from app.forms import validators, fields from app.forms.base import Form @@ -12,7 +12,7 @@ class NoteForm(Form): title = wtf.StringField(validators=[wtf.validators.Length(max=255)]) content = wtf.StringField() - poster_uuid = wtf.StringField(validators=[validators.UUID()]) + poster_uuid = fields.UUID() poster_name = wtf.StringField(validators=[ wtf.validators.Length(max=255), validators.RequiredIfNot('poster_uuid')]) -- GitLab From b5a33b2653b1427ee47bb93d3560ddad1e5a78c9 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 16:33:39 -0400 Subject: [PATCH 17/21] Compare form field values directly to check that they are constant (instead of comparing strings). --- app/controllers/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/base.py b/app/controllers/base.py index 2fb7149..bbea4d7 100644 --- a/app/controllers/base.py +++ b/app/controllers/base.py @@ -123,7 +123,7 @@ class RestController(object): if not form_field: if getattr(model, field): raise e - elif not str(getattr(model, field)) == str(form_field.data): + elif getattr(model, field) != form_field.data: raise e form.populate_obj(model) -- GitLab From f7bc01efde9e085cbe71d2b556f2f92d3023f6f7 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 16:33:54 -0400 Subject: [PATCH 18/21] Add the posted datetime to notes. --- app/controllers/note.py | 2 +- app/forms/note.py | 2 ++ app/models/base.py | 8 +++++--- app/models/columns.py | 24 ++++++++++++++++++++++-- app/models/note.py | 6 ++++-- app/tests/environment.py | 2 ++ app/tests/test_note.py | 22 ++++++++++++++++++++++ 7 files changed, 58 insertions(+), 8 deletions(-) diff --git a/app/controllers/note.py b/app/controllers/note.py index f1806cd..829ed51 100644 --- a/app/controllers/note.py +++ b/app/controllers/note.py @@ -8,7 +8,7 @@ class NoteController(RestController): """The note controller.""" Model = Note constant_fields = [ - 'sales_force_id', 'project_id', 'poster_uuid', 'poster_name'] + 'sales_force_id', 'project_id', 'posted', 'poster_uuid', 'poster_name'] def get_form(self, filter_data): """Return the note form.""" diff --git a/app/forms/note.py b/app/forms/note.py index 3093562..b092c64 100644 --- a/app/forms/note.py +++ b/app/forms/note.py @@ -9,6 +9,8 @@ class NoteForm(Form): 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() diff --git a/app/models/base.py b/app/models/base.py index 7bb2721..53a7771 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,8 +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): @@ -31,7 +33,7 @@ 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): @@ -43,8 +45,8 @@ class Model(object): 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 747db83..5b715e7 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 index a3e956c..8a71388 100644 --- a/app/models/note.py +++ b/app/models/note.py @@ -1,13 +1,15 @@ """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.columns import GUID +from app.models import columns class Note(Model, Tracked, SalesForce, db.Model): """A note.""" project_id = db.Column( db.Integer, db.ForeignKey('project.id'), nullable=False) + + posted = db.Column(columns.Arrow()) title = db.Column(db.Unicode(255)) content = db.Column(db.UnicodeText()) @@ -15,5 +17,5 @@ class Note(Model, Tracked, SalesForce, db.Model): # 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(GUID()) + 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 db24cf7..4c0d50f 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -3,6 +3,7 @@ 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 @@ -149,6 +150,7 @@ class Environment(object): 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 index 724e8ea..e15d689 100644 --- a/app/tests/test_note.py +++ b/app/tests/test_note.py @@ -1,4 +1,5 @@ """Unit tests for notes.""" +import arrow import uuid from sqlalchemy import and_ from app.lib.database import db @@ -38,6 +39,7 @@ class TestNote(RestTestCase): 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'} @@ -84,6 +86,17 @@ class TestNote(RestTestCase): 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 @@ -115,6 +128,15 @@ class TestNote(RestTestCase): 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 -- GitLab From 878f16701cedb0be75691ac4f429876848605312 Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 17:50:53 -0400 Subject: [PATCH 19/21] Delete notes when their project is deleted. --- app/models/note.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/note.py b/app/models/note.py index 8a71388..470c1dc 100644 --- a/app/models/note.py +++ b/app/models/note.py @@ -7,7 +7,9 @@ from app.models import columns class Note(Model, Tracked, SalesForce, db.Model): """A note.""" project_id = db.Column( - db.Integer, db.ForeignKey('project.id'), nullable=False) + db.Integer, + db.ForeignKey('project.id', ondelete='CASCADE'), + nullable=False) posted = db.Column(columns.Arrow()) title = db.Column(db.Unicode(255)) -- GitLab From 5ff17fb007b79f89b357a5a4edd86ba0874f3ecf Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 18:03:06 -0400 Subject: [PATCH 20/21] Add support for bulk uploading of notes in the /project/?form=salesforce POST endpoint. --- app/controllers/project.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/controllers/project.py b/app/controllers/project.py index c9827ae..be3dd41 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) -- GitLab From 2c2ae260ead54ad9307a6631a56ca942f665866a Mon Sep 17 00:00:00 2001 From: astex <0astex@gmail.com> Date: Mon, 28 Mar 2016 18:03:23 -0400 Subject: [PATCH 21/21] Add unit tests for posting notes from salesforce. --- app/tests/test_project.py | 95 +++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/app/tests/test_project.py b/app/tests/test_project.py index d6240d4..628daa9 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, -- GitLab