From d32664150fbc00340f3ff4304c13abf9a191299a Mon Sep 17 00:00:00 2001 From: Gage Hugo Date: Thu, 6 Jul 2017 16:21:03 -0500 Subject: [PATCH] Add project tags functionality This change adds tags functionality for projects in keystone. A user can add a single tag with "--tag", chain "--tag" to add multiple tags, or clear tags with "--no-tag". Change-Id: I31cfef3e76dcefe299dacb00c11bb1a10a252628 Partially-Implements: bp project-tags --- doc/source/cli/command-objects/project.rst | 35 ++++++ openstackclient/identity/v3/project.py | 9 +- openstackclient/identity/v3/tag.py | 116 ++++++++++++++++++ .../tests/unit/identity/v3/fakes.py | 7 ++ .../tests/unit/identity/v3/test_domain.py | 5 +- .../tests/unit/identity/v3/test_project.py | 103 +++++++++++++++- .../bp-project-tags-b544aef9672d415b.yaml | 8 ++ 7 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 openstackclient/identity/v3/tag.py create mode 100644 releasenotes/notes/bp-project-tags-b544aef9672d415b.yaml diff --git a/doc/source/cli/command-objects/project.rst b/doc/source/cli/command-objects/project.rst index cb0941ca1..6891a79a4 100644 --- a/doc/source/cli/command-objects/project.rst +++ b/doc/source/cli/command-objects/project.rst @@ -19,6 +19,7 @@ Create new project [--enable | --disable] [--property ] [--or-show] + [--tag ] .. option:: --domain @@ -56,6 +57,13 @@ Create new project If the project already exists return the existing project data and do not fail. +.. option:: --tag + + Add a tag to the project + (repeat option to set multiple tags) + + .. versionadded:: 3 + .. _project_create-name: .. describe:: @@ -98,6 +106,8 @@ List projects [--my-projects] [--long] [--sort [:,:,..]] + [--tags [,,...]] [--tags-any [,,...]] + [--not-tags [,,...]] [--not-tags-any [,,...]] .. option:: --domain @@ -127,6 +137,30 @@ List projects multiple keys and directions can be specified --sort [:,:,..] +.. option:: --tags [,,...] + + List projects which have all given tag(s) + + .. versionadded:: 3 + +.. option:: --tags-any [,,...] + + List projects which have any given tag(s) + + .. versionadded:: 3 + +.. option:: --not-tags [,,...] + + Exclude projects which have all given tag(s) + + .. versionadded:: 3 + +.. option:: --not-tags-any [,,...] + + Exclude projects which have any given tag(s) + + .. versionadded:: 3 + project set ----------- @@ -141,6 +175,7 @@ Set project properties [--description ] [--enable | --disable] [--property ] + [--tag | --clear-tags | --remove-tags ] .. option:: --name diff --git a/openstackclient/identity/v3/project.py b/openstackclient/identity/v3/project.py index 60efbac4a..e819a0a89 100644 --- a/openstackclient/identity/v3/project.py +++ b/openstackclient/identity/v3/project.py @@ -26,7 +26,7 @@ import six from openstackclient.i18n import _ from openstackclient.identity import common - +from openstackclient.identity.v3 import tag LOG = logging.getLogger(__name__) @@ -79,6 +79,7 @@ class CreateProject(command.ShowOne): action='store_true', help=_('Return existing project'), ) + tag.add_tag_option_to_parser_for_create(parser, _('project')) return parser def take_action(self, parsed_args): @@ -102,6 +103,7 @@ class CreateProject(command.ShowOne): kwargs = {} if parsed_args.property: kwargs = parsed_args.property.copy() + kwargs['tags'] = list(set(parsed_args.tags)) try: project = identity_client.projects.create( @@ -207,6 +209,7 @@ class ListProject(command.Lister): '(default: asc), repeat this option to specify multiple ' 'keys and directions.'), ) + tag.add_tag_filtering_option_to_parser(parser, _('projects')) return parser def take_action(self, parsed_args): @@ -234,6 +237,8 @@ class ListProject(command.Lister): kwargs['user'] = user_id + tag.get_tag_filtering_args(parsed_args, kwargs) + if parsed_args.my_projects: # NOTE(adriant): my-projects supersedes all the other filters. kwargs = {'user': self.app.client_manager.auth_ref.user_id} @@ -303,6 +308,7 @@ class SetProject(command.Command): help=_('Set a property on ' '(repeat option to set multiple properties)'), ) + tag.add_tag_option_to_parser_for_set(parser, _('project')) return parser def take_action(self, parsed_args): @@ -323,6 +329,7 @@ class SetProject(command.Command): kwargs['enabled'] = False if parsed_args.property: kwargs.update(parsed_args.property) + tag.update_tags_in_args(parsed_args, project, kwargs) identity_client.projects.update(project.id, **kwargs) diff --git a/openstackclient/identity/v3/tag.py b/openstackclient/identity/v3/tag.py new file mode 100644 index 000000000..abf022d48 --- /dev/null +++ b/openstackclient/identity/v3/tag.py @@ -0,0 +1,116 @@ +# 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 argparse + +from openstackclient.i18n import _ + + +class _CommaListAction(argparse.Action): + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, values.split(',')) + + +def add_tag_filtering_option_to_parser(parser, collection_name): + parser.add_argument( + '--tags', + metavar='[,,...]', + action=_CommaListAction, + help=_('List %s which have all given tag(s) ' + '(Comma-separated list of tags)') % collection_name + ) + parser.add_argument( + '--tags-any', + metavar='[,,...]', + action=_CommaListAction, + help=_('List %s which have any given tag(s) ' + '(Comma-separated list of tags)') % collection_name + ) + parser.add_argument( + '--not-tags', + metavar='[,,...]', + action=_CommaListAction, + help=_('Exclude %s which have all given tag(s) ' + '(Comma-separated list of tags)') % collection_name + ) + parser.add_argument( + '--not-tags-any', + metavar='[,,...]', + action=_CommaListAction, + help=_('Exclude %s which have any given tag(s) ' + '(Comma-separated list of tags)') % collection_name + ) + + +def get_tag_filtering_args(parsed_args, args): + if parsed_args.tags: + args['tags'] = ','.join(parsed_args.tags) + if parsed_args.tags_any: + args['tags-any'] = ','.join(parsed_args.tags_any) + if parsed_args.not_tags: + args['not-tags'] = ','.join(parsed_args.not_tags) + if parsed_args.not_tags_any: + args['not-tags-any'] = ','.join(parsed_args.not_tags_any) + + +def add_tag_option_to_parser_for_create(parser, resource_name): + tag_group = parser.add_mutually_exclusive_group() + tag_group.add_argument( + '--tag', + action='append', + dest='tags', + metavar='', + default=[], + help=_('Tag to be added to the %s ' + '(repeat option to set multiple tags)') % resource_name + ) + + +def add_tag_option_to_parser_for_set(parser, resource_name): + parser.add_argument( + '--tag', + action='append', + dest='tags', + metavar='', + default=[], + help=_('Tag to be added to the %s ' + '(repeat option to set multiple tags)') % resource_name + ) + parser.add_argument( + '--clear-tags', + action='store_true', + help=_('Clear tags associated with the %s. Specify ' + 'both --tag and --clear-tags to overwrite ' + 'current tags') % resource_name + ) + parser.add_argument( + '--remove-tag', + metavar='', + default=[], + help=_('Tag to be deleted from the %s ' + '(repeat option to delete multiple tags)') % resource_name + ) + + +def update_tags_in_args(parsed_args, obj, args): + if parsed_args.clear_tags: + args['tags'] = [] + obj.tags = [] + if parsed_args.remove_tag: + if parsed_args.remove_tag in obj.tags: + obj.tags.remove(parsed_args.remove_tag) + args['tags'] = list(set(obj.tags)) + return + if parsed_args.tags: + args['tags'] = list(set(obj.tags).union( + set(parsed_args.tags))) diff --git a/openstackclient/tests/unit/identity/v3/fakes.py b/openstackclient/tests/unit/identity/v3/fakes.py index 3e2caf01d..3770e29fe 100644 --- a/openstackclient/tests/unit/identity/v3/fakes.py +++ b/openstackclient/tests/unit/identity/v3/fakes.py @@ -34,6 +34,7 @@ DOMAIN = { 'name': domain_name, 'description': domain_description, 'enabled': True, + 'tags': [], 'links': base_url + 'domains/' + domain_id, } @@ -115,6 +116,7 @@ PROJECT = { 'description': project_description, 'enabled': True, 'domain_id': domain_id, + 'tags': [], 'links': base_url + 'projects/' + project_id, } @@ -124,6 +126,7 @@ PROJECT_2 = { 'description': project_description + 'plus four more', 'enabled': True, 'domain_id': domain_id, + 'tags': [], 'links': base_url + 'projects/' + project_id, } @@ -145,6 +148,7 @@ PROJECT_WITH_PARENT = { 'enabled': True, 'domain_id': domain_id, 'parent_id': project_id, + 'tags': [], 'links': base_url + 'projects/' + (project_id + '-with-parent'), } @@ -155,6 +159,7 @@ PROJECT_WITH_GRANDPARENT = { 'enabled': True, 'domain_id': domain_id, 'parent_id': PROJECT_WITH_PARENT['id'], + 'tags': [], 'links': base_url + 'projects/' + (project_id + '-with-grandparent'), } @@ -619,6 +624,7 @@ class FakeProject(object): 'is_domain': False, 'domain_id': 'domain-id-' + uuid.uuid4().hex, 'parent_id': 'parent-id-' + uuid.uuid4().hex, + 'tags': [], 'links': 'links-' + uuid.uuid4().hex, } project_info.update(attrs) @@ -666,6 +672,7 @@ class FakeDomain(object): 'name': 'domain-name-' + uuid.uuid4().hex, 'description': 'domain-description-' + uuid.uuid4().hex, 'enabled': True, + 'tags': [], 'links': 'links-' + uuid.uuid4().hex, } domain_info.update(attrs) diff --git a/openstackclient/tests/unit/identity/v3/test_domain.py b/openstackclient/tests/unit/identity/v3/test_domain.py index 36f13d332..014986e57 100644 --- a/openstackclient/tests/unit/identity/v3/test_domain.py +++ b/openstackclient/tests/unit/identity/v3/test_domain.py @@ -31,6 +31,7 @@ class TestDomainCreate(TestDomain): 'enabled', 'id', 'name', + 'tags' ) def setUp(self): @@ -43,6 +44,7 @@ class TestDomainCreate(TestDomain): True, self.domain.id, self.domain.name, + self.domain.tags ) # Get the command object to test @@ -390,12 +392,13 @@ class TestDomainShow(TestDomain): self.domain.id, ) - collist = ('description', 'enabled', 'id', 'name') + collist = ('description', 'enabled', 'id', 'name', 'tags') self.assertEqual(collist, columns) datalist = ( self.domain.description, True, self.domain.id, self.domain.name, + self.domain.tags ) self.assertEqual(datalist, data) diff --git a/openstackclient/tests/unit/identity/v3/test_project.py b/openstackclient/tests/unit/identity/v3/test_project.py index 16ac3116f..266da2277 100644 --- a/openstackclient/tests/unit/identity/v3/test_project.py +++ b/openstackclient/tests/unit/identity/v3/test_project.py @@ -50,6 +50,7 @@ class TestProjectCreate(TestProject): 'is_domain', 'name', 'parent_id', + 'tags' ) def setUp(self): @@ -67,6 +68,7 @@ class TestProjectCreate(TestProject): False, self.project.name, self.project.parent_id, + self.project.tags ) # Get the command object to test self.cmd = project.CreateProject(self.app, None) @@ -80,6 +82,7 @@ class TestProjectCreate(TestProject): ('enable', False), ('disable', False), ('name', self.project.name), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -95,6 +98,7 @@ class TestProjectCreate(TestProject): 'description': None, 'enabled': True, 'parent': None, + 'tags': [] } # ProjectManager.create(name=, domain=, description=, # enabled=, **kwargs) @@ -110,6 +114,7 @@ class TestProjectCreate(TestProject): 'is_domain', 'name', 'parent_id', + 'tags' ) self.assertEqual(collist, columns) datalist = ( @@ -120,6 +125,7 @@ class TestProjectCreate(TestProject): False, self.project.name, self.project.parent_id, + self.project.tags ) self.assertEqual(datalist, data) @@ -134,6 +140,7 @@ class TestProjectCreate(TestProject): ('disable', False), ('name', self.project.name), ('parent', None), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -149,6 +156,7 @@ class TestProjectCreate(TestProject): 'description': 'new desc', 'enabled': True, 'parent': None, + 'tags': [] } # ProjectManager.create(name=, domain=, description=, # enabled=, **kwargs) @@ -170,6 +178,7 @@ class TestProjectCreate(TestProject): ('disable', False), ('name', self.project.name), ('parent', None), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -185,6 +194,7 @@ class TestProjectCreate(TestProject): 'description': None, 'enabled': True, 'parent': None, + 'tags': [] } # ProjectManager.create(name=, domain=, description=, # enabled=, **kwargs) @@ -206,6 +216,7 @@ class TestProjectCreate(TestProject): ('disable', False), ('name', self.project.name), ('parent', None), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) mocker = mock.Mock() @@ -221,6 +232,7 @@ class TestProjectCreate(TestProject): 'description': None, 'enabled': True, 'parent': None, + 'tags': [] } self.projects_mock.create.assert_called_with( **kwargs @@ -238,6 +250,7 @@ class TestProjectCreate(TestProject): ('disable', False), ('name', self.project.name), ('parent', None), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -253,6 +266,7 @@ class TestProjectCreate(TestProject): 'description': None, 'enabled': True, 'parent': None, + 'tags': [] } # ProjectManager.create(name=, domain=, description=, # enabled=, **kwargs) @@ -288,6 +302,7 @@ class TestProjectCreate(TestProject): 'description': None, 'enabled': False, 'parent': None, + 'tags': [] } # ProjectManager.create(name=, domain=, # description=, enabled=, **kwargs) @@ -324,6 +339,7 @@ class TestProjectCreate(TestProject): 'parent': None, 'fee': 'fi', 'fo': 'fum', + 'tags': [] } # ProjectManager.create(name=, domain=, description=, # enabled=, **kwargs) @@ -352,6 +368,7 @@ class TestProjectCreate(TestProject): ('enable', False), ('disable', False), ('name', self.project.name), + ('tags', []) ] parsed_args = self.check_parser(self.cmd, arglist, verifylist) @@ -363,6 +380,7 @@ class TestProjectCreate(TestProject): 'parent': self.parent.id, 'description': None, 'enabled': True, + 'tags': [] } self.projects_mock.create.assert_called_with( @@ -377,6 +395,7 @@ class TestProjectCreate(TestProject): 'is_domain', 'name', 'parent_id', + 'tags' ) self.assertEqual(columns, collist) datalist = ( @@ -387,6 +406,7 @@ class TestProjectCreate(TestProject): self.project.is_domain, self.project.name, self.parent.id, + self.project.tags ) self.assertEqual(data, datalist) @@ -417,6 +437,43 @@ class TestProjectCreate(TestProject): parsed_args, ) + def test_project_create_with_tags(self): + arglist = [ + '--domain', self.project.domain_id, + '--tag', 'foo', + self.project.name, + ] + verifylist = [ + ('domain', self.project.domain_id), + ('enable', False), + ('disable', False), + ('name', self.project.name), + ('parent', None), + ('tags', ['foo']) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'name': self.project.name, + 'domain': self.project.domain_id, + 'description': None, + 'enabled': True, + 'parent': None, + 'tags': ['foo'] + } + self.projects_mock.create.assert_called_with( + **kwargs + ) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist, data) + class TestProjectDelete(TestProject): @@ -816,6 +873,38 @@ class TestProjectSet(TestProject): ) self.assertIsNone(result) + def test_project_set_tags(self): + arglist = [ + '--name', 'qwerty', + '--domain', self.project.domain_id, + '--tag', 'foo', + self.project.name, + ] + verifylist = [ + ('name', 'qwerty'), + ('domain', self.project.domain_id), + ('enable', False), + ('disable', False), + ('project', self.project.name), + ('tags', ['foo']) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'name': 'qwerty', + 'tags': ['foo'] + } + # ProjectManager.update(project, name=, domain=, description=, + # enabled=, **kwargs) + self.projects_mock.update.assert_called_with( + self.project.id, + **kwargs + ) + self.assertIsNone(result) + class TestProjectShow(TestProject): @@ -867,6 +956,7 @@ class TestProjectShow(TestProject): 'is_domain', 'name', 'parent_id', + 'tags' ) self.assertEqual(collist, columns) datalist = ( @@ -877,6 +967,7 @@ class TestProjectShow(TestProject): False, self.project.name, self.project.parent_id, + self.project.tags ) self.assertEqual(datalist, data) @@ -926,6 +1017,7 @@ class TestProjectShow(TestProject): 'name', 'parent_id', 'parents', + 'tags' ) self.assertEqual(columns, collist) datalist = ( @@ -936,7 +1028,8 @@ class TestProjectShow(TestProject): self.project.is_domain, self.project.name, self.project.parent_id, - [{'project': {'id': self.project.parent_id}}] + [{'project': {'id': self.project.parent_id}}], + self.project.tags ) self.assertEqual(data, datalist) @@ -985,6 +1078,7 @@ class TestProjectShow(TestProject): 'name', 'parent_id', 'subtree', + 'tags' ) self.assertEqual(columns, collist) datalist = ( @@ -995,7 +1089,8 @@ class TestProjectShow(TestProject): self.project.is_domain, self.project.name, self.project.parent_id, - [{'project': {'id': 'children-id'}}] + [{'project': {'id': 'children-id'}}], + self.project.tags ) self.assertEqual(data, datalist) @@ -1047,6 +1142,7 @@ class TestProjectShow(TestProject): 'parent_id', 'parents', 'subtree', + 'tags' ) self.assertEqual(columns, collist) datalist = ( @@ -1058,7 +1154,8 @@ class TestProjectShow(TestProject): self.project.name, self.project.parent_id, [{'project': {'id': self.project.parent_id}}], - [{'project': {'id': 'children-id'}}] + [{'project': {'id': 'children-id'}}], + self.project.tags ) self.assertEqual(data, datalist) diff --git a/releasenotes/notes/bp-project-tags-b544aef9672d415b.yaml b/releasenotes/notes/bp-project-tags-b544aef9672d415b.yaml new file mode 100644 index 000000000..0da35ac37 --- /dev/null +++ b/releasenotes/notes/bp-project-tags-b544aef9672d415b.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add ``--tag`` option to ``project create`` command, ``--tag``, ``--clear-tags``, and + ``--remove-tag`` options to ``project set`` command. Add ``--tags``, ``--tags-any``, + ``--not-tags``, and ``--not-tags-any`` options to ``project list`` command to filter + list results by different projects based on their tags. + [`blueprint project-tags `_]