From 76867c6aa4e4b3729ecb9ba817d8cafeeb2c6a49 Mon Sep 17 00:00:00 2001 From: Alexander Kislitsky Date: Tue, 23 Aug 2016 19:08:39 +0300 Subject: [PATCH] Components operations handled in the fuel2 Cascade deletion of resource definitons on the component deletion fixed. Cascade only on the DB level is not enougth for deletion referenced objects. Resources definitions creation is not supported yet in the component creation call. Resource definition would be able to add to the component in the create resource definition call. Update of resource definitions list is supported in the component creation call. Standard cliff formatters are used for fuel2 components operations. Python-fuelclient added to test requirements. Change-Id: If572f7437f48bdde65de114f6f70af3c071c1d0e --- setup.cfg | 10 ++ test-requirements.txt | 1 + tuning_box/cli/base.py | 39 ++++++ tuning_box/cli/components.py | 103 ++++++++++++++ tuning_box/db.py | 9 +- tuning_box/fuelclient.py | 21 +++ tuning_box/library/components.py | 10 +- tuning_box/tests/cli/__init__.py | 5 +- tuning_box/tests/cli/test_components.py | 144 ++++++++++++++++++++ tuning_box/tests/library/test_components.py | 24 +++- 10 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 tuning_box/cli/components.py create mode 100644 tuning_box/tests/cli/test_components.py diff --git a/setup.cfg b/setup.cfg index 71e6f8a..01f680d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,11 @@ tuning_box.cli = env_show = tuning_box.cli.environments:ShowEnvironment env_delete = tuning_box.cli.environments:DeleteEnvironment env_update = tuning_box.cli.environments:UpdateEnvironment + comp_create = tuning_box.cli.components:CreateComponent + comp_list = tuning_box.cli.components:ListComponents + comp_show = tuning_box.cli.components:ShowComponent + comp_delete = tuning_box.cli.components:DeleteComponent + comp_update = tuning_box.cli.components:UpdateComponent fuelclient = config_get = tuning_box.fuelclient:Get config_set = tuning_box.fuelclient:Set @@ -66,6 +71,11 @@ fuelclient = config_env_show = tuning_box.fuelclient:ShowEnvironment config_env_delete = tuning_box.fuelclient:DeleteEnvironment config_env_update = tuning_box.fuelclient:UpdateEnvironment + config_comp_create = tuning_box.fuelclient:CreateComponent + config_comp_list = tuning_box.fuelclient:ListComponents + config_comp_show = tuning_box.fuelclient:ShowComponent + config_comp_delete = tuning_box.fuelclient:DeleteComponent + config_comp_update = tuning_box.fuelclient:UpdateComponent console_scripts = tuningbox_db_upgrade = tuning_box.migration:upgrade tuningbox_db_downgrade = tuning_box.migration:downgrade diff --git a/test-requirements.txt b/test-requirements.txt index f19c234..6ac4598 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -15,3 +15,4 @@ testrepository>=0.0.18 testscenarios>=0.4 testtools>=1.4.0 requests-mock +python-fuelclient \ No newline at end of file diff --git a/tuning_box/cli/base.py b/tuning_box/cli/base.py index 932689c..39144bf 100644 --- a/tuning_box/cli/base.py +++ b/tuning_box/cli/base.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +import abc import json import yaml @@ -67,6 +68,44 @@ class BaseCommand(command.Command): return list(six.moves.map(cast_to, result)) +class BaseOneCommand(BaseCommand): + + @abc.abstractproperty + def base_url(self): + """Base url for request operations""" + + @abc.abstractproperty + def entity_name(self): + """Name of the TuningBox entity""" + + def get_parser(self, *args, **kwargs): + parser = super(BaseOneCommand, self).get_parser(*args, **kwargs) + parser.add_argument( + 'id', + type=int, + help='Id of the {0} to delete.'.format(self.entity_name)) + return parser + + def get_url(self, parsed_args): + return '{0}/{1}'.format(self.base_url, parsed_args.id) + + def get_deletion_message(self, parsed_args): + return '{0} with id {1} was deleted'.format( + self.entity_name.capitalize(), parsed_args.id) + + def get_update_message(self, parsed_args): + return '{0} with id {1} was updated'.format( + self.entity_name.capitalize(), parsed_args.id) + + +class BaseDeleteCommand(BaseOneCommand): + """Deletes entity with the specified id.""" + + def take_action(self, parsed_args): + self.get_client().delete(self.get_url(parsed_args)) + return self.get_deletion_message(parsed_args) + + class FormattedCommand(BaseCommand): format_choices = ('json', 'yaml', 'plain') diff --git a/tuning_box/cli/components.py b/tuning_box/cli/components.py new file mode 100644 index 0000000..68c65af --- /dev/null +++ b/tuning_box/cli/components.py @@ -0,0 +1,103 @@ +# 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 cliff import lister +from cliff import show +from fuelclient.cli import error as fc_error +from fuelclient.common import data_utils + +from tuning_box.cli import base + + +class ComponentsCommand(base.BaseCommand): + entity_name = 'component' + base_url = '/components' + columns = ('id', 'name', 'resource_definitions') + + +class ListComponents(ComponentsCommand, lister.Lister): + + def take_action(self, parsed_args): + result = self.get_client().get(self.base_url) + try: + data = data_utils.get_display_data_multi(self.columns, result) + return self.columns, data + except fc_error.BadDataException: + return zip(*result.items()) + + +class CreateComponent(ComponentsCommand, show.ShowOne): + + def get_parser(self, *args, **kwargs): + parser = super(CreateComponent, self).get_parser( + *args, **kwargs) + parser.add_argument( + '-n', '--name', + type=str, + help="Component name" + ) + return parser + + def take_action(self, parsed_args): + result = self.get_client().post( + '/components', {'name': parsed_args.name, + 'resource_definitions': []}) + return zip(*result.items()) + + +class ShowComponent(ComponentsCommand, base.BaseOneCommand, show.ShowOne): + + def take_action(self, parsed_args): + result = self.get_client().get(self.get_url(parsed_args)) + try: + data = data_utils.get_display_data_single(self.columns, result) + return self.columns, data + except fc_error.BadDataException: + return zip(*result.items()) + + +class DeleteComponent(ComponentsCommand, base.BaseDeleteCommand): + pass + + +class UpdateComponent(ComponentsCommand, base.BaseOneCommand): + + def get_parser(self, *args, **kwargs): + parser = super(UpdateComponent, self).get_parser( + *args, **kwargs) + parser.add_argument( + '-n', '--name', + type=str, + help="Component name" + ) + parser.add_argument( + '-r', '--resource-definitions', + dest='resources', + type=str, + help="Comma separated resource definitions IDs. " + "Set parameter to [] if you want to pass empty list", + ) + return parser + + def take_action(self, parsed_args): + data = {} + if parsed_args.name is not None: + data['name'] = parsed_args.name + if parsed_args.resources is not None: + data['resource_definitions'] = [] + res_def_ids = self._parse_comma_separated( + parsed_args, 'resources', int) + for res_def_id in res_def_ids: + data['resource_definitions'].append({'id': res_def_id}) + + self.get_client().patch(self.get_url(parsed_args), data) + return self.get_update_message(parsed_args) diff --git a/tuning_box/db.py b/tuning_box/db.py index 3a1ad10..000728e 100644 --- a/tuning_box/db.py +++ b/tuning_box/db.py @@ -128,7 +128,12 @@ class Component(ModelMixin, db.Model): class ResourceDefinition(ModelMixin, db.Model): name = db.Column(db.String(128)) component_id = fk(Component, ondelete='CASCADE') - component = db.relationship(Component, backref='resource_definitions') + component = db.relationship( + Component, + backref=sqlalchemy.orm.backref('resource_definitions', + cascade='all, delete-orphan') + ) + content = db.Column(Json) __repr_attrs__ = ('id', 'name', 'component', 'content') @@ -259,7 +264,7 @@ def get_or_404(cls, ident): result = cls.query.get(ident) if result is None: raise errors.TuningboxNotFound( - "{0} not found by {1}".format(cls.__name__, ident) + "{0} not found by id {1}".format(cls.__name__, ident) ) return result diff --git a/tuning_box/fuelclient.py b/tuning_box/fuelclient.py index 0dccc52..d770619 100644 --- a/tuning_box/fuelclient.py +++ b/tuning_box/fuelclient.py @@ -17,6 +17,7 @@ from fuelclient import client as fc_client from tuning_box import cli from tuning_box.cli import base as cli_base +from tuning_box.cli import components from tuning_box.cli import environments from tuning_box.cli import resources from tuning_box import client as tb_client @@ -75,6 +76,26 @@ class UpdateEnvironment(FuelBaseCommand, environments.UpdateEnvironment): pass +class CreateComponent(FuelBaseCommand, components.CreateComponent): + pass + + +class ListComponents(FuelBaseCommand, components.ListComponents): + pass + + +class ShowComponent(FuelBaseCommand, components.ShowComponent): + pass + + +class DeleteComponent(FuelBaseCommand, components.DeleteComponent): + pass + + +class UpdateComponent(FuelBaseCommand, components.UpdateComponent): + pass + + class Config(command.Command): def get_parser(self, *args, **kwargs): parser = super(Config, self).get_parser(*args, **kwargs) diff --git a/tuning_box/library/components.py b/tuning_box/library/components.py index a68a875..0f43204 100644 --- a/tuning_box/library/components.py +++ b/tuning_box/library/components.py @@ -32,13 +32,13 @@ class ComponentsCollection(flask_restful.Resource): method_decorators = [flask_restful.marshal_with(component_fields)] def get(self): - return db.Component.query.all() + return db.Component.query.order_by(db.Component.id).all() @db.with_transaction def post(self): component = db.Component(name=flask.request.json['name']) component.resource_definitions = [] - for res_def_data in flask.request.json.get('resource_definitions'): + for res_def_data in flask.request.json.get('resource_definitions', []): res_def = db.ResourceDefinition( name=res_def_data['name'], content=res_def_data.get('content')) component.resource_definitions.append(res_def) @@ -50,11 +50,11 @@ class Component(flask_restful.Resource): method_decorators = [flask_restful.marshal_with(component_fields)] def get(self, component_id): - return db.Component.query.get_or_404(component_id) + return db.get_or_404(db.Component, component_id) @db.with_transaction def _perform_update(self, component_id): - component = db.Component.query.get_or_404(component_id) + component = db.get_or_404(db.Component, component_id) update_by = flask.request.json component.name = update_by.get('name', component.name) res_definitions = update_by.get('resource_definitions') @@ -72,6 +72,6 @@ class Component(flask_restful.Resource): @db.with_transaction def delete(self, component_id): - component = db.Component.query.get_or_404(component_id) + component = db.get_or_404(db.Component, component_id) db.db.session.delete(component) return None, 204 diff --git a/tuning_box/tests/cli/__init__.py b/tuning_box/tests/cli/__init__.py index bea19c0..b7d42d9 100644 --- a/tuning_box/tests/cli/__init__.py +++ b/tuning_box/tests/cli/__init__.py @@ -83,10 +83,11 @@ class SafeTuningBoxApp(cli.TuningBoxApp): def run(self, argv): try: - exit_code = super(SafeTuningBoxApp, self).run(argv) + super(SafeTuningBoxApp, self).run(argv) except SystemExit as e: + # We should check exit code only if system exit was called. exit_code = e.code - assert exit_code == 0 + assert exit_code == 0 class _BaseCLITest(base.TestCase): diff --git a/tuning_box/tests/cli/test_components.py b/tuning_box/tests/cli/test_components.py new file mode 100644 index 0000000..b81625f --- /dev/null +++ b/tuning_box/tests/cli/test_components.py @@ -0,0 +1,144 @@ +# 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 testscenarios + +from tuning_box.tests.cli import _BaseCLITest + + +class TestCreateComponent(testscenarios.WithScenarios, _BaseCLITest): + scenarios = [ + (s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1]))) + for s in [ + ('json', ('/components', + 'comp create --name comp_name --format json', + '{\n "a": "b"\n}')), + ('yaml', ('/components', + 'comp create -n comp_name -f yaml', + 'a: b\n')), + ] + ] + + mock_url = None + args = None + expected_result = None + + def test_post(self): + self.req_mock.post( + self.BASE_URL + self.mock_url, + headers={'Content-Type': 'application/json'}, + json={'a': 'b'}, + ) + self.cli.run(self.args.split()) + self.assertEqual(self.expected_result, self.cli.stdout.getvalue()) + + +class TestListComponents(testscenarios.WithScenarios, _BaseCLITest): + + scenarios = [ + (s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1]))) + for s in [ + ('json', ('/components', 'comp list -f json', '[]')), + ('yaml', ('/components', 'comp list --format yaml', '[]\n')), + ] + ] + mock_url = None + args = None + expected_result = None + + def test_get(self): + self.req_mock.get( + self.BASE_URL + self.mock_url, + headers={'Content-Type': 'application/json'}, + json=[], + ) + self.cli.run(self.args.split()) + self.assertEqual(self.expected_result, self.cli.stdout.getvalue()) + + +class TestShowComponent(testscenarios.WithScenarios, _BaseCLITest): + + scenarios = [ + (s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1]))) + for s in [ + ('yaml', ('/components/9', 'comp show 9 -f yaml', + 'id: 1\nname: n\nresource_definitions: []\n')), + ] + ] + mock_url = None + args = None + expected_result = None + + def test_get(self): + self.req_mock.get( + self.BASE_URL + self.mock_url, + headers={'Content-Type': 'application/json'}, + json={'id': 1, 'name': 'n', 'resource_definitions': []}, + ) + self.cli.run(self.args.split()) + self.assertEqual(self.expected_result, self.cli.stdout.getvalue()) + + +class TestDeleteComponent(testscenarios.WithScenarios, _BaseCLITest): + + scenarios = [ + (s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1]))) + for s in [ + ('', ('/components/9', 'comp delete 9', '')), + ] + ] + mock_url = None + args = None + expected_result = None + + def test_delete(self): + self.req_mock.delete( + self.BASE_URL + self.mock_url, + headers={'Content-Type': 'application/json'} + ) + self.cli.run(self.args.split()) + self.assertEqual(self.expected_result, self.cli.stdout.getvalue()) + + +class TestUpdateComponent(testscenarios.WithScenarios, _BaseCLITest): + + scenarios = [ + (s[0], dict(zip(('mock_url', 'args', 'expected_result'), s[1]))) + for s in [ + ('no_data', ('/components/9', 'comp update 9', '')), + ('s_name', ('/components/9', + 'comp update 9 -n comp_name', '')), + ('l_name', ('/components/9', + 'comp update 9 --name comp_name', '')), + ('s_r_defs', ('/components/9', + 'comp update 9 -r 1,2 ', '')), + ('l_r_ders', ('/components/9', + 'comp update 9 --resource-definitions 1,2', '')), + ('empty_s_r_defs', ('/components/9', + 'comp update 9 -r [] -n comp_name', '')), + ('empty_l_r_defs', ('/components/9', + 'comp update 9 --resource-definitions []', + '')) + ] + ] + mock_url = None + args = None + expected_result = None + + def test_update(self): + self.req_mock.patch( + self.BASE_URL + self.mock_url, + headers={'Content-Type': 'application/json'}, + json={} + ) + self.cli.run(self.args.split()) + self.assertEqual(self.expected_result, self.cli.stdout.getvalue()) diff --git a/tuning_box/tests/library/test_components.py b/tuning_box/tests/library/test_components.py index 540c939..9a8871e 100644 --- a/tuning_box/tests/library/test_components.py +++ b/tuning_box/tests/library/test_components.py @@ -115,6 +115,10 @@ class TestComponents(BaseTest): self.assertEqual(res.data, b'') self._assert_not_in_db(db.Component, 7) + with self.app.app_context(): + actual_res_defs = db.ResourceDefinition.query.all() + self.assertEqual([], actual_res_defs) + def test_delete_component_404(self): res = self.client.delete('/components/7') self.assertEqual(res.status_code, 404) @@ -145,17 +149,29 @@ class TestComponents(BaseTest): self.assertEqual([], actual_component['resource_definitions']) # Restoring resource_definitions and name + res_def = { + 'name': 'resdef1', + 'component_id': 7, + 'content': {'key': 'nsname.key'} + } + res = self.client.post( + '/resource_definitions', + data=res_def + ) + self.assertEqual(201, res.status_code) + res = self.client.put( component_url, - data={'name': initial_data['name'], - 'resource_definitions': initial_data['resource_definitions']} + data={'name': initial_data['name']} ) self.assertEqual(204, res.status_code) actual_component = self.client.get(component_url).json self.assertEqual(initial_data['name'], actual_component['name']) - self.assertItemsEqual(initial_data['resource_definitions'], - actual_component['resource_definitions']) + self.assertItemsEqual( + (d['name'] for d in initial_data['resource_definitions']), + (d['name'] for d in actual_component['resource_definitions']) + ) def test_put_component_resource_not_found(self): self._fixture()