Merge "Add project tags functionality"

This commit is contained in:
Zuul 2018-02-25 01:05:16 +00:00 committed by Gerrit Code Review
commit d6761f0936
7 changed files with 278 additions and 5 deletions
doc/source/cli/command-objects
openstackclient
releasenotes/notes

@ -19,6 +19,7 @@ Create new project
[--enable | --disable] [--enable | --disable]
[--property <key=value>] [--property <key=value>]
[--or-show] [--or-show]
[--tag <tag>]
<name> <name>
.. option:: --domain <domain> .. option:: --domain <domain>
@ -56,6 +57,13 @@ Create new project
If the project already exists return the existing project data and do not fail. 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: .. _project_create-name:
.. describe:: <name> .. describe:: <name>
@ -98,6 +106,8 @@ List projects
[--my-projects] [--my-projects]
[--long] [--long]
[--sort <key>[:<direction>,<key>:<direction>,..]] [--sort <key>[:<direction>,<key>:<direction>,..]]
[--tags <tag>[,<tag>,...]] [--tags-any <tag>[,<tag>,...]]
[--not-tags <tag>[,<tag>,...]] [--not-tags-any <tag>[,<tag>,...]]
.. option:: --domain <domain> .. option:: --domain <domain>
@ -127,6 +137,30 @@ List projects
multiple keys and directions can be specified --sort multiple keys and directions can be specified --sort
<key>[:<direction>,<key>:<direction>,..] <key>[:<direction>,<key>:<direction>,..]
.. option:: --tags <tag>[,<tag>,...]
List projects which have all given tag(s)
.. versionadded:: 3
.. option:: --tags-any <tag>[,<tag>,...]
List projects which have any given tag(s)
.. versionadded:: 3
.. option:: --not-tags <tag>[,<tag>,...]
Exclude projects which have all given tag(s)
.. versionadded:: 3
.. option:: --not-tags-any <tag>[,<tag>,...]
Exclude projects which have any given tag(s)
.. versionadded:: 3
project set project set
----------- -----------
@ -141,6 +175,7 @@ Set project properties
[--description <description>] [--description <description>]
[--enable | --disable] [--enable | --disable]
[--property <key=value>] [--property <key=value>]
[--tag <tag> | --clear-tags | --remove-tags <tag>]
<project> <project>
.. option:: --name <name> .. option:: --name <name>

@ -26,7 +26,7 @@ import six
from openstackclient.i18n import _ from openstackclient.i18n import _
from openstackclient.identity import common from openstackclient.identity import common
from openstackclient.identity.v3 import tag
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -79,6 +79,7 @@ class CreateProject(command.ShowOne):
action='store_true', action='store_true',
help=_('Return existing project'), help=_('Return existing project'),
) )
tag.add_tag_option_to_parser_for_create(parser, _('project'))
return parser return parser
def take_action(self, parsed_args): def take_action(self, parsed_args):
@ -102,6 +103,7 @@ class CreateProject(command.ShowOne):
kwargs = {} kwargs = {}
if parsed_args.property: if parsed_args.property:
kwargs = parsed_args.property.copy() kwargs = parsed_args.property.copy()
kwargs['tags'] = list(set(parsed_args.tags))
try: try:
project = identity_client.projects.create( project = identity_client.projects.create(
@ -207,6 +209,7 @@ class ListProject(command.Lister):
'(default: asc), repeat this option to specify multiple ' '(default: asc), repeat this option to specify multiple '
'keys and directions.'), 'keys and directions.'),
) )
tag.add_tag_filtering_option_to_parser(parser, _('projects'))
return parser return parser
def take_action(self, parsed_args): def take_action(self, parsed_args):
@ -234,6 +237,8 @@ class ListProject(command.Lister):
kwargs['user'] = user_id kwargs['user'] = user_id
tag.get_tag_filtering_args(parsed_args, kwargs)
if parsed_args.my_projects: if parsed_args.my_projects:
# NOTE(adriant): my-projects supersedes all the other filters. # NOTE(adriant): my-projects supersedes all the other filters.
kwargs = {'user': self.app.client_manager.auth_ref.user_id} kwargs = {'user': self.app.client_manager.auth_ref.user_id}
@ -303,6 +308,7 @@ class SetProject(command.Command):
help=_('Set a property on <project> ' help=_('Set a property on <project> '
'(repeat option to set multiple properties)'), '(repeat option to set multiple properties)'),
) )
tag.add_tag_option_to_parser_for_set(parser, _('project'))
return parser return parser
def take_action(self, parsed_args): def take_action(self, parsed_args):
@ -323,6 +329,7 @@ class SetProject(command.Command):
kwargs['enabled'] = False kwargs['enabled'] = False
if parsed_args.property: if parsed_args.property:
kwargs.update(parsed_args.property) kwargs.update(parsed_args.property)
tag.update_tags_in_args(parsed_args, project, kwargs)
identity_client.projects.update(project.id, **kwargs) identity_client.projects.update(project.id, **kwargs)

