diff --git a/.gitignore b/.gitignore index c16b943a540e9bbaac9e95e9d1fdecdf434df404..1665ba656541b2f293819aed4702736364b75a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,22 @@ config.json # PyCharm .idea ->>>>>>> d818a0e1981417ebc795923ddc504dc124f62a16 + +# Vagrant +.vagrant + +# Mac system +.DS_Store +._.DS_Store + +# Logs +/logs/ + +# Virtualenv +virtpy/ + +# Data +/data/ + +# Local Dev Folder +/local_dev \ No newline at end of file diff --git a/app/controllers/place.py b/app/controllers/place.py new file mode 100644 index 0000000000000000000000000000000000000000..12a6b6c80ac1aa3ea7d99ab070c7bd45b5f29e8a --- /dev/null +++ b/app/controllers/place.py @@ -0,0 +1,30 @@ +from werkzeug.exceptions import BadRequest + +from app.controllers.base import RestController +from app.forms.place import AddressForm, PlaceForm +from app.models.place import Address, Place + + +class PlaceController(RestController): + """The Place controller.""" + Model = Place + constant_fields = ['sales_force_id', 'address_id'] + + def get_form(self, filter_data): + """Return the Place form. + Args: + filter_data (dict) + """ + return PlaceForm + + +class AddressController(RestController): + """The Address controller.""" + Model = Address + + def get_form(self, filter_data): + """Return the Address form. + Args: + filter_data (dict) + """ + return AddressForm diff --git a/app/controllers/project.py b/app/controllers/project.py index f79edea5f20122cc7effd804949ec488c64bea31..24931b685ce83c26c9336bf450f00df91a7fceec 100644 --- a/app/controllers/project.py +++ b/app/controllers/project.py @@ -9,7 +9,7 @@ from app.forms.project import ProjectForm class ProjectController(RestController): """The project controller.""" Model = Project - constant_fields = ['sales_force_id', 'client_id'] + constant_fields = ['sales_force_id', 'client_id', 'place_id'] def get_form(self, filter_data): """Return the project form.""" diff --git a/app/forms/place.py b/app/forms/place.py new file mode 100644 index 0000000000000000000000000000000000000000..08c522b3c6d4a043f090387f66dee9779c079ce4 --- /dev/null +++ b/app/forms/place.py @@ -0,0 +1,29 @@ +"""Forms for validating Addresses, Locations, Places.""" +import wtforms as wtf +from app.forms import validators +from app.forms.base import Form +from app.lib import geography + + +class AddressForm(Form): + """A form for posting new Addresses.""" + street_address = wtf.StringField( + validators=[wtf.validators.Required(), wtf.validators.Length(max=255)]) + city = wtf.StringField( + validators=[wtf.validators.Required(), wtf.validators.Length(max=255)]) + county = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) + state = wtf.StringField( + validators=[wtf.validators.AnyOf(geography.states)]) + country = wtf.StringField( + validators=[wtf.validators.AnyOf(geography.countries)]) + postal_code = wtf.StringField(validators=[validators.zip_]) + + +class PlaceForm(Form): + """A form for posting new Places.""" + sales_force_id = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) + name = wtf.StringField( + validators=[wtf.validators.Required(), wtf.validators.Length(max=255)]) + address_id = wtf.IntegerField(validators=[wtf.validators.Required()]) diff --git a/app/forms/project.py b/app/forms/project.py index e56e9e0587d39050c4dd84b7bb339d5d11d85fe5..d69d9bf8789167e485632915787b59c2a6f94131 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -10,6 +10,7 @@ class ProjectForm(Form): sales_force_id = wtf.StringField( validators=[wtf.validators.Length(max=255)]) client_id = wtf.IntegerField(validators=[wtf.validators.Required()]) + place_id = wtf.IntegerField(validators=[wtf.validators.Required()]) name = wtf.StringField( validators=[ wtf.validators.Required(), diff --git a/app/forms/validators.py b/app/forms/validators.py index 1aed52614485aaa9d3a97611be32225b2534e902..da9c2ec0df52b8ab1dd86db0eed0032eda44432d 100644 --- a/app/forms/validators.py +++ b/app/forms/validators.py @@ -22,3 +22,6 @@ class Map(object): # A phone number validator. phone = wtf.validators.Regexp(r'^[0-9]{10,}$') + +# A zip code validator. +zip_ = wtf.validators.Regexp(r'^\d{5}(-\d{4})?$') diff --git a/app/lib/geography.py b/app/lib/geography.py new file mode 100644 index 0000000000000000000000000000000000000000..57c94c8c936263631ab68bffd7e5e2b4af94c6fb --- /dev/null +++ b/app/lib/geography.py @@ -0,0 +1,20 @@ +"""Utilities for working with geographical entities.""" +# Valid strings representing valid provinces and states. +# +# TODO SQLAlchemy < 1.1 does not support the use of a python enum in Enum +# fields, so for now we have to use a list of strings. When 1.1 leaves +# beta, this should be ported to an Enum. +states = [ + 'AL', 'AK', 'AS', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'DC', 'FM', 'FL', + 'GA', 'GU', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MH', + 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', + 'NY', 'NC', 'ND', 'MP', 'OH', 'OK', 'OR', 'PW', 'PA', 'PR', 'RI', 'SC', + 'SD', 'TN', 'TX', 'UT', 'VT', 'VI', 'VA', 'WA', 'WV', 'WI', 'WY' +] + +# Valid strings representing countries. +# +# TODO SQLAlchemy < 1.1 does not support the use of a python enum in Enum +# fields, so for now we have to use a list of strings. When 1.1 leaves +# beta, this should be ported to an Enum. +countries = ['United States of America'] diff --git a/app/models/client.py b/app/models/client.py index a473e61bc27389c9bde30ea57ff98da6bf717bda..afa2543572c453303dd67511d7ee4016a4cb40b3 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -5,5 +5,4 @@ from app.models.base import Model, Tracked, SalesForce class Client(Model, Tracked, SalesForce, db.Model): """A client.""" - sales_force_id = db.Column(db.Unicode(255), index=True) name = db.Column(db.Unicode(255), nullable=False) diff --git a/app/models/place.py b/app/models/place.py new file mode 100644 index 0000000000000000000000000000000000000000..5062eb7a6a19a003ce35d026f736346c64e45bda --- /dev/null +++ b/app/models/place.py @@ -0,0 +1,22 @@ +from geoalchemy2 import Geometry + +from app.lib.database import db +from app.lib import geography +from app.models.base import Model, Tracked, SalesForce + + +class Place(Model, Tracked, SalesForce, db.Model): + """The Place class""" + name = db.Column(db.Unicode(255), nullable=False) + address_id = db.Column( + db.Integer, db.ForeignKey('address.id'), nullable=False) + + +class Address(Model, Tracked, db.Model): + """The Address class""" + street_address = db.Column(db.Unicode(255), nullable=False) + city = db.Column(db.Unicode(255), nullable=False) + county = db.Column(db.Unicode(255)) + state = db.Column(db.Enum(*geography.states), nullable=False) + country = db.Column(db.Enum(*geography.countries), nullable=False) + postal_code = db.Column(db.Unicode(10)) diff --git a/app/models/project.py b/app/models/project.py index d5726340f3b960b5407473462b7506c9797a37c9..ec3bbfd1c45c7c6ad40d733b52c27da05208619c 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -36,6 +36,8 @@ class Project(Model, Tracked, SalesForce, db.Model): client_id = db.Column( db.Integer, db.ForeignKey('client.id'), nullable=False) + place_id = db.Column( + db.Integer, db.ForeignKey('place.id'), nullable=False) name = db.Column(db.Unicode(255), nullable=False) state = db.Column(db.Enum(*states), default='pending', nullable=False) diff --git a/app/tests/environment.py b/app/tests/environment.py index 145b0a43dcf941e1c3cd60963c1b96638901ca0a..b75aec73c1ba6c3204ae957fa4c89c58b07bae52 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -10,6 +10,7 @@ from app.lib.red import redis from app.lib.database import db, commit from app.models.application import Application +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 @@ -68,11 +69,29 @@ class Environment(object): def client(self): return self.add(Client(sales_force_id='test', name='test')) + @property + def address(self): + return self.add(Address( + street_address='15 MetroTech Center', + city='Brooklyn', + county='Kings', + state='NY', + country='United States of America', + postal_code='11210')) + + @property + def place(self): + return self.add(Place( + sales_force_id='test', + name='test', + address_id=self.address.id)) + @property def project(self): return self.add(Project( sales_force_id='test', client_id=self.client.id, + place_id=self.place.id, name='test')) @property diff --git a/app/tests/test_place.py b/app/tests/test_place.py new file mode 100644 index 0000000000000000000000000000000000000000..5aba1bc40dc4fd065418063bc9cdce4a8c6444e7 --- /dev/null +++ b/app/tests/test_place.py @@ -0,0 +1,224 @@ +"""Unit tests for working with Places and Addresses.""" +from sqlalchemy import and_ +from app.lib.database import db +from app.tests.base import RestTestCase +from app.models.place import Place, Address + + +class TestPlace(RestTestCase): + """Tests the /place/ endpoints.""" + url = '/place/' + Model = Place + + def test_index(self): + """Tests /place/ GET.""" + model = self.env.place + self._test_index() + + def test_get(self): + """Tests /place/ GET.""" + model = self.env.place + self._test_get(model.id) + + def test_post(self): + """Tests /place/ POST.""" + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'address_id': self.env.address.id} + response_data = self._test_post(data) + self.assertTrue( + db.session.query(Place)\ + .filter(and_( + Place.id == response_data['id'], + Place.sales_force_id == data['sales_force_id'], + Place.name == data['name'], + Place.address_id == data['address_id'], + ))\ + .first()) + + def test_post_missing_name(self): + """Tests /place/ POST with a missing name. It should 400.""" + data = {'sales_force_id': 'test', 'address_id': self.env.address.id} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_missing_address_id(self): + """Tests /place/ POST with a missing address ID. It should 400.""" + data = {'sales_force_id': 'test', 'name': 'test'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_address_id(self): + """Tests /place/ POST with a bad address ID. It should 400.""" + data = {'sales_force_id': 'test', 'name': 'test', 'address_id': '100000000'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /place/ PUT.""" + model = self.env.place + data = model.get_dictionary() + data.update({'name': 'foo'}) + response_data = self._test_put(model.id, data) + self.assertEqual(response_data['name'], 'foo') + self.assertTrue( + db.session.query(Place)\ + .filter(and_( + Place.id == model.id, + Place.name == data['name'] + ))\ + .first()) + + def test_put_sales_force_id(self): + """Tests /place/ PUT with a sales force id. It should 400.""" + model = self.env.project + data = model.get_dictionary() + data.update({'sales_force_id': 'foo'}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_address_id(self): + """Tests /place/ PUT with an address id. It should 400.""" + model = self.env.place + data = model.get_dictionary() + data.update({'address_id': self.env.address.id}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_delete(self): + """Tests /place/ DELETE. It should 405.""" + model = self.env.place + response = self.delete(self.url + str(model.id)) + self.assertEqual(response.status_code, 405) + + +class TestAddress(RestTestCase): + """Tests the /address/ endpoints.""" + url = '/address/' + Model = Address + + def test_index(self): + """Tests /address/ GET.""" + model = self.env.address + self._test_index() + + def test_get(self): + """Tests /address/ GET.""" + model = self.env.address + self._test_get(model.id) + + def test_post(self): + """Tests /address/ POST.""" + data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11210'} + response_data = self._test_post(data) + self.assertTrue( + db.session.query(Address)\ + .filter(and_( + Address.id == response_data['id'], + Address.street_address == data['street_address'], + Address.city == data['city'], + Address.state == data['state'], + Address.country == data['country'], + Address.postal_code == data['postal_code']))\ + .first()) + + def test_post_missing_street_address(self): + """Tests /address/ POST with a missing street_address. It should 400.""" + data = { + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_missing_city(self): + """Tests /address/ POST with missing city. It should 400.""" + data = { + 'street_address': '15 MetroTech Center', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_missing_state(self): + """Tests /address/ POST with missing state. It should 400.""" + data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'country': 'United States of America', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_invalid_state(self): + """Tests /address/ POST with invalid state. It should 400.""" + data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NN', + 'country': 'United States of America', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_missing_country(self): + """Tests /address/ POST with missing Country. It should 400.""" + data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_invalid_country(self): + """Tests /address/ POST with missing Country. It should 400.""" + data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America!', + 'postal_code': '11210'} + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /address/ PUT.""" + model = self.env.address + data = model.get_dictionary() + new_data = { + 'street_address': '1600 Pennsylvania Ave', + 'city': 'Washington, DC', + 'county': 'Washington County, DC', + 'state': 'DC', + 'postal_code': '20006'} + data.update(new_data) + response_data = self._test_put(model.id, data) + for key, value in new_data.items(): + self.assertEqual(response_data[key], value) + + filters = [Address.id == model.id] + filters += [getattr(Address, k) == data[k] for k in new_data.keys()] + self.assertTrue( + db.session.query(Address).filter(and_(*filters)).first()) + + def test_delete(self): + """Tests /address/ DELETE. It should 405.""" + model = self.env.address + response = self.delete(self.url + str(model.id)) + self.assertEqual(response.status_code, 405) diff --git a/app/tests/test_project.py b/app/tests/test_project.py index fbc4b85f076714b2a0614781fb17914cf14f9d2c..ac68ef2a1121ade1fea6e8dbdb1ca664154713ae 100644 --- a/app/tests/test_project.py +++ b/app/tests/test_project.py @@ -30,6 +30,7 @@ class TestProject(RestTestCase): data = { 'sales_force_id': 'test', 'client_id': self.env.client.id, + 'place_id': self.env.place.id, 'name': 'test', 'state': 'pending'} response_data = self._test_post(data) @@ -54,6 +55,7 @@ class TestProject(RestTestCase): data={ 'sales_force_id': 'test', 'client_id': self.env.client.id, + 'place_id': self.env.place.id, 'state': 'pending'}) self.assertEqual(response.status_code, 400) @@ -63,6 +65,7 @@ class TestProject(RestTestCase): data={ 'sales_force_id': 'test', 'client_id': self.env.client.id, + 'place_id': self.env.place.id, 'name': 'test'}) self.assertEqual(response.status_code, 400) @@ -82,6 +85,18 @@ class TestProject(RestTestCase): data={ 'sales_force_id': 'test', 'client_id': 1, + 'place_id': self.env.place.id, + 'name': 'test', + 'state': 'pending'}) + self.assertEqual(response.status_code, 400) + + def test_post_bad_place_id(self): + """Tests /project/ POST with a bad place id.""" + response = self.post(self.url, + data={ + 'sales_force_id': 'test', + 'client_id': self.env.client.id, + 'place_id': 1, 'name': 'test', 'state': 'pending'}) self.assertEqual(response.status_code, 400) @@ -145,6 +160,14 @@ class TestProject(RestTestCase): response = self.put(self.url + str(model.id), data=data) self.assertEqual(response.status_code, 400) + def test_put_place_id(self): + """Tests /project/ PUT with a place id. It should 400.""" + model = self.env.project + data = model.get_dictionary() + data.update({'place_id': self.env.place.id}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + def test_delete(self): """Tests /project/ DELETE. It should 405.""" model = self.env.project diff --git a/app/views/__init__.py b/app/views/__init__.py index 3582222a712c1db097513390de95ea77b39d82a1..7908ceb226dd214a91981a4632cd08c8d59f9155 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 +from app.views import application, auth, project, client, contact, place def register(app): @@ -19,3 +19,6 @@ def register(app): contact.ContactView.register(app) contact.ContactMethodView.register(app) contact.ProjectContactView.register(app) + + place.PlaceView.register(app) + place.AddressView.register(app) diff --git a/app/views/place.py b/app/views/place.py new file mode 100644 index 0000000000000000000000000000000000000000..c03bcd5a9660f070a7cfd2e9f4cb23d92e7baed7 --- /dev/null +++ b/app/views/place.py @@ -0,0 +1,36 @@ +from flask import request +from werkzeug.exceptions import MethodNotAllowed + +from app.controllers.place import AddressController, PlaceController +from app.views.base import RestView + + +class PlaceView(RestView): + """The Place view.""" + def get_controller(self): + """Return an instance of the Place controller.""" + return PlaceController() + + def delete(self, id_): + """Not implemented. + Args: + id_ - The object ID + """ + raise MethodNotAllowed( + 'Places cannot be deleted. Cancel the project instead.') + + +class AddressView(RestView): + """The Address view.""" + def get_controller(self): + """Return an instance of the Address controller.""" + return AddressController() + + def delete(self, id_): + """Not implemented. + + Args: + id_ - The object ID + """ + raise MethodNotAllowed( + 'Addresses cannot be deleted. Cancel the project instead.') diff --git a/requirements.txt b/requirements.txt index 2f33974ef1a383cdbb2fcf3eb906202e85b832fa..4f1139e23fb84c6574ea0bac4d8aa34cee5ea545 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,4 @@ wcwidth==0.1.6 websocket-client==0.35.0 Werkzeug==0.11.4 WTForms==2.1 +geoalchemy2==0.2.6