Put, post operations implemented for environments

Implementation of environment operations moved to library package.
Code for searching objects by ids and names extracted to the library
function.
Exceptions propagation added to example config. It is useful for
testing and troubleshooting to have error message not only in logs
but in the API response too.
Tuningbox errors hierarchy added to the project.

Change-Id: Ic2fd3c3c17409723bfa3cfff1c0bb18f3a65f0d7
This commit is contained in:
Alexander Kislitsky 2016-08-11 22:15:55 +03:00
parent e6d6e58bcf
commit 8f5bf1883d
10 changed files with 484 additions and 175 deletions

View File

@ -11,6 +11,7 @@
# under the License.
LOG_LEVEL = 'DEBUG'
PROPAGATE_EXCEPTIONS = True
SQLALCHEMY_DATABASE_URI = \
'postgresql://tuningbox:tuningbox@localhost/tuningbox'

View File

@ -15,24 +15,33 @@ import itertools
import flask
import flask_restful
from flask_restful import fields
from sqlalchemy import exc as sa_exc
from werkzeug import exceptions
from tuning_box import converters
from tuning_box import db
from tuning_box import errors
from tuning_box.library import components
from tuning_box.library import environments
from tuning_box import logger
from tuning_box.middleware import keystone
# These handlers work if PROPAGATE_EXCEPTIONS is off (non-Nailgun case)
api_errors = {
'IntegrityError': {'status': 409}, # sqlalchemy IntegrityError
'TuningboxIntegrityError': {'status': 409},
'TuningboxNotFound': {'status': 404}
}
api = flask_restful.Api(errors=api_errors)
api.add_resource(components.ComponentsCollection, '/components')
api.add_resource(components.Component, '/components/<int:component_id>')
api.add_resource(environments.EnvironmentsCollection, '/environments')
api.add_resource(
environments.Environment,
'/environments/<int:environment_id>', # Backward compatibility support
'/environment/<int:environment_id>'
)
def with_transaction(f):
@ -44,55 +53,6 @@ def with_transaction(f):
return inner
environment_fields = {
'id': fields.Integer,
'components': fields.List(fields.Integer(attribute='id')),
'hierarchy_levels': fields.List(fields.String(attribute='name')),
}
@api.resource('/environments')
class EnvironmentsCollection(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(environment_fields)]
def get(self):
return db.Environment.query.all()
@with_transaction
def post(self):
component_ids = flask.request.json['components']
# TODO(yorik-sar): verify that resource names do not clash
components = [db.Component.query.get_by_id_or_name(i)
for i in component_ids]
hierarchy_levels = []
level = None
for name in flask.request.json['hierarchy_levels']:
level = db.EnvironmentHierarchyLevel(name=name, parent=level)
hierarchy_levels.append(level)
environment = db.Environment(components=components,
hierarchy_levels=hierarchy_levels)
if 'id' in flask.request.json:
environment.id = flask.request.json['id']
db.db.session.add(environment)
return environment, 201
@api.resource('/environments/<int:environment_id>')
class Environment(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(environment_fields)]
def get(self, environment_id):
return db.Environment.query.get_or_404(environment_id)
@with_transaction
def delete(self, environment_id):
environment = db.Environment.query.get_or_404(environment_id)
db.db.session.delete(environment)
return None, 204
def iter_environment_level_values(environment, levels):
env_levels = db.EnvironmentHierarchyLevel.get_for_environment(environment)
level_pairs = zip(env_levels, levels)
@ -250,6 +210,12 @@ def handle_integrity_error(exc):
return response
def handle_object_not_found(exc):
response = flask.jsonify(msg=exc.args[0])
response.status_code = 404
return response
def build_app(configure_logging=True, with_keystone=True):
app = flask.Flask(__name__)
app.url_map.converters.update(converters.ALL)
@ -259,6 +225,10 @@ def build_app(configure_logging=True, with_keystone=True):
app.config.from_envvar('TUNINGBOX_SETTINGS', silent=True)
# These handlers work if PROPAGATE_EXCEPTIONS is on (Nailgun case)
app.register_error_handler(sa_exc.IntegrityError, handle_integrity_error)
app.register_error_handler(errors.TuningboxIntegrityError,
handle_integrity_error)
app.register_error_handler(errors.TuningboxNotFound,
handle_object_not_found)
db.db.init_app(app)
if configure_logging:
log_level = app.config.get('LOG_LEVEL', 'INFO')

View File

@ -47,12 +47,12 @@ def fk(cls, **kwargs):
class BaseQuery(flask_sqlalchemy.BaseQuery):
def get_by_id_or_name(self, id_or_name):
def get_by_id_or_name(self, id_or_name, fail_on_none=True):
if isinstance(id_or_name, int):
result = self.get(id_or_name)
else:
result = self.filter_by(name=id_or_name).one_or_none()
if result is None:
if fail_on_none and result is None:
flask.abort(404)
return result

23
tuning_box/errors.py Normal file
View File

@ -0,0 +1,23 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
class BaseTuningboxError(Exception):
pass
class TuningboxIntegrityError(BaseTuningboxError):
pass
class TuningboxNotFound(BaseTuningboxError):
pass

View File

@ -0,0 +1,44 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from tuning_box import errors
def load_objects(model, ids):
if ids is None:
return None
result = []
for obj_id in ids:
obj = model.query.filter_by(id=obj_id).one_or_none()
if obj is None:
raise errors.TuningboxNotFound(
"{0} not found by identifier: "
"{1}".format(model.__tablename__, obj_id)
)
result.append(obj)
return result
def load_objects_by_id_or_name(model, identifiers):
if identifiers is None:
return None
result = []
for identifier in identifiers:
obj = model.query.get_by_id_or_name(
identifier, fail_on_none=False)
if obj is None:
raise errors.TuningboxNotFound(
"{0} not found by identifier: "
"{1}".format(model.__tablename__, identifier)
)
result.append(obj)
return result

View File

@ -16,7 +16,7 @@ import flask_restful
from flask_restful import fields
from tuning_box import db
from tuning_box import library
resource_definition_fields = {
'id': fields.Integer,
@ -64,14 +64,8 @@ class Component(flask_restful.Resource):
component.name = update_by.get('name', component.name)
resource_definitions = update_by.get('resource_definitions')
if resource_definitions is not None:
resources = []
for resource_data in resource_definitions:
resource = db.ResourceDefinition.query.filter_by(
id=resource_data.get('id')
).one()
resource.component_id = component.id
db.db.session.add(resource)
resources.append(resource)
ids = [data['id'] for data in resource_definitions]
resources = library.load_objects(db.ResourceDefinition, ids)
component.resource_definitions = resources
def put(self, component_id):

View File

@ -0,0 +1,115 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import flask
import flask_restful
from flask_restful import fields
from tuning_box import db
from tuning_box import errors
from tuning_box import library
environment_fields = {
'id': fields.Integer,
'components': fields.List(fields.Integer(attribute='id')),
'hierarchy_levels': fields.List(fields.String(attribute='name')),
}
class EnvironmentsCollection(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(environment_fields)]
def get(self):
return db.Environment.query.all()
def _check_components(self, components):
identities = set()
duplicates = set()
id_names = ('id', 'name')
for component in components:
for id_name in id_names:
value = getattr(component, id_name)
if value not in identities:
identities.add(value)
else:
duplicates.add(value)
if duplicates:
raise errors.TuningboxIntegrityError(
"Components duplicates: {0}".format(duplicates))
@db.with_transaction
def post(self):
component_ids = flask.request.json['components']
components = [db.Component.query.get_by_id_or_name(i)
for i in component_ids]
self._check_components(components)
hierarchy_levels = []
level = None
for name in flask.request.json['hierarchy_levels']:
level = db.EnvironmentHierarchyLevel(name=name, parent=level)
hierarchy_levels.append(level)
environment = db.Environment(components=components,
hierarchy_levels=hierarchy_levels)
if 'id' in flask.request.json:
environment.id = flask.request.json['id']
db.db.session.add(environment)
return environment, 201
class Environment(flask_restful.Resource):
method_decorators = [flask_restful.marshal_with(environment_fields)]
def get(self, environment_id):
return db.Environment.query.get_or_404(environment_id)
def _update_components(self, environment, components):
if components is not None:
new_components = library.load_objects_by_id_or_name(
db.Component, components)
environment.components = new_components
def _update_hierarchy_levels(self, environment, hierarchy_levels):
if hierarchy_levels is not None:
new_hierarchy_levels = library.load_objects_by_id_or_name(
db.EnvironmentHierarchyLevel, hierarchy_levels)
parent = None
for level in new_hierarchy_levels:
level.parent = parent
parent = level
environment.hierarchy_levels = new_hierarchy_levels
@db.with_transaction
def _perform_update(self, environment_id):
environment = db.Environment.query.get_or_404(environment_id)
update_by = flask.request.json
components = update_by.get('components')
self._update_components(environment, components)
hierarchy_levels = update_by.get('hierarchy_levels')
self._update_hierarchy_levels(environment, hierarchy_levels)
def put(self, environment_id):
return self.patch(environment_id)
def patch(self, env_id):
self._perform_update(env_id)
return None, 204
@db.with_transaction
def delete(self, environment_id):
environment = db.Environment.query.get_or_404(environment_id)
db.db.session.delete(environment)
return None, 204

View File

@ -10,6 +10,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
from tuning_box import db
from tuning_box.library import components
@ -156,6 +157,21 @@ class TestComponents(BaseTest):
self.assertItemsEqual(initial_data['resource_definitions'],
actual_component['resource_definitions'])
def test_put_component_resource_not_found(self):
self._fixture()
component_url = '/components/7'
initial_data = self._component_json
resource_definition = copy.deepcopy(
initial_data['resource_definitions'][0])
resource_definition['id'] = None
res = self.client.put(
component_url,
data={'resource_definitions': [resource_definition]}
)
self.assertEqual(404, res.status_code)
def test_put_component_ignore_changing_id(self):
self._fixture()
component_url = '/components/7'

View File

@ -0,0 +1,260 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from tuning_box import db
from tuning_box import library
from tuning_box.library import environments
from tuning_box.tests.test_app import BaseTest
class TestEnvironments(BaseTest):
def test_get_environments_empty(self):
res = self.client.get('/environments')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, [])
def test_get_environments(self):
self._fixture()
res = self.client.get('/environments')
self.assertEqual(200, res.status_code)
self.assertEqual(1, len(res.json))
self.assertItemsEqual(
{'id': 9, 'components': [7], 'hierarchy_levels': ['lvl1', 'lvl2']},
res.json[0]
)
def test_get_one_environment(self):
self._fixture()
res = self.client.get('/environments/9')
self.assertEqual(200, res.status_code)
self.assertItemsEqual(
{'id': 9, 'components': [7], 'hierarchy_levels': ['lvl1', 'lvl2']},
res.json
)
def test_get_one_environment_404(self):
res = self.client.get('/environments/9')
self.assertEqual(res.status_code, 404)
def test_post_environment(self):
self._fixture()
json = {'components': [7], 'hierarchy_levels': ['lvla', 'lvlb']}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 201)
json['id'] = res.json['id']
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, res.json['id'],
environments.environment_fields, json)
def test_post_environment_preserve_id(self):
self._fixture()
json = {
'id': 42,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(201, res.status_code)
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, 42, environments.environment_fields, json)
def test_post_environment_preserve_id_conflict(self):
self._fixture()
json = {
'id': 9,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 409)
def test_post_environment_preserve_id_conflict_propagate_exc(self):
self.app.config["PROPAGATE_EXCEPTIONS"] = True
self._fixture()
json = {
'id': 9,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 409)
def test_post_environment_by_component_name(self):
self._fixture()
json = {
'components': ['component1'],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 201)
json['id'] = res.json['id']
json['components'] = [7]
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, res.json['id'],
environments.environment_fields, json)
def test_post_components_duplication(self):
self._fixture()
json = {
'components': ['component1', 7],
'hierarchy_levels': ['lvl'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(409, res.status_code)
def test_post_components_no_duplication(self):
self._fixture()
components_url = '/components'
res = self.client.get(components_url)
self.assertEqual(200, res.status_code)
component = res.json[0]
# Creating component with name equal to id of existed component
res = self.client.post(
components_url,
data={
'name': component['id'],
'resource_definitions': []
}
)
self.assertEqual(201, res.status_code)
new_component = res.json
# Checking no components duplication detected
json = {
'components': [component['id'], new_component['name']],
'hierarchy_levels': ['lvl'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(201, res.status_code)
def test_post_environment_404(self):
self._fixture()
json = {'components': [8], 'hierarchy_levels': ['lvla', 'lvlb']}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 404)
self._assert_not_in_db(db.Environment, 10)
def test_post_environment_by_component_name_404(self):
self._fixture()
json = {
'components': ['component2'],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 404)
self._assert_not_in_db(db.Environment, 10)
def test_delete_environment(self):
self._fixture()
res = self.client.delete('/environments/9')
self.assertEqual(res.status_code, 204)
self.assertEqual(res.data, b'')
self._assert_not_in_db(db.Environment, 9)
def test_delete_environment_404(self):
res = self.client.delete('/environments/9')
self.assertEqual(res.status_code, 404)
def test_put_environment_404(self):
res = self.client.put('/environments/7')
self.assertEqual(res.status_code, 404)
def test_put_environment_components(self):
self._fixture()
environment_url = '/environment/9'
initial = self.client.get(environment_url).json
# Updating components
res = self.client.put(environment_url,
data={'components': []})
self.assertEqual(204, res.status_code)
actual = self.client.get(environment_url).json
self.assertEqual([], actual['components'])
# Restoring components
res = self.client.put(
environment_url,
data={'components': initial['components']}
)
self.assertEqual(204, res.status_code)
actual = self.client.get(environment_url).json
self.assertItemsEqual(initial, actual)
def test_put_environment_component_not_found(self):
self._fixture()
environment_url = '/environment/9'
res = self.client.put(
environment_url,
data={'components': [None]}
)
self.assertEqual(404, res.status_code)
def check_hierarchy_levels(self, hierarchy_levels_names):
with self.app.app_context():
hierarchy_levels = library.load_objects_by_id_or_name(
db.EnvironmentHierarchyLevel, hierarchy_levels_names)
parent_id = None
for level in hierarchy_levels:
self.assertEqual(parent_id, level.parent_id)
parent_id = level.id
def test_put_environment_hierarchy_levels(self):
self._fixture()
environment_url = '/environment/9'
initial = self.client.get(environment_url).json
# Updating hierarchy levels
res = self.client.put(environment_url,
data={'hierarchy_levels': []})
self.assertEqual(204, res.status_code)
actual = self.client.get(environment_url).json
self.assertEqual([], actual['hierarchy_levels'])
# Restoring levels
res = self.client.put(
environment_url,
data={'hierarchy_levels': initial['hierarchy_levels']}
)
self.assertEqual(204, res.status_code)
actual = self.client.get(environment_url).json
self.assertItemsEqual(initial, actual)
self.check_hierarchy_levels(actual['hierarchy_levels'])
def test_put_environment_hierarchy_levels_remove_level(self):
self._fixture()
environment_url = '/environment/9'
initial = self.client.get(environment_url).json
expected_levels = initial['hierarchy_levels'][1:]
# Updating hierarchy levels
res = self.client.put(
environment_url,
data={'hierarchy_levels': expected_levels}
)
self.assertEqual(204, res.status_code)
actual = self.client.get(environment_url).json
self.assertEqual(expected_levels, actual['hierarchy_levels'])
self.check_hierarchy_levels(actual['hierarchy_levels'])
def test_put_environment_level_not_found(self):
self._fixture()
environment_url = '/environment/9'
res = self.client.put(
environment_url,
data={'hierarchy_levels': [None]}
)
self.assertEqual(404, res.status_code)

View File

@ -88,120 +88,6 @@ class BaseTest(base.TestCase):
class TestApp(BaseTest):
def test_get_environments_empty(self):
res = self.client.get('/environments')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, [])
def test_get_environments(self):
self._fixture()
res = self.client.get('/environments')
self.assertEqual(200, res.status_code)
self.assertEqual(1, len(res.json))
self.assertItemsEqual(
{'id': 9, 'components': [7], 'hierarchy_levels': ['lvl1', 'lvl2']},
res.json[0]
)
def test_get_one_environment(self):
self._fixture()
res = self.client.get('/environments/9')
self.assertEqual(200, res.status_code)
self.assertItemsEqual(
{'id': 9, 'components': [7], 'hierarchy_levels': ['lvl1', 'lvl2']},
res.json
)
def test_get_one_environment_404(self):
res = self.client.get('/environments/9')
self.assertEqual(res.status_code, 404)
def test_post_environment(self):
self._fixture()
json = {'components': [7], 'hierarchy_levels': ['lvla', 'lvlb']}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 201)
json['id'] = res.json['id']
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, res.json['id'], app.environment_fields, json)
def test_post_environment_preserve_id(self):
self._fixture()
json = {
'id': 42,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(201, res.status_code)
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, 42, app.environment_fields, json)
def test_post_environment_preserve_id_conflict(self):
self._fixture()
json = {
'id': 9,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 409)
def test_post_environment_preserve_id_conflict_propagate_exc(self):
self.app.config["PROPAGATE_EXCEPTIONS"] = True
self._fixture()
json = {
'id': 9,
'components': [7],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 409)
def test_post_environment_by_component_name(self):
self._fixture()
json = {
'components': ['component1'],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 201)
json['id'] = res.json['id']
json['components'] = [7]
self.assertItemsEqual(json, res.json)
self._assert_db_effect(
db.Environment, res.json['id'], app.environment_fields, json)
def test_post_environment_404(self):
self._fixture()
json = {'components': [8], 'hierarchy_levels': ['lvla', 'lvlb']}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 404)
self._assert_not_in_db(db.Environment, 10)
def test_post_environment_by_component_name_404(self):
self._fixture()
json = {
'components': ['component2'],
'hierarchy_levels': ['lvla', 'lvlb'],
}
res = self.client.post('/environments', data=json)
self.assertEqual(res.status_code, 404)
self._assert_not_in_db(db.Environment, 10)
def test_delete_environment(self):
self._fixture()
res = self.client.delete('/environments/9')
self.assertEqual(res.status_code, 204)
self.assertEqual(res.data, b'')
self._assert_not_in_db(db.Environment, 9)
def test_delete_environment_404(self):
res = self.client.delete('/environments/9')
self.assertEqual(res.status_code, 404)
def test_get_environment_level_value_root(self):
self._fixture()
with self.app.app_context(), db.db.session.begin():