diff --git a/app/config/development.default.py b/app/config/development.default.py index 24620cbc983d889905931c7bb68b431c48e7aede..2b8d67d8013b1789e709483fd4197bde2b6271db 100644 --- a/app/config/development.default.py +++ b/app/config/development.default.py @@ -1,5 +1,5 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False -SQLALCHEMY_DATABASE_URI = 'sqlite://' +SQLALCHEMY_DATABASE_URI = 'postgresql:///buildingservice' REDIS_URI = 'redis://127.0.0.1:6379/' diff --git a/app/config/test.default.py b/app/config/test.default.py index 6231e62d0a48031ad1a12a06d3fb7d35f618b2a6..8916da377099bfc23c5527b4058a4de73d9901f9 100644 --- a/app/config/test.default.py +++ b/app/config/test.default.py @@ -1,5 +1,5 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False -SQLALCHEMY_DATABASE_URI = 'sqlite://' +SQLALCHEMY_DATABASE_URI = 'postgresql:///buildingservice-testing' REDIS_URI = 'redis://127.0.0.1:6379/' @@ -21,35 +21,6 @@ SERVICE_CONFIG = { # App APP_CACHE_EXPIRY = 60 * 60 # One hour. -# Test applications -TEST_CLIENTS = { - # This application should use a secret to authenticate and should have an - # authentication with the boilerplate service. - 'secret-authenticated': { - 'key': '', - 'secret': '' - }, - # This application should use a referrer to authenticate and should have an - # authentication with the boilerplate service. - 'referrer-authenticated': { - 'key': '', - 'referrer': '' - }, - # This application should use a referrer to authenticate, but should have no - # authentication with the boilerplate service. - 'referrer-unauthenticated': { - 'key': '', - 'referrer': '' - }, - # This application should use a referrer to authenticate, should have an - # authentication with the boilerplate service, and should have a role - # named 'test'. - 'referrer-authenticated-role': { - 'key': '', - 'referrer': '' - } -} - # Auth GOOGLE_AUTH_HEADER = 'x-blocpower-google-token' USER_CACHE_EXPIRY = 60 * 60 # One Hour. @@ -58,12 +29,6 @@ USER_CACHE_EXPIRY = 60 * 60 # One Hour. HEADER_AUTH_KEY = 'x-blocpower-auth-key' HEADER_AUTH_TOKEN = 'x-blocpower-auth-token' -# Google auth information. -GOOGLE_CLIENT_ID = '$GOOGLE_CLIENT_ID' -GOOGLE_CLIENT_SECRET = '$GOOGLE_CLIENT_SECRET' -GOOGLE_REFRESH_TOKEN = '$GOOGLE_REFRESH_TOKEN' -GOOGLE_AUTH_URL = 'https://www.googleapis.com/oauth2/v4/token' - # HACK Fix issue where raising an exception in a test context creates an extra # request. PRESERVE_CONTEXT_ON_EXCEPTION = False diff --git a/app/controllers/building.py b/app/controllers/building.py new file mode 100644 index 0000000000000000000000000000000000000000..48af604cdfd90c878c39a459f8ef2bf831104786 --- /dev/null +++ b/app/controllers/building.py @@ -0,0 +1,13 @@ +"""Controllers for posting or manipulating a building.""" +from ..models.building import Building +from ..forms.building import BuildingForm +from .base import RestController + + +class BuildingController(RestController): + """A building controller.""" + Model = Building + + def get_form(self, filter_data): + """Return the building form.""" + return BuildingForm diff --git a/app/forms/base.py b/app/forms/base.py index 1be257d42013f3b00c6908c9ec112cfc7b701adc..0ef9f496e458521180b7e8cc18afb3e29b8b2253 100644 --- a/app/forms/base.py +++ b/app/forms/base.py @@ -22,3 +22,14 @@ class AddressForm(object): country = wtf.StringField( validators=[wtf.validators.AnyOf(geography.countries)]) postal_code = wtf.StringField(validators=[validators.zip_]) + + +class LocationForm(object): + """A form for validating locations.""" + point = wtf.StringField(validators=[validators.point]) + + +class SalesForceForm(object): + """A form for validating salesforce models.""" + sales_force_id = wtf.StringField( + validators=[wtf.validators.Length(max=255)]) diff --git a/app/forms/building.py b/app/forms/building.py new file mode 100644 index 0000000000000000000000000000000000000000..1c54944718dbec6cddd86426390b8e5a5308fd7e --- /dev/null +++ b/app/forms/building.py @@ -0,0 +1,8 @@ +"""Forms for validating buildings.""" +import wtforms as wtf +from .base import Form, AddressForm, LocationForm, SalesForceForm + + +class BuildingForm(SalesForceForm, LocationForm, AddressForm, Form): + """A form for validating buildings.""" + name = wtf.StringField(validators=[wtf.validators.Length(max=255)]) diff --git a/app/forms/validators.py b/app/forms/validators.py index 9a1889d07cb8542003343be52f1a1a4e1bd34877..005539cab02cacd991b2ab775eb449e5c8e5b435 100644 --- a/app/forms/validators.py +++ b/app/forms/validators.py @@ -4,3 +4,7 @@ import wtforms as wtf # A zip code validator. zip_ = wtf.validators.Regexp(r'^\d{5}(-\d{4})?$') + + +# A postGIS point validator. For example, POINT(-70 40). +point = wtf.validators.Regexp(r'POINT\(\-?\d{1,3}(\.\d+)? \-?\d{1,3}(\.\d+)?\)') diff --git a/app/models/base.py b/app/models/base.py index fd3b0a1a75b4400610b2b8b52e351a41bd836e28..d4cfdb0c8a9ac2cd1b5973808dd4888dad2908d3 100644 --- a/app/models/base.py +++ b/app/models/base.py @@ -1,9 +1,12 @@ import arrow import decimal import uuid + from sqlalchemy.sql import func from sqlalchemy.ext.declarative import declared_attr + from geoalchemy2 import Geometry +from geoalchemy2.elements import WKBElement, WKTElement from ..lib.database import db from ..lib import geography @@ -44,6 +47,8 @@ class Model(object): # If we cast decimals to floats, we end up with rounding # errors. Better to render them out to strings. value = str(value) + elif isinstance(value, (WKBElement, WKTElement)): + value = db.session.scalar(value.ST_AsText()) d[key] = value return d @@ -54,6 +59,7 @@ class Tracked(object): created = db.Column(columns.Arrow, default=func.now()) updated = db.Column(columns.Arrow, onupdate=func.now()) + class Address(object): """An address (with street number, city, state, zip, ...). This does not necessarily correlate with a physical location as defined in the @@ -75,6 +81,13 @@ class Location(object): """A physical location (with latitude, longitude, and elevation).""" point = db.Column(Geometry('POINT'), nullable=False) + class SalesForce(object): """A mixin that includes a SalesForce id.""" sales_force_id = db.Column(db.Unicode(255), index=True) + + +class External(object): + """An external-facing model. These have a UUID key.""" + key = db.Column( + columns.GUID(), index=True, nullable=False, default=uuid.uuid4) diff --git a/app/models/building.py b/app/models/building.py new file mode 100644 index 0000000000000000000000000000000000000000..224f23ed960a9aea3eb0aa2a765a4c27c6ced4c5 --- /dev/null +++ b/app/models/building.py @@ -0,0 +1,8 @@ +"""Models for dealing with buildings.""" +from .base import Model, SalesForce, Tracked, Location, Address, External +from ..lib.database import db + +class Building( + Tracked, External, SalesForce, Location, Address, Model, db.Model): + """A building.""" + name = db.Column(db.Unicode(255)) diff --git a/app/tests/environment.py b/app/tests/environment.py index 5ed6760c2430fd8c46a5db9a7450501e1bbef051..48df3cff2d7330f107ab431f5913c922d70a5b00 100644 --- a/app/tests/environment.py +++ b/app/tests/environment.py @@ -3,10 +3,8 @@ This provides several example models which can be used as needed in the test cases. """ -import requests - -from app.lib.red import redis -from app.lib.database import db, commit +from ..lib.database import db, commit +from ..models.building import Building class Environment(object): @@ -21,11 +19,15 @@ class Environment(object): return model @property - def google_token(self): - """Gets a valid google access token.""" - return requests.post(self.app.config['GOOGLE_AUTH_URL'], data={ - 'client_id': self.app.config['GOOGLE_CLIENT_ID'], - 'client_secret': self.app.config['GOOGLE_CLIENT_SECRET'], - 'refresh_token': self.app.config['GOOGLE_REFRESH_TOKEN'], - 'grant_type': 'refresh_token' - }).json()['id_token'] + def building(self): + """A building.""" + return self.add(Building( + name='test', + sales_force_id='test', + street_address='123 Test St', + city='Test', + county='Test', + state='NY', + country='United States of America', + postal_code='11111', + point='POINT(-70 40)')) diff --git a/app/tests/test_building.py b/app/tests/test_building.py new file mode 100644 index 0000000000000000000000000000000000000000..687d0f5d790b69d64f1116b32be41f70400267f5 --- /dev/null +++ b/app/tests/test_building.py @@ -0,0 +1,52 @@ +"""Unit tests for building endpoints.""" +from sqlalchemy import and_ +from ..lib.database import db +from ..models.building import Building +from .base import RestTestCase + + +class BuildingTestCase(RestTestCase): + """Tests the /building/ endpoints.""" + url = '/building/' + + def test_index(self): + """Test /building/ GET.""" + _ = self.env.building + self._test_index() + + def test_get(self): + """Test /building/ GET.""" + model = self.env.building + self._test_get(model.id) + + def test_post(self): + """Test /building/ POST.""" + data = { + 'name': 'test', + 'sales_force_id': 'test', + 'street_address': '123 Test St', + 'city': 'Test', + 'county': 'Test', + 'state': 'NY', + 'country': 'United States of America', + 'postal_code': '11111', + 'point': 'POINT(-70 40)'} + response_data = self._test_post(data) + filters = [Building.id == response_data['id']] + filters += [getattr(Building, k) == v for k, v in data.items()] + self.assertTrue( + db.session.query(Building).filter(and_(*filters)).first()) + + def test_put(self): + """Test /building/ PUT. It should 405.""" + model = self.env.building + data = model.get_dictionary() + data.update({'name': 'foo'}) + response = self.put(self.url + str(model.id), data=data) + self.assertEqual(response.status_code, 405) + + def test_delete(self): + """Test /building/ DELETE. It should 405.""" + model = self.env.building + response = self.delete(self.url + str(model.id)) + self.assertEqual(response.status_code, 405) diff --git a/app/views/__init__.py b/app/views/__init__.py index 1dcf967d1e5ec2c509c5a7e9c550092aa1c73b23..eefe51588f3750c17587296bd04cb76aaa46ca45 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -1,4 +1,5 @@ """Flask-classy views for the flask application.""" +from . import building def register(app): @@ -7,4 +8,4 @@ def register(app): This can be used as a comprehensive list of all views that are present in the application. """ - pass + building.BuildingView.register(app) diff --git a/app/views/building.py b/app/views/building.py new file mode 100644 index 0000000000000000000000000000000000000000..ac867ecfafdde236d4594fc19e69fe82f5b850e9 --- /dev/null +++ b/app/views/building.py @@ -0,0 +1,17 @@ +"""Views for working with buildings.""" +from werkzeug.exceptions import MethodNotAllowed +from ..controllers.building import BuildingController +from .base import RestView + + +class BuildingView(RestView): + """The building view.""" + def get_controller(self): + """Return an instance of the building controller.""" + return BuildingController() + + def put(self, id_): + raise MethodNotAllowed() + + def delete(self, id_): + raise MethodNotAllowed()