diff --git a/app/controllers/base.py b/app/controllers/base.py index 75036dc83a4ea8703f9ec58a106180dbf9376af2..330d4e6055585ccc2d981e3783c19be76496c57f 100644 --- a/app/controllers/base.py +++ b/app/controllers/base.py @@ -34,6 +34,9 @@ class RestController(object): # The primary key to use to retrieve the model in get/put/delete requests. key = 'id' + # Field names that put() is not allowed to change. + constant_fields = [] + def commit(self): """Commits the transaction. @@ -105,10 +108,19 @@ class RestController(object): form = self.get_form(filter_data)(formdata=MultiDict(data)) if not str(form.id.data) == str(id_): - raise BadRequest(['The id in the model and the uri do not match.']) + raise BadRequest( + {'id': 'The id in the model and the uri do not match.'}) if not form.validate(): 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.populate_obj(model) db.session.add(model) self.commit() diff --git a/app/controllers/client.py b/app/controllers/client.py new file mode 100644 index 0000000000000000000000000000000000000000..e9ab58337559fa03dad0eaba83d7ecc11a33f39d --- /dev/null +++ b/app/controllers/client.py @@ -0,0 +1,15 @@ +"""Controllers for managing clients.""" +from werkzeug.exceptions import BadRequest +from app.controllers.base import RestController +from app.models.client import Client +from app.forms.client import ClientForm + + +class ClientController(RestController): + """The client controller.""" + Model = Client + constant_fields = ['sales_force_id'] + + def get_form(self, filter_data): + """Return the client form.""" + return ClientForm diff --git a/app/controllers/contact.py b/app/controllers/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..9e337d9286001dcf7bbdd140e0de56835c1dffef --- /dev/null +++ b/app/controllers/contact.py @@ -0,0 +1,34 @@ +"""Controllers for managing contacts.""" +from werkzeug.exceptions import BadRequest +from app.controllers.base import RestController +from app.models.contact import Contact, ContactMethod, ProjectContact +from app.forms.contact import ContactForm, ContactMethodForm, ProjectContactForm + + +class ContactController(RestController): + """The contact controller.""" + Model = Contact + constant_fields = ['sales_force_id'] + + def get_form(self, filter_data): + """Return the contact form.""" + return ContactForm + + +class ContactMethodController(RestController): + """The contact method controller.""" + Model = ContactMethod + constant_fields = ['contact_id', 'method'] + + def get_form(self, filter_data): + """Return the contact method form.""" + return ContactMethodForm + + +class ProjectContactController(RestController): + """The controller for the project-contact m2m relationship.""" + Model = ProjectContact + + def get_form(self, filter_data): + """Return the project contact form.""" + return ProjectContactForm diff --git a/app/controllers/project.py b/app/controllers/project.py index c1aaf536cbd47f26f1603675f33c66ff8326e3e1..f79edea5f20122cc7effd804949ec488c64bea31 100644 --- a/app/controllers/project.py +++ b/app/controllers/project.py @@ -1,22 +1,19 @@ """Controllers for managing top-level projects.""" +from werkzeug.exceptions import BadRequest from app.lib.database import db from app.controllers.base import RestController from app.models.project import Project, ProjectStateChange -from app.forms.project import CreateProjectForm, UpdateProjectForm +from app.forms.project import ProjectForm class ProjectController(RestController): """The project controller.""" Model = Project + constant_fields = ['sales_force_id', 'client_id'] def get_form(self, filter_data): - try: - return { - 'create': CreateProjectForm, - 'update': UpdateProjectForm - }[filter_data.get('form')] - except KeyError: - raise BadRequest('Invalid form.') + """Return the project form.""" + return ProjectForm def log_state_change(self, model): db.session.add(ProjectStateChange( diff --git a/app/forms/client.py b/app/forms/client.py new file mode 100644 index 0000000000000000000000000000000000000000..7dc2c4a62a29c8c5ffb7e064325a7309b73e27de --- /dev/null +++ b/app/forms/client.py @@ -0,0 +1,14 @@ +"""Forms for validating clients.""" +import wtforms as wtf +from app.forms.base import Form + + +class ClientForm(Form): + """A form for posting new clients.""" + sales_force_id = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) + name = wtf.StringField( + validators=[ + wtf.validators.Required(), + wtf.validators.Length(max=255) + ]) diff --git a/app/forms/contact.py b/app/forms/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..2ccc7b9c0ccf8cc43b02b56485f8a23b4cd15402 --- /dev/null +++ b/app/forms/contact.py @@ -0,0 +1,43 @@ +"""Forms for dealing with contacts.""" +import wtforms as wtf + +from app.forms import validators +from app.forms.base import Form +from app.models.contact import ContactMethod + + +class ContactForm(Form): + """A form for validating contacts.""" + sales_force_id = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) + name = wtf.StringField( + validators=[ + wtf.validators.Required(), + wtf.validators.Length(max=255) + ]) + + +class ContactMethodForm(Form): + """A form for validating contact methods.""" + contact_id = wtf.IntegerField(validators=[wtf.validators.Required()]) + method = wtf.StringField( + validators=[ + wtf.validators.Required(), + wtf.validators.AnyOf(ContactMethod.methods) + ]) + value = wtf.StringField( + validators=[ + wtf.validators.Required(), + validators.Map('method', { + 'email': [wtf.validators.Email()], + 'phone': [validators.phone], + 'sms': [validators.phone], + 'fax': [validators.phone] + }) + ]) + + +class ProjectContactForm(Form): + """A form for validating m2m relationships between projects and contacts.""" + project_id = wtf.IntegerField(validators=[wtf.validators.Required()]) + contact_id = wtf.IntegerField(validators=[wtf.validators.Required()]) diff --git a/app/forms/project.py b/app/forms/project.py index 82e29c913eb0bad8c526f921baa3d340e7cb5c7b..e56e9e0587d39050c4dd84b7bb339d5d11d85fe5 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -5,24 +5,11 @@ from app.forms.base import Form from app.models.project import Project -class CreateProjectForm(Form): - """A form for validating new projects.""" +class ProjectForm(Form): + """A form for validating projects.""" sales_force_id = wtf.StringField( validators=[wtf.validators.Length(max=255)]) - name = wtf.StringField( - validators=[ - wtf.validators.Required(), - wtf.validators.Length(max=255) - ]) - state = wtf.StringField( - validators=[ - wtf.validators.Required(), - wtf.validators.AnyOf(Project.states) - ]) - - -class UpdateProjectForm(Form): - """A form for validating existing projects.""" + client_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 new file mode 100644 index 0000000000000000000000000000000000000000..1aed52614485aaa9d3a97611be32225b2534e902 --- /dev/null +++ b/app/forms/validators.py @@ -0,0 +1,24 @@ +"""Custom validators.""" +import wtforms as wtf + + +class Map(object): + """A mapped validator. Maps the value of another field on the form to a + list of validators to apply to this field. + """ + def __init__(self, condition_field_name, map_): + """Initialize the validator with a condition field and a map.""" + self.condition_field_name = condition_field_name + self.map_ = map_ + + def __call__(self, form, field): + """Call the subvalidators from the matching map_ value.""" + condition_field = form._fields.get(self.condition_field_name) + if condition_field: + condition_field = condition_field.data + for validator in self.map_.get(condition_field, []): + validator(form, field) + + +# 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 1a7283b8713c0afc8c6ae68c5a1257f50ca9c268..b63c56e58b79f166ba36c38cd268c83eee943393 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -41,3 +41,8 @@ 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()) + + +class SalesForce(object): + """A mixin that includes a SalesForce id.""" + sales_force_id = db.Column(db.Unicode(255), index=True) diff --git a/app/models/client.py b/app/models/client.py new file mode 100644 index 0000000000000000000000000000000000000000..a473e61bc27389c9bde30ea57ff98da6bf717bda --- /dev/null +++ b/app/models/client.py @@ -0,0 +1,9 @@ +"""Models for tracking client information.""" +from app.lib.database import db +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/contact.py b/app/models/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..f9294a0fc09683be2a555ffe88df5940590e555d --- /dev/null +++ b/app/models/contact.py @@ -0,0 +1,35 @@ +"""Models for dealing with contacts.""" +from app.lib.database import db +from app.models.base import Model, Tracked, SalesForce + + +class Contact(Model, Tracked, SalesForce, db.Model): + """A contact (i.e an entity you can reach out to).""" + name = db.Column(db.Unicode(255), nullable=False) + + +class ContactMethod(Model, Tracked, db.Model): + """A contact method.""" + # 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 the Enum below. + # + #class Methods(unicode, Enum): + # SMS = 'sms' + # Phone = 'phone' + # Fax = 'fax' + # Email = 'email' + methods = ['sms', 'phone', 'fax', 'email'] + + contact_id = db.Column( + db.Integer, db.ForeignKey('contact.id'), nullable=False) + method = db.Column(db.Enum(*methods), nullable=False) + value = db.Column(db.Unicode(255), nullable=False) + + +class ProjectContact(Model, Tracked, db.Model): + """A m2m relationship between project and contact.""" + project_id = db.Column( + db.Integer, db.ForeignKey('project.id'), nullable=False) + contact_id = db.Column( + db.Integer, db.ForeignKey('contact.id'), nullable=False) diff --git a/app/models/project.py b/app/models/project.py index 74870fb1598f7fd7df914dbb486c725bf617841a..d5726340f3b960b5407473462b7506c9797a37c9 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -1,11 +1,9 @@ """Models directly-relating to the top-level project.""" -from sqlalchemy import event +from app.lib.database import db +from app.models.base import Model, Tracked, SalesForce -from app.lib.database import db, commit -from app.models.base import Model, Tracked - -class Project(Model, Tracked, db.Model): +class Project(Model, Tracked, SalesForce, db.Model): """A project.""" # 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 @@ -36,7 +34,8 @@ class Project(Model, Tracked, db.Model): 'constructed', 'verified', 'paid' ] - sales_force_id = db.Column(db.Unicode(255), index=True) + client_id = db.Column( + db.Integer, db.ForeignKey('client.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 77ca58548ff5da7ec3eddc7d3a8fbfbfac4b2cea..145b0a43dcf941e1c3cd60963c1b96638901ca0a 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -11,6 +11,8 @@ from app.lib.database import db, commit from app.models.application import Application from app.models.project import Project +from app.models.client import Client +from app.models.contact import Contact, ContactMethod, ProjectContact class Environment(object): @@ -52,10 +54,6 @@ class Environment(object): redis.client.sadd(application.key, token) return application, token - @property - def project(self): - return self.add(Project(sales_force_id='test', name='test')) - @property def google_token(self): """Gets a valid google access token.""" @@ -65,3 +63,52 @@ class Environment(object): 'refresh_token': self.app.config['GOOGLE_REFRESH_TOKEN'], 'grant_type': 'refresh_token' }).json()['id_token'] + + @property + def client(self): + return self.add(Client(sales_force_id='test', name='test')) + + @property + def project(self): + return self.add(Project( + sales_force_id='test', + client_id=self.client.id, + name='test')) + + @property + def contact(self): + return self.add(Contact(sales_force_id='test', name='test')) + + @property + def email_contact_method(self): + return self.add(ContactMethod( + contact_id=self.contact.id, + method='email', + value='test@blocpower.org')) + + @property + def phone_contact_method(self): + return self.add(ContactMethod( + contact_id=self.contact.id, + method='phone', + value='5555555555')) + + @property + def sms_contact_method(self): + return self.add(ContactMethod( + contact_id=self.contact.id, + method='sms', + value='5555555555')) + + @property + def fax_contact_method(self): + return self.add(ContactMethod( + contact_id=self.contact.id, + method='fax', + value='5555555555')) + + @property + def project_contact(self): + return self.add(ProjectContact( + project_id=self.project.id, + contact_id=self.contact.id)) diff --git a/app/tests/test_client.py b/app/tests/test_client.py new file mode 100644 index 0000000000000000000000000000000000000000..0dca40928934543b353fbe29bf0f06f96325fca0 --- /dev/null +++ b/app/tests/test_client.py @@ -0,0 +1,68 @@ +"""Unit tests for clients.""" +from sqlalchemy import and_ +from app.lib.database import db +from app.tests.base import RestTestCase +from app.models.client import Client + + +class TestClient(RestTestCase): + """Tests the /client/ endpoints.""" + url = '/client/' + Model = Client + + def test_index(self): + """Tests /client/ GET.""" + model = self.env.client + self._test_index() + + def test_get(self): + """Tests /client/ GET.""" + model = self.env.client + self._test_get(model.id) + + def test_post(self): + """Tests /client/ POST.""" + data = self._test_post({ + 'sales_force_id': 'test', + 'name': 'test'}) + self.assertTrue( + db.session.query(Client)\ + .filter(and_( + Client.id == data['id'], + Client.sales_force_id == data['sales_force_id'], + Client.name == data['name']))\ + .first()) + + def test_post_missing_name(self): + """Tests /client/ POST with a missing name. It should 400.""" + response = self.post(self.url, + data={'sales_force_id': 'test'}) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /client/ PUT.""" + model = self.env.client + 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(Client)\ + .filter(and_( + Client.id == model.id, + Client.name == data['name']))\ + .first()) + + def test_put_sales_force_id(self): + """Tests /client/ PUT with a sales force id. It should 400.""" + model = self.env.client + 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_delete(self): + """Tests /client/ DELETE. It should 405.""" + model = self.env.client + response = self.delete(self.url + str(model.id)) + self.assertEqual(response.status_code, 405) diff --git a/app/tests/test_contact.py b/app/tests/test_contact.py new file mode 100644 index 0000000000000000000000000000000000000000..2be613b6badee9ce0afbba9fafd448fb9ab32de9 --- /dev/null +++ b/app/tests/test_contact.py @@ -0,0 +1,329 @@ +"""Unit tests for working with contacts.""" +from sqlalchemy import and_ +from app.lib.database import db +from app.tests.base import RestTestCase +from app.models.contact import Contact, ContactMethod, ProjectContact + + +class TestContact(RestTestCase): + """Tests the /contact/ endpoints.""" + url = '/contact/' + Model = Contact + + def test_index(self): + """Tests /contact/ GET.""" + model = self.env.contact + self._test_index() + + def test_get(self): + """Tests /contact/ GET.""" + model = self.env.contact + self._test_get(model.id) + + def test_post(self): + """Tests /contact/ POST.""" + data = { + 'sales_force_id': 'test', + 'name': 'test' + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(Contact)\ + .filter(and_( + Contact.id == response_data['id'], + Contact.sales_force_id == data['sales_force_id'], + Contact.name == data['name']))\ + .first()) + + def test_post_missing_name(self): + """Tests /contact/ POST with a missing name. It should 400.""" + response = self.post(self.url, data={'sales_force_id': 'test'}) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /contact/ PUT.""" + model = self.env.contact + 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(Contact)\ + .filter(and_( + Contact.id == model.id, + Contact.name == data['name'] + ))\ + .first()) + + def test_put_sales_force_id(self): + """Tests /contact/ PUT with a sales force id. It should 400.""" + model = self.env.contact + 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_delete(self): + """Tests /contact/ DELETE.""" + model = self.env.contact + self._test_delete(model.id) + + +class TestContactMethod(RestTestCase): + """Tests the /contact/method/ endpoints.""" + url = '/contact/method/' + Model = ContactMethod + + def test_index(self): + """Tests /contact/method/ GET.""" + model = self.env.email_contact_method + self._test_index() + + def test_get(self): + """Tests /contact/method/ GET.""" + model = self.env.email_contact_method + self._test_get(model.id) + + def test_post_email(self): + """Tests /contact/method/ POST with an email.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'email', + 'value': 'test@blocpower.org' + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.id == response_data['id'], + ContactMethod.method == data['method'], + ContactMethod.value == data['value']))\ + .first()) + + def test_post_bad_email(self): + """Tests /contact/method/ POST with a bad email.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'email', + 'value': 'foo@bar' + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_phone(self): + """Tests /contact/method/ POST with a phone number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'phone', + 'value': '55555555555' + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.id == response_data['id'], + ContactMethod.method == data['method'], + ContactMethod.value == data['value']))\ + .first()) + + def test_post_bad_phone(self): + """Tests /contact/method/ POST with a bad phone number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'phone', + 'value': '555555555' # One character too short. + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_sms(self): + """Tests /contact/method/ POST with a sms number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'sms', + 'value': '55555555555' + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.id == response_data['id'], + ContactMethod.method == data['method'], + ContactMethod.value == data['value']))\ + .first()) + + def test_post_bad_sms(self): + """Tests /contact/method/ POST with a bad sms number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'sms', + 'value': '555555555' # One character too short. + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_fax(self): + """Tests /contact/method/ POST with a fax number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'fax', + 'value': '55555555555' + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.id == response_data['id'], + ContactMethod.method == data['method'], + ContactMethod.value == data['value']))\ + .first()) + + def test_post_bad_fax(self): + """Tests /contact/method/ POST with a bad fax number.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'fax', + 'value': '555555555' # One character too short. + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_method(self): + """Tests /contact/method/ POST with a bad method.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'mail', + 'value': 'test@blocpower.org' + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_contact_id(self): + """Tests /contact/method/ POST with a bad contact id.""" + data = { + 'contact_id': 1, + 'method': 'email', + 'value': 'test@blocpower.org' + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_no_method(self): + """Tests /contact/method/ POST with no method.""" + data = { + 'contact_id': self.env.contact.id, + 'value': 'test@blocpower.org' + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_no_value(self): + """Tests /contact/method/ POST with no value.""" + data = { + 'contact_id': self.env.contact.id, + 'method': 'email' + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /contact/method/ PUT.""" + model = self.env.email_contact_method + data = model.get_dictionary() + data.update({'value': 'foo@blocpower.org'}) + response_data = self._test_put(model.id, data) + self.assertEqual(response_data['value'], data['value']); + self.assertTrue( + db.session.query(ContactMethod)\ + .filter(and_( + ContactMethod.id == model.id, + ContactMethod.value == data['value']))\ + .first()) + + # Put requests can also fail in the same way as post requests, but, since + # they use the same forms, we only really need to test one. Those + # negative cases are left out for the sake of brevity. + + def test_put_contact_id(self): + """Tests /contact/method/ PUT with a new contact_id. It should 400. + """ + model = self.env.email_contact_method + data = model.get_dictionary() + data.update({'contact_id': self.env.contact.id}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_method(self): + """Tests /contact/method/ PUT with a new method. It should 400.""" + model = self.env.email_contact_method + data = model.get_dictionary() + data.update({'method': 'phone', 'value': '5555555555'}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_delete(self): + """Tests /contact/method/ DELETE.""" + model = self.env.email_contact_method + self._test_delete(model.id) + + +class TestProjectContact(RestTestCase): + """Tests the /contact/project/ endpoints.""" + url = '/contact/project/' + Model = ProjectContact + + def test_index(self): + """Tests /contact/project/ GET.""" + model = self.env.project_contact + self._test_index() + + def test_get(self): + """Tests /contact/project/ GET.""" + model = self.env.project_contact + self._test_get(model.id) + + def test_post(self): + """Tests /contact/project/ POST.""" + data = { + 'project_id': self.env.project.id, + 'contact_id': self.env.contact.id + } + response_data = self._test_post(data) + self.assertTrue( + db.session.query(ProjectContact)\ + .filter(and_( + ProjectContact.id == response_data['id'], + ProjectContact.project_id == data['project_id'], + ProjectContact.contact_id == data['contact_id']))\ + .first()) + + def test_post_bad_contact(self): + """Tests /contact/project/ POST with a bad contact id. It should 400.""" + data = { + 'project_id': self.env.project.id, + 'contact_id': 1 + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_post_bad_project(self): + """Tests /contact/project/ POST with a bad project id. It should 400.""" + data = { + 'project_id': 1, + 'contact_id': self.env.contact.id + } + response = self.post(self.url, data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Tests /contact/project/ PUT. It should 405.""" + model = self.env.project_contact + data = model.get_dictionary() + data.update({'project_id': self.env.project.id}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 405) + + def test_delete(self): + """Tests /contact/project/ DELETE.""" + model = self.env.project_contact + self._test_delete(model.id) diff --git a/app/tests/test_project.py b/app/tests/test_project.py index 4b07d4fa2a41ff84a9367500414f907428fabe3f..fbc4b85f076714b2a0614781fb17914cf14f9d2c 100644 --- a/app/tests/test_project.py +++ b/app/tests/test_project.py @@ -27,54 +27,63 @@ class TestProject(RestTestCase): In addition to posting the model normally, it should create a state change model. """ - data = self._test_post( - { - 'sales_force_id': 'test', - 'name': 'test', - 'state': 'pending' - }, { - 'form': 'create' - }) + data = { + 'sales_force_id': 'test', + 'client_id': self.env.client.id, + 'name': 'test', + 'state': 'pending'} + response_data = self._test_post(data) + self.assertTrue( + db.session.query(Project)\ + .filter(and_( + Project.id == response_data['id'], + Project.sales_force_id == data['sales_force_id'], + Project.name == data['name'], + Project.state == data['state']))\ + .first()) self.assertTrue( db.session.query(ProjectStateChange)\ .filter(and_( - ProjectStateChange.project_id == data['id'], + ProjectStateChange.project_id == response_data['id'], ProjectStateChange.state == data['state']))\ .first()) - def test_post_bad_form(self): - """Tests /project/ POST with a bad form.""" - response = self.post(self.url, - data={'sales_force_id': 'test', 'name': 'test', 'state': 'pending'}) - self.assertEqual(response.status_code, 400) - def test_post_missing_name(self): """Tests /project/ POST with a missing name.""" response = self.post(self.url, - data={'sales_force_id': 'test', 'state': 'pending'}, - query_string={'form': 'create'}) + data={ + 'sales_force_id': 'test', + 'client_id': self.env.client.id, + 'state': 'pending'}) self.assertEqual(response.status_code, 400) def test_post_missing_state(self): """Tests /project/ POST with a missing state.""" response = self.post(self.url, - data={'sales_force_id': 'test', 'name': 'test'}, - query_string={'form': 'create'}) + data={ + 'sales_force_id': 'test', + 'client_id': self.env.client.id, + 'name': 'test'}) self.assertEqual(response.status_code, 400) - def test_post_missing_state(self): + def test_post_bad_state(self): """Tests /project/ POST with a bad state.""" response = self.post(self.url, - data={'sales_force_id': 'test', 'name': 'test', 'state': 'foo'}, - query_string={'form': 'create'}) + data={ + 'sales_force_id': 'test', + 'client_id': self.env.client.id, + 'name': 'test', + 'state': 'foo'}) self.assertEqual(response.status_code, 400) - def test_put_bad_form(self): - """Tests /project/ PUT with a bad form.""" - model = self.env.project - data = model.get_dictionary() - data.update({'name': 'foo'}) - response = self.put(self.url + str(model.id), data=data) + def test_post_bad_client_id(self): + """Tests /project/ POST with a bad client id.""" + response = self.post(self.url, + data={ + 'sales_force_id': 'test', + 'client_id': 1, + 'name': 'test', + 'state': 'pending'}) self.assertEqual(response.status_code, 400) def test_put_name(self): @@ -82,31 +91,29 @@ class TestProject(RestTestCase): model = self.env.project data = model.get_dictionary() data.update({'name': 'foo'}) - response_data = self._test_put( - model.id, data, filter_data={'form': 'update'}) - self.assertEqual(response_data['name'], 'foo') + response_data = self._test_put(model.id, data) + self.assertEqual(response_data['name'], data['name']) self.assertEqual( db.session.query(Project)\ .filter(Project.id == model.id)\ .first().name, - 'foo') + data['name']) def test_put_state(self): """Tests /project/ PUT with a new state. - + This should create a project state change model. """ model = self.env.project data = model.get_dictionary() data.update({'state': 'accepted'}) - response_data = self._test_put( - model.id, data, filter_data={'form': 'update'}) - self.assertEqual(response_data['state'], 'accepted') + response_data = self._test_put(model.id, data) + self.assertEqual(response_data['state'], data['state']) self.assertEqual( db.session.query(Project)\ .filter(Project.id == model.id)\ .first().state, - 'accepted') + data['state']) self.assertTrue( db.session.query(ProjectStateChange)\ .filter(and_( @@ -119,8 +126,23 @@ class TestProject(RestTestCase): model = self.env.project data = model.get_dictionary() data.update({'state': 'foo'}) - response = self.put(self.url + str(model.id), - data=data, query_string={'form': 'update'}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 400) + + def test_put_sales_force_id(self): + """Tests /project/ 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_client_id(self): + """Tests /project/ PUT with a client id. It should 400.""" + model = self.env.project + data = model.get_dictionary() + data.update({'client_id': self.env.client.id}) + response = self.put(self.url + str(model.id), data=data) self.assertEqual(response.status_code, 400) def test_delete(self): diff --git a/app/views/__init__.py b/app/views/__init__.py index ee90b57a3d70063dd1c890d148339d38cbb6914c..3582222a712c1db097513390de95ea77b39d82a1 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 +from app.views import application, auth, project, client, contact def register(app): @@ -10,6 +10,12 @@ def register(app): """ application.AppAuthView.register(app) + auth.GoogleAuthView.register(app) + project.ProjectView.register(app) - auth.GoogleAuthView.register(app) + client.ClientView.register(app) + + contact.ContactView.register(app) + contact.ContactMethodView.register(app) + contact.ProjectContactView.register(app) diff --git a/app/views/client.py b/app/views/client.py new file mode 100644 index 0000000000000000000000000000000000000000..5536dc8fc1af49703ecd8d6d81a6c0374455197e --- /dev/null +++ b/app/views/client.py @@ -0,0 +1,17 @@ +"""Views for managing clients.""" +from werkzeug.exceptions import MethodNotAllowed +from flask import request +from app.views.base import RestView +from app.controllers.client import ClientController + + +class ClientView(RestView): + """The client view.""" + def get_controller(self): + """Return an instance of the client controller.""" + return ClientController() + + def delete(self, id_): + """Not implemented.""" + raise MethodNotAllowed( + 'Clients cannot be deleted. Cancel the project instead.') diff --git a/app/views/contact.py b/app/views/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..88be9abcd5982aa93883425b0640ad9b28b828f7 --- /dev/null +++ b/app/views/contact.py @@ -0,0 +1,38 @@ +"""Views for managing contacts.""" +from werkzeug.exceptions import MethodNotAllowed +from flask import request +from app.views.base import RestView +from app.controllers.contact import ( + ContactController, ContactMethodController, ProjectContactController) +from app.permissions.base import FormNeed + + +class ContactView(RestView): + """The contact view.""" + def get_controller(self): + """Return an instance of the contact controller.""" + return ContactController() + + +class ContactMethodView(RestView): + """The contact method view.""" + route_base = '/contact/method/' + + def get_controller(self): + """Return an instance of the contact method controller.""" + return ContactMethodController() + + +class ProjectContactView(RestView): + """A view for the project-contact m2m relationship.""" + route_base = '/contact/project/' + + def get_controller(self): + """Return an instance of the project contact controller.""" + return ProjectContactController() + + def put(self, id_): + """Not implemented.""" + raise MethodNotAllowed( + 'Do not modify existing m2m relationships. Delete this and ' + + 'create a new one instead.') diff --git a/app/views/project.py b/app/views/project.py index 20e99395c4d9e6d59b219ef04660a41cee83ae15..976426651d8c8ea9724b3b4c4f241c5049d7f037 100644 --- a/app/views/project.py +++ b/app/views/project.py @@ -4,7 +4,6 @@ from flask import request from app.views.base import RestView from app.controllers.project import ProjectController -from app.permissions.base import FormNeed class ProjectView(RestView): @@ -13,16 +12,6 @@ class ProjectView(RestView): """Return an instance of the project controller.""" return ProjectController() - def post(self): - form = request.args.get('form') - with FormNeed("create", form): - return super(ProjectView, self).post() - - def put(self, id_): - form = request.args.get('form') - with FormNeed("update", form): - return super(ProjectView, self).put(id_) - def delete(self, id_): """Not implemented""" raise MethodNotAllowed(