diff --git a/.gitignore b/.gitignore index 1665ba656541b2f293819aed4702736364b75a9e..a53075ef894ddb164bbe6692ce5013a824f82ae0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ config.json # PyCharm .idea +# Nosetests Coverage +.coverage + # Vagrant .vagrant @@ -30,8 +33,5 @@ config.json # Virtualenv virtpy/ -# Data -/data/ - # Local Dev Folder -/local_dev \ No newline at end of file +/local_dev diff --git a/app/controllers/project.py b/app/controllers/project.py index 24931b685ce83c26c9336bf450f00df91a7fceec..c9827ae714d1c9e07c12df47e32bea3eb7b8a31d 100644 --- a/app/controllers/project.py +++ b/app/controllers/project.py @@ -1,8 +1,20 @@ """Controllers for managing top-level projects.""" +from sqlalchemy import and_ from werkzeug.exceptions import BadRequest + from app.lib.database import db from app.controllers.base import RestController + +from app.models.client import Client +from app.models.contact import Contact, ContactMethod, ProjectContact +from app.models.place import Place, Address from app.models.project import Project, ProjectStateChange + +from app.controllers.client import ClientController +from app.controllers.contact import ( + ContactController, ContactMethodController, ProjectContactController) +from app.controllers.place import PlaceController, AddressController + from app.forms.project import ProjectForm @@ -22,8 +34,141 @@ class ProjectController(RestController): self.commit() def post(self, data, filter_data): - """Logs a model state change after saving a new model.""" + """Post a new model. + + This logs a model state change after saving a new model. It + includes support for salesforce, which dumps all submodels (e.g + client, contacts, ...) in addition to the model itself. + + Behavior with the embedded models is a little odd. Rather than + validating everything up front, we validate as we go. Except for + the project itself, an object will successfully save if all + subsequent saves succeeded. For example, this means that a client + may be created even if the request 400s due to a problem with the + project itself. + """ + # A project has to have a client and a place, so we create these models + # before posting the project when performing a bulk upload. + if filter_data.get('form') == 'salesforce': + try: + client_data = data['client'] + place_data = data['place'] + address_data = data['address'] + except KeyError: + raise BadRequest( + 'SalesForce projects require an embedded client, place, ' + + 'and address.') + + # Create/modify the client. + try: + client = db.session.query(Client)\ + .filter( + Client.sales_force_id == client_data['sales_force_id'])\ + .first() + except KeyError: + raise BadRequest( + 'When embedding a client in a salesforce project, please ' + + 'include its salesforce id.') + if client: + client = ClientController().put(client.id, client_data, {}) + else: + client = ClientController().post(client_data, {}) + data.update({'client_id': client.id}) + + # Create/modify the place and address. + try: + place = db.session.query(Place)\ + .filter( + Place.sales_force_id == place_data['sales_force_id'])\ + .first() + except KeyError: + raise BadRequest( + 'When embedding a place in a salesforce project, please ' + + 'include its salesforce id.') + + address = place.address if place else None + if address: + address = AddressController().put(address.id, address_data, {}) + else: + address = AddressController().post(address_data, {}) + place_data.update({'address_id': address.id}) + + if place: + place = PlaceController().put(place.id, place_data, {}) + else: + place = PlaceController().post(place_data, {}) + data.update({'place_id': place.id}) + model = super(ProjectController, self).post(data, filter_data) + + if filter_data.get('form') == 'salesforce': + try: + try: + contacts_data = data['contacts'] + contact_methods_data = data['contact_methods'] + except KeyError: + raise BadRequest( + 'SalesForce projects require embedded contacts.') + + for contact_data in contacts_data: + # Create the contact if it does not exist. + contact = db.session.query(Contact)\ + .filter(Contact.sales_force_id == \ + contact_data['sales_force_id'])\ + .first() + if contact: + contact = ContactController().put( + contact.id, contact_data, {}) + else: + contact = ContactController().post(contact_data, {}) + + # Associate the contact with the project. + project_contact = db.session.query(ProjectContact)\ + .filter(and_( + ProjectContact.project_id == model.id, + ProjectContact.contact_id == contact.id))\ + .first() + if not project_contact: + ProjectContactController().post( + {'project_id': model.id, 'contact_id': contact.id}, + {}) + + for contact_method_data in contact_methods_data: + # Grab the contact by salesforce id. + contact = db.session.query(Contact)\ + .join(ProjectContact, + ProjectContact.contact_id == Contact.id)\ + .join(Project, Project.id == ProjectContact.project_id)\ + .filter(and_( + Project.id == model.id, + Contact.sales_force_id == \ + contact_method_data['contact_sales_force_id']))\ + .first() + if not contact: + raise BadRequest( + 'Contact method references a contact that is not ' + + 'associated with the project.') + + contact_method = db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.contact_id == contact.id, + ContactMethod.method == \ + contact_method_data['method'], + ContactMethod.value == \ + contact_method_data['value']))\ + .first() + if not contact_method: + contact_method_data.update({'contact_id': contact.id}) + contact_method = ContactMethodController().post( + contact_method_data, + {}) + + except BadRequest as e: + # Do not keep the project if contacts failed to upload. + db.session.delete(model) + self.commit() + raise e + self.log_state_change(model) return model diff --git a/app/models/place.py b/app/models/place.py index 5062eb7a6a19a003ce35d026f736346c64e45bda..61ef4bdbcdc3b979d28625dc4814fafc0fd040fc 100644 --- a/app/models/place.py +++ b/app/models/place.py @@ -1,4 +1,4 @@ -from geoalchemy2 import Geometry +from sqlalchemy.orm import relationship from app.lib.database import db from app.lib import geography @@ -11,6 +11,8 @@ class Place(Model, Tracked, SalesForce, db.Model): address_id = db.Column( db.Integer, db.ForeignKey('address.id'), nullable=False) + address = relationship('Address', uselist=False) + class Address(Model, Tracked, db.Model): """The Address class""" diff --git a/app/tests/test_project.py b/app/tests/test_project.py index ac68ef2a1121ade1fea6e8dbdb1ca664154713ae..d6240d4a3c56c0f2632f742c5565c6f57d209bcb 100644 --- a/app/tests/test_project.py +++ b/app/tests/test_project.py @@ -3,6 +3,9 @@ from sqlalchemy import and_ from app.lib.database import db 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.project import Project, ProjectStateChange @@ -21,6 +24,11 @@ class TestProject(RestTestCase): model = self.env.project self._test_get(model.id) + def test_get_bad_id(self): + """Tests /project/ GET with a bad id. It should 404.""" + response = self.get(self.url + str(1)) + self.assertTrue(response.status_code, 404) + def test_post(self): """Tests /project/ POST. @@ -49,6 +57,386 @@ class TestProject(RestTestCase): ProjectStateChange.state == data['state']))\ .first()) + def test_post_sf_client(self): + """Tests /project/?form=salesforce POST with a new client.""" + client_data = {'sales_force_id': 'test', 'name': 'test'} + place = self.env.place + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': client_data, + 'place': place.get_dictionary(), + 'address': place.address.get_dictionary(), + 'contacts': [], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + # Check that a client was posted with the new data. + self.assertTrue( + db.session.query(Client)\ + .join(Project, Project.client_id == Client.id) + .filter(and_( + Project.id == response_data['id'], + Client.name == client_data['name'], + Client.sales_force_id == client_data['sales_force_id']))\ + .first()) + + def test_post_sf_change_client(self): + """Tests /project/?form=salesforce POST with a modified client.""" + client = self.env.client + client_data = client.get_dictionary() + client_data.update({'name': 'foo'}) + place = self.env.place + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': client_data, + 'place': place.get_dictionary(), + 'address': place.address.get_dictionary(), + 'contacts': [], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + # Check that the client was modified as expected. + self.assertTrue( + db.session.query(Client)\ + .join(Project, Project.client_id == Client.id) + .filter(and_( + Project.id == response_data['id'], + Client.id == client.id, + Client.name == client_data['name']))\ + .first()) + + def test_post_sf_no_client(self): + """Tests /project/?form=salesforce POST with no client. This should + 400. + """ + place = self.env.place + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + '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 client could fail, but + # those are already tested in the /client/ endpoint, so there's no need + # to test them here. + + def test_post_sf_new_place(self): + """Tests /project/?form=salesforce POST with a new place and address. + """ + place_data = {'sales_force_id': 'test', 'name': 'test'} + address_data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11210'} + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'place': place_data, + 'address': address_data, + 'contacts': [], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + + filters = [Project.id == response_data['id']] + filters += [ + getattr(Place, k) == place_data.get(k) + for k in place_data.keys()] + filters += [ + getattr(Address, k) == address_data.get(k) + for k in address_data.keys()] + + self.assertTrue( + db.session.query(Address)\ + .join(Place, Place.address_id == Address.id)\ + .join(Project, Project.place_id == Place.id)\ + .filter(and_(*filters))\ + .first()) + + def test_post_sf_change_place(self): + """Tests /project/?form=salesforce POST with a modified place and + address. + """ + place = self.env.place + place_data = place.get_dictionary() + new_place_data = {'name': 'foo'} + place_data.update(new_place_data) + address_data = place.address.get_dictionary() + new_address_data = { + 'street_address': '111 N Fake St', + 'city': 'Raleigh', + 'county': 'Wake', + 'state': 'NC', + 'postal_code': '27615'} + address_data.update(new_address_data) + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'place': place_data, + 'address': address_data, + 'contacts': [], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + + filters = [ + Project.id == response_data['id'], + Place.id == place.id, + Address.id == place.address.id] + filters += [ + getattr(Place, k) == place_data.get(k) + for k in new_place_data.keys()] + filters += [ + getattr(Address, k) == address_data.get(k) + for k in new_address_data.keys()] + + self.assertTrue( + db.session.query(Address)\ + .join(Place, Place.address_id == Address.id)\ + .join(Project, Project.place_id == Place.id)\ + .filter(and_(*filters))\ + .first()) + + def test_post_sf_no_place(self): + """Tests /project/?form=salesforce POST with no place. It should 400. + """ + address_data = { + 'street_address': '15 MetroTech Center', + 'city': 'Brooklyn', + 'county': 'Kings', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11210'} + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'address': address_data, + 'contacts': [], + 'contact_methods': []} + response = self.post( + self.url, + data=data, + query_string={'form': 'salesforce'}) + self.assertEqual(response.status_code, 400) + + def test_post_sf_no_address(self): + """Tests /project/?form=salesforce POST with no address. It should 400. + """ + place_data = {'sales_force_id': 'test', 'name': 'test'} + data = { + 'sales_force_id': 'test', + 'name': 'test', + 'state': 'pending', + 'client': self.env.client.get_dictionary(), + 'place': place_data, + '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 place and address could + # fail, but those are already tested in the /address/ and /place/ + # endpoints, so there's no need to test them here. + + def test_post_sf_new_contacts(self): + """Tests /project/?form=salesforce POST with a new contact.""" + place = self.env.place + contact_data = {'sales_force_id': 'test', '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_data], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + self.assertTrue( + db.session.query(Contact)\ + .join(ProjectContact, ProjectContact.contact_id == Contact.id)\ + .join(Project, ProjectContact.project_id == Project.id)\ + .filter(and_( + Project.id == response_data['id'], + Contact.sales_force_id == contact_data['sales_force_id'], + Contact.name == contact_data['name']))\ + .first()) + + def test_post_sf_change_contacts(self): + """Tests /project/?form=salesforce POST with a modified contact.""" + place = self.env.place + contact = self.env.contact + contact_data = contact.get_dictionary() + contact_data.update({'name': 'foo'}) + 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_data], + 'contact_methods': []} + response_data = self._test_post(data, {'form': 'salesforce'}) + self.assertTrue( + db.session.query(Contact)\ + .join(ProjectContact, ProjectContact.contact_id == Contact.id)\ + .join(Project, ProjectContact.project_id == Project.id)\ + .filter(and_( + Project.id == response_data['id'], + Contact.id == contact.id, + Contact.sales_force_id == contact_data['sales_force_id'], + Contact.name == contact_data['name']))\ + .first()) + + def test_post_sf_no_contacts(self): + """Tests /project/?form=salesforce POST with no contacts. 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(), + 'contact_methods': []} + response = self.post( + self.url, + data=data, + query_string={'form': 'salesforce'}) + self.assertEqual(response.status_code, 400) + self.assertFalse(db.session.query(Project).first()) + + # There are additional ways that posting with a contact could fail, but + # those are already tested in the /contact/ and /contact/project/ + # endpoints, so there's no need to test them here. + + def test_post_sf_contact_methods(self): + """Tests /project/?form=salesforce POST with a contact method.""" + place = self.env.place + contact_data = { + 'sales_force_id': 'test', + 'name': 'test'} + contact_method_data = { + 'contact_sales_force_id': contact_data['sales_force_id'], + 'method': 'email', + 'value': 'test@blocpower.org'} + 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_data], + 'contact_methods': [contact_method_data]} + response_data = self._test_post(data, {'form': 'salesforce'}) + self.assertTrue( + db.session.query(ContactMethod)\ + .join(Contact, ContactMethod.contact_id == Contact.id)\ + .join(ProjectContact, ProjectContact.contact_id == Contact.id)\ + .join(Project, ProjectContact.project_id == Project.id)\ + .filter(and_( + Project.id == response_data['id'], + ContactMethod.method == contact_method_data['method'], + ContactMethod.value == contact_method_data['value']))\ + .first()) + + def test_post_sf_contact_methods_bad_sf_id(self): + """Tests /project/?form=salesforce POST with a contact method with a + bad contact_sales_force_id. It should 400. + """ + place = self.env.place + contact_method_data = { + 'contact_sales_force_id': 'test', + 'method': 'email', + 'value': 'test@blocpower.org'} + 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': [contact_method_data]} + response = self.post( + self.url, + data=data, + query_string={'form': 'salesforce'}) + self.assertEqual(response.status_code, 400) + + def test_post_sf_contact_methods_unassociated_sf_id(self): + """Tests /project/?form=salesforce POST with a contact method with a + contact_sales_force_id that is not associated with the project. It + should 400. + """ + place = self.env.place + contact = self.env.contact + contact_method_data = { + 'contact_sales_force_id': contact.sales_force_id, + 'method': 'email', + 'value': 'test@blocpower.org'} + 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': [contact_method_data]} + response = self.post( + self.url, + data=data, + query_string={'form': 'salesforce'}) + self.assertEqual(response.status_code, 400) + + def test_post_sf_no_contact_methods(self): + """Tests /project/?form=salesforce POST with no contact methods. 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': []} + 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 contact method could fail, + # but those are already tested in the /contact/method/ 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/requirements.txt b/requirements.txt index 4f1139e23fb84c6574ea0bac4d8aa34cee5ea545..71b304e1f2c6f625b8f73c02c49fc9ad2d5be783 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ botocore==1.3.28 cement==2.4.0 cffi==1.5.2 colorama==0.3.3 +coverage==4.0.3 docker-py==1.1.0 dockerpty==0.3.4 docopt==0.6.2 @@ -18,6 +19,7 @@ Jinja2==2.8 jmespath==0.9.0 MarkupSafe==0.23 needs==1.0.9 +nose==1.3.7 oauth2client==2.0.1 parse==1.6.6 pathspec==0.3.3