@ -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='<tag>[,<tag>,...]',
action=_CommaListAction,
help=_('List %s which have all given tag(s) '
'(Comma-separated list of tags)') % collection_name
)
parser.add_argument(
'--tags-any',
metavar='<tag>[,<tag>,...]',
action=_CommaListAction,
help=_('List %s which have any given tag(s) '
'(Comma-separated list of tags)') % collection_name
)
parser.add_argument(
'--not-tags',
metavar='<tag>[,<tag>,...]',
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='<tag>[,<tag>,...]',
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='<tag>',
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='<tag>',
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='<tag>',
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)))

@ -34,6 +34,7 @@ DOMAIN = {
'name': domain_name, 'name': domain_name,
'description': domain_description, 'description': domain_description,
'enabled': True, 'enabled': True,
'tags': [],
'links': base_url + 'domains/' + domain_id, 'links': base_url + 'domains/' + domain_id,
} }
@ -115,6 +116,7 @@ PROJECT = {
'description': project_description, 'description': project_description,
'enabled': True, 'enabled': True,
'domain_id': domain_id, 'domain_id': domain_id,
'tags': [],
'links': base_url + 'projects/' + project_id, 'links': base_url + 'projects/' + project_id,
} }
@ -124,6 +126,7 @@ PROJECT_2 = {
'description': project_description + 'plus four more', 'description': project_description + 'plus four more',
'enabled': True, 'enabled': True,
'domain_id': domain_id, 'domain_id': domain_id,
'tags': [],
'links': base_url + 'projects/' + project_id, 'links': base_url + 'projects/' + project_id,
} }
@ -145,6 +148,7 @@ PROJECT_WITH_PARENT = {
'enabled': True, 'enabled': True,
'domain_id': domain_id, 'domain_id': domain_id,
'parent_id': project_id, 'parent_id': project_id,
'tags': [],
'links': base_url + 'projects/' + (project_id + '-with-parent'), 'links': base_url + 'projects/' + (project_id + '-with-parent'),
} }
@ -155,6 +159,7 @@ PROJECT_WITH_GRANDPARENT = {
'enabled': True, 'enabled': True,
'domain_id': domain_id, 'domain_id': domain_id,
'parent_id': PROJECT_WITH_PARENT['id'], 'parent_id': PROJECT_WITH_PARENT['id'],
'tags': [],
'links': base_url + 'projects/' + (project_id + '-with-grandparent'), 'links': base_url + 'projects/' + (project_id + '-with-grandparent'),
} }
@ -619,6 +624,7 @@ class FakeProject(object):
'is_domain': False, 'is_domain': False,
'domain_id': 'domain-id-' + uuid.uuid4().hex, 'domain_id': 'domain-id-' + uuid.uuid4().hex,
'parent_id': 'parent-id-' + uuid.uuid4().hex, 'parent_id': 'parent-id-' + uuid.uuid4().hex,
'tags': [],
'links': 'links-' + uuid.uuid4().hex, 'links': 'links-' + uuid.uuid4().hex,
} }
project_info.update(attrs) project_info.update(attrs)
@ -666,6 +672,7 @@ class FakeDomain(object):
'name': 'domain-name-' + uuid.uuid4().hex, 'name': 'domain-name-' + uuid.uuid4().hex,
'description': 'domain-description-' + uuid.uuid4().hex, 'description': 'domain-description-' + uuid.uuid4().hex,
'enabled': True, 'enabled': True,
'tags': [],
'links': 'links-' + uuid.uuid4().hex, 'links': 'links-' + uuid.uuid4().hex,
} }
domain_info.update(attrs) domain_info.update(attrs)

@ -31,6 +31,7 @@ class TestDomainCreate(TestDomain):
'enabled', 'enabled',
'id', 'id',
'name', 'name',
'tags'
) )
def setUp(self): def setUp(self):
@ -43,6 +44,7 @@ class TestDomainCreate(TestDomain):
True, True,
self.domain.id, self.domain.id,
self.domain.name, self.domain.name,
self.domain.tags
) )
# Get the command object to test # Get the command object to test
@ -390,12 +392,13 @@ class TestDomainShow(TestDomain):
self.domain.id, self.domain.id,
) )
collist = ('description', 'enabled', 'id', 'name') collist = ('description', 'enabled', 'id', 'name', 'tags')
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
self.domain.description, self.domain.description,
True, True,
self.domain.id, self.domain.id,
self.domain.name, self.domain.name,
self.domain.tags
) )
self.assertEqual(datalist, data) self.assertEqual(datalist, data)

@ -50,6 +50,7 @@ class TestProjectCreate(TestProject):
'is_domain', 'is_domain',
'name', 'name',
'parent_id', 'parent_id',
'tags'
) )
def setUp(self): def setUp(self):
@ -67,6 +68,7 @@ class TestProjectCreate(TestProject):
False, False,
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
self.project.tags
) )
# Get the command object to test # Get the command object to test
self.cmd = project.CreateProject(self.app, None) self.cmd = project.CreateProject(self.app, None)
@ -80,6 +82,7 @@ class TestProjectCreate(TestProject):
('enable', False), ('enable', False),
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@ -95,6 +98,7 @@ class TestProjectCreate(TestProject):
'description': None, 'description': None,
'enabled': True, 'enabled': True,
'parent': None, 'parent': None,
'tags': []
} }
# ProjectManager.create(name=, domain=, description=, # ProjectManager.create(name=, domain=, description=,
# enabled=, **kwargs) # enabled=, **kwargs)
@ -110,6 +114,7 @@ class TestProjectCreate(TestProject):
'is_domain', 'is_domain',
'name', 'name',
'parent_id', 'parent_id',
'tags'
) )
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
@ -120,6 +125,7 @@ class TestProjectCreate(TestProject):
False, False,
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
self.project.tags
) )
self.assertEqual(datalist, data) self.assertEqual(datalist, data)
@ -134,6 +140,7 @@ class TestProjectCreate(TestProject):
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('parent', None), ('parent', None),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@ -149,6 +156,7 @@ class TestProjectCreate(TestProject):
'description': 'new desc', 'description': 'new desc',
'enabled': True, 'enabled': True,
'parent': None, 'parent': None,
'tags': []
} }
# ProjectManager.create(name=, domain=, description=, # ProjectManager.create(name=, domain=, description=,
# enabled=, **kwargs) # enabled=, **kwargs)
@ -170,6 +178,7 @@ class TestProjectCreate(TestProject):
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('parent', None), ('parent', None),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@ -185,6 +194,7 @@ class TestProjectCreate(TestProject):
'description': None, 'description': None,
'enabled': True, 'enabled': True,
'parent': None, 'parent': None,
'tags': []
} }
# ProjectManager.create(name=, domain=, description=, # ProjectManager.create(name=, domain=, description=,
# enabled=, **kwargs) # enabled=, **kwargs)
@ -206,6 +216,7 @@ class TestProjectCreate(TestProject):
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('parent', None), ('parent', None),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
mocker = mock.Mock() mocker = mock.Mock()
@ -221,6 +232,7 @@ class TestProjectCreate(TestProject):
'description': None, 'description': None,
'enabled': True, 'enabled': True,
'parent': None, 'parent': None,
'tags': []
} }
self.projects_mock.create.assert_called_with( self.projects_mock.create.assert_called_with(
**kwargs **kwargs
@ -238,6 +250,7 @@ class TestProjectCreate(TestProject):
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('parent', None), ('parent', None),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@ -253,6 +266,7 @@ class TestProjectCreate(TestProject):
'description': None, 'description': None,
'enabled': True, 'enabled': True,
'parent': None, 'parent': None,
'tags': []
} }
# ProjectManager.create(name=, domain=, description=, # ProjectManager.create(name=, domain=, description=,
# enabled=, **kwargs) # enabled=, **kwargs)
@ -288,6 +302,7 @@ class TestProjectCreate(TestProject):
'description': None, 'description': None,
'enabled': False, 'enabled': False,
'parent': None, 'parent': None,
'tags': []
} }
# ProjectManager.create(name=, domain=, # ProjectManager.create(name=, domain=,
# description=, enabled=, **kwargs) # description=, enabled=, **kwargs)
@ -324,6 +339,7 @@ class TestProjectCreate(TestProject):
'parent': None, 'parent': None,
'fee': 'fi', 'fee': 'fi',
'fo': 'fum', 'fo': 'fum',
'tags': []
} }
# ProjectManager.create(name=, domain=, description=, # ProjectManager.create(name=, domain=, description=,
# enabled=, **kwargs) # enabled=, **kwargs)
@ -352,6 +368,7 @@ class TestProjectCreate(TestProject):
('enable', False), ('enable', False),
('disable', False), ('disable', False),
('name', self.project.name), ('name', self.project.name),
('tags', [])
] ]
parsed_args = self.check_parser(self.cmd, arglist, verifylist) parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@ -363,6 +380,7 @@ class TestProjectCreate(TestProject):
'parent': self.parent.id, 'parent': self.parent.id,
'description': None, 'description': None,
'enabled': True, 'enabled': True,
'tags': []
} }
self.projects_mock.create.assert_called_with( self.projects_mock.create.assert_called_with(
@ -377,6 +395,7 @@ class TestProjectCreate(TestProject):
'is_domain', 'is_domain',
'name', 'name',
'parent_id', 'parent_id',
'tags'
) )
self.assertEqual(columns, collist) self.assertEqual(columns, collist)
datalist = ( datalist = (
@ -387,6 +406,7 @@ class TestProjectCreate(TestProject):
self.project.is_domain, self.project.is_domain,
self.project.name, self.project.name,
self.parent.id, self.parent.id,
self.project.tags
) )
self.assertEqual(data, datalist) self.assertEqual(data, datalist)
@ -417,6 +437,43 @@ class TestProjectCreate(TestProject):
parsed_args, 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): class TestProjectDelete(TestProject):
@ -816,6 +873,38 @@ class TestProjectSet(TestProject):
) )
self.assertIsNone(result) 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): class TestProjectShow(TestProject):
@ -867,6 +956,7 @@ class TestProjectShow(TestProject):
'is_domain', 'is_domain',
'name', 'name',
'parent_id', 'parent_id',
'tags'
) )
self.assertEqual(collist, columns) self.assertEqual(collist, columns)
datalist = ( datalist = (
@ -877,6 +967,7 @@ class TestProjectShow(TestProject):
False, False,
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
self.project.tags
) )
self.assertEqual(datalist, data) self.assertEqual(datalist, data)
@ -926,6 +1017,7 @@ class TestProjectShow(TestProject):
'name', 'name',
'parent_id', 'parent_id',
'parents', 'parents',
'tags'
) )
self.assertEqual(columns, collist) self.assertEqual(columns, collist)
datalist = ( datalist = (
@ -936,7 +1028,8 @@ class TestProjectShow(TestProject):
self.project.is_domain, self.project.is_domain,
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
[{'project': {'id': self.project.parent_id}}] [{'project': {'id': self.project.parent_id}}],
self.project.tags
) )
self.assertEqual(data, datalist) self.assertEqual(data, datalist)
@ -985,6 +1078,7 @@ class TestProjectShow(TestProject):
'name', 'name',
'parent_id', 'parent_id',
'subtree', 'subtree',
'tags'
) )
self.assertEqual(columns, collist) self.assertEqual(columns, collist)
datalist = ( datalist = (
@ -995,7 +1089,8 @@ class TestProjectShow(TestProject):
self.project.is_domain, self.project.is_domain,
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
[{'project': {'id': 'children-id'}}] [{'project': {'id': 'children-id'}}],
self.project.tags
) )
self.assertEqual(data, datalist) self.assertEqual(data, datalist)
@ -1047,6 +1142,7 @@ class TestProjectShow(TestProject):
'parent_id', 'parent_id',
'parents', 'parents',
'subtree', 'subtree',
'tags'
) )
self.assertEqual(columns, collist) self.assertEqual(columns, collist)
datalist = ( datalist = (
@ -1058,7 +1154,8 @@ class TestProjectShow(TestProject):
self.project.name, self.project.name,
self.project.parent_id, self.project.parent_id,
[{'project': {'id': 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) self.assertEqual(data, datalist)

@ -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 <https://blueprints.launchpad.net/keystone/+spec/project-tags>`_]