Add project tags to keystoneclient
Adds the client functionality for the following project tag calls: - Create a project tag on a project - Check if a project tag exists on a project - List project tags on a project - Modify project tags on a project - Delete a specific project tag on a project - Delete all project tags on a project Co-Authored-By: Jess Egler <jess.egler@gmail.com> Co-Authored-By: Rohan Arora <ra271w@att.com> Co-Authored-By: Tin Lam <tin@irrational.io> Partially Implements: bp project-tags Change-Id: I486b2969ae0aa2638842d842fb8b0955cc086d25
This commit is contained in:
		| @@ -356,6 +356,13 @@ class CrudManager(Manager): | ||||
|         if params is None: | ||||
|             return '' | ||||
|         else: | ||||
|             # NOTE(spilla) Since the manager cannot take in a hyphen as a | ||||
|             # key in the kwarg, it is passed in with a _.  This needs to be | ||||
|             # replaced with a proper hyphen for the URL to work properly. | ||||
|             tags_params = ('tags_any', 'not_tags', 'not_tags_any') | ||||
|             for tag_param in tags_params: | ||||
|                 if tag_param in params: | ||||
|                     params[tag_param.replace('_', '-')] = params.pop(tag_param) | ||||
|             return '?%s' % urllib.parse.urlencode(params, doseq=True) | ||||
|  | ||||
|     def build_key_only_query(self, params_list): | ||||
|   | ||||
| @@ -71,9 +71,10 @@ class Domain(Base): | ||||
|  | ||||
| class Project(Base): | ||||
|  | ||||
|     def __init__(self, client, domain_id=None, parent=None): | ||||
|     def __init__(self, client, domain_id=None, parent=None, tags=None): | ||||
|         super(Project, self).__init__(client, domain_id) | ||||
|         self.parent = parent | ||||
|         self.tags = tags if tags else [] | ||||
|  | ||||
|     def setUp(self): | ||||
|         super(Project, self).setUp() | ||||
| @@ -81,7 +82,8 @@ class Project(Base): | ||||
|         self.ref = {'name': RESOURCE_NAME_PREFIX + uuid.uuid4().hex, | ||||
|                     'domain': self.domain_id, | ||||
|                     'enabled': True, | ||||
|                     'parent': self.parent} | ||||
|                     'parent': self.parent, | ||||
|                     'tags': self.tags} | ||||
|         self.entity = self.client.projects.create(**self.ref) | ||||
|         self.addCleanup(self.client.projects.delete, self.entity) | ||||
|  | ||||
|   | ||||
| @@ -53,6 +53,7 @@ class ProjectsTestCase(base.V3ClientTestCase, ProjectsTestMixin): | ||||
|  | ||||
|         self.test_project = fixtures.Project(self.client, self.test_domain.id) | ||||
|         self.useFixture(self.test_project) | ||||
|         self.special_tag = '~`!@#$%^&*()-_+=<>.? \'"' | ||||
|  | ||||
|     def test_create_subproject(self): | ||||
|         project_ref = { | ||||
| @@ -188,3 +189,257 @@ class ProjectsTestCase(base.V3ClientTestCase, ProjectsTestMixin): | ||||
|         self.assertRaises(http.NotFound, | ||||
|                           self.client.projects.get, | ||||
|                           project.id) | ||||
|  | ||||
|     def test_list_projects_with_tag_filters(self): | ||||
|         project_one = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1']) | ||||
|         project_two = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1', 'tag2']) | ||||
|         project_three = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag2', 'tag3']) | ||||
|  | ||||
|         self.useFixture(project_one) | ||||
|         self.useFixture(project_two) | ||||
|         self.useFixture(project_three) | ||||
|  | ||||
|         projects = self.client.projects.list(tags='tag1') | ||||
|         project_ids = [] | ||||
|         for project in projects: | ||||
|             project_ids.append(project.id) | ||||
|         self.assertIn(project_one.id, project_ids) | ||||
|  | ||||
|         projects = self.client.projects.list(tags_any='tag1') | ||||
|         project_ids = [] | ||||
|         for project in projects: | ||||
|             project_ids.append(project.id) | ||||
|         self.assertIn(project_one.id, project_ids) | ||||
|         self.assertIn(project_two.id, project_ids) | ||||
|  | ||||
|         projects = self.client.projects.list(not_tags='tag1') | ||||
|         project_ids = [] | ||||
|         for project in projects: | ||||
|             project_ids.append(project.id) | ||||
|         self.assertNotIn(project_one.id, project_ids) | ||||
|  | ||||
|         projects = self.client.projects.list(not_tags_any='tag1,tag2') | ||||
|         project_ids = [] | ||||
|         for project in projects: | ||||
|             project_ids.append(project.id) | ||||
|         self.assertNotIn(project_one.id, project_ids) | ||||
|         self.assertNotIn(project_two.id, project_ids) | ||||
|         self.assertNotIn(project_three.id, project_ids) | ||||
|  | ||||
|         projects = self.client.projects.list(tags='tag1,tag2') | ||||
|         project_ids = [] | ||||
|         for project in projects: | ||||
|             project_ids.append(project.id) | ||||
|         self.assertNotIn(project_one.id, project_ids) | ||||
|         self.assertIn(project_two.id, project_ids) | ||||
|         self.assertNotIn(project_three.id, project_ids) | ||||
|  | ||||
|     def test_add_tag(self): | ||||
|         project = fixtures.Project(self.client, self.test_domain.id) | ||||
|         self.useFixture(project) | ||||
|  | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual([], tags) | ||||
|  | ||||
|         project.add_tag('tag1') | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual(['tag1'], tags) | ||||
|  | ||||
|         # verify there is an error when you try to add the same tag | ||||
|         self.assertRaises(http.BadRequest, | ||||
|                           project.add_tag, | ||||
|                           'tag1') | ||||
|  | ||||
|     def test_update_tags(self): | ||||
|         project = fixtures.Project(self.client, self.test_domain.id) | ||||
|         self.useFixture(project) | ||||
|  | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual([], tags) | ||||
|  | ||||
|         project.update_tags(['tag1', 'tag2', self.special_tag]) | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertIn('tag1', tags) | ||||
|         self.assertIn('tag2', tags) | ||||
|         self.assertIn(self.special_tag, tags) | ||||
|         self.assertEqual(3, len(tags)) | ||||
|  | ||||
|         project.update_tags([]) | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual([], tags) | ||||
|  | ||||
|         # cannot have duplicate tags in update | ||||
|         self.assertRaises(http.BadRequest, | ||||
|                           project.update_tags, | ||||
|                           ['tag1', 'tag1']) | ||||
|  | ||||
|     def test_delete_tag(self): | ||||
|         project = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1', self.special_tag]) | ||||
|         self.useFixture(project) | ||||
|  | ||||
|         project.delete_tag('tag1') | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual([self.special_tag], tags) | ||||
|  | ||||
|         project.delete_tag(self.special_tag) | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual([], tags) | ||||
|  | ||||
|     def test_delete_all_tags(self): | ||||
|         project_one = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1']) | ||||
|  | ||||
|         project_two = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1', 'tag2', self.special_tag]) | ||||
|  | ||||
|         project_three = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=[]) | ||||
|  | ||||
|         self.useFixture(project_one) | ||||
|         self.useFixture(project_two) | ||||
|         self.useFixture(project_three) | ||||
|  | ||||
|         result_one = project_one.delete_all_tags() | ||||
|         tags_one = self.client.projects.get(project_one.id).tags | ||||
|         tags_two = self.client.projects.get(project_two.id).tags | ||||
|         self.assertEqual([], result_one) | ||||
|         self.assertEqual([], tags_one) | ||||
|         self.assertIn('tag1', tags_two) | ||||
|  | ||||
|         result_two = project_two.delete_all_tags() | ||||
|         tags_two = self.client.projects.get(project_two.id).tags | ||||
|         self.assertEqual([], result_two) | ||||
|         self.assertEqual([], tags_two) | ||||
|  | ||||
|         result_three = project_three.delete_all_tags() | ||||
|         tags_three = self.client.projects.get(project_three.id).tags | ||||
|         self.assertEqual([], result_three) | ||||
|         self.assertEqual([], tags_three) | ||||
|  | ||||
|     def test_list_tags(self): | ||||
|         tags_one = ['tag1'] | ||||
|         project_one = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=tags_one) | ||||
|  | ||||
|         tags_two = ['tag1', 'tag2'] | ||||
|         project_two = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=tags_two) | ||||
|  | ||||
|         tags_three = [] | ||||
|         project_three = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=tags_three) | ||||
|  | ||||
|         self.useFixture(project_one) | ||||
|         self.useFixture(project_two) | ||||
|         self.useFixture(project_three) | ||||
|  | ||||
|         result_one = project_one.list_tags() | ||||
|         result_two = project_two.list_tags() | ||||
|         result_three = project_three.list_tags() | ||||
|  | ||||
|         for tag in tags_one: | ||||
|             self.assertIn(tag, result_one) | ||||
|         self.assertEqual(1, len(result_one)) | ||||
|  | ||||
|         for tag in tags_two: | ||||
|             self.assertIn(tag, result_two) | ||||
|         self.assertEqual(2, len(result_two)) | ||||
|  | ||||
|         for tag in tags_three: | ||||
|             self.assertIn(tag, result_three) | ||||
|         self.assertEqual(0, len(result_three)) | ||||
|  | ||||
|     def test_check_tag(self): | ||||
|         project = fixtures.Project( | ||||
|             self.client, self.test_domain.id, | ||||
|             tags=['tag1']) | ||||
|         self.useFixture(project) | ||||
|  | ||||
|         tags = self.client.projects.get(project.id).tags | ||||
|         self.assertEqual(['tag1'], tags) | ||||
|         self.assertTrue(project.check_tag('tag1')) | ||||
|         self.assertFalse(project.check_tag('tag2')) | ||||
|         self.assertFalse(project.check_tag(self.special_tag)) | ||||
|  | ||||
|     def test_add_invalid_tags(self): | ||||
|         project_one = fixtures.Project( | ||||
|             self.client, self.test_domain.id) | ||||
|  | ||||
|         self.useFixture(project_one) | ||||
|  | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.add_tag, | ||||
|                           ',') | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.add_tag, | ||||
|                           '/') | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.add_tag, | ||||
|                           '') | ||||
|  | ||||
|     def test_update_invalid_tags(self): | ||||
|         tags_comma = ['tag1', ','] | ||||
|         tags_slash = ['tag1', '/'] | ||||
|         tags_blank = ['tag1', ''] | ||||
|         project_one = fixtures.Project( | ||||
|             self.client, self.test_domain.id) | ||||
|  | ||||
|         self.useFixture(project_one) | ||||
|  | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.update_tags, | ||||
|                           tags_comma) | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.update_tags, | ||||
|                           tags_slash) | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           project_one.update_tags, | ||||
|                           tags_blank) | ||||
|  | ||||
|     def test_create_project_invalid_tags(self): | ||||
|         project_ref = { | ||||
|             'name': fixtures.RESOURCE_NAME_PREFIX + uuid.uuid4().hex, | ||||
|             'domain': self.test_domain.id, | ||||
|             'enabled': True, | ||||
|             'description': uuid.uuid4().hex, | ||||
|             'tags': ','} | ||||
|  | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           self.client.projects.create, | ||||
|                           **project_ref) | ||||
|  | ||||
|         project_ref = { | ||||
|             'name': fixtures.RESOURCE_NAME_PREFIX + uuid.uuid4().hex, | ||||
|             'domain': self.test_domain.id, | ||||
|             'enabled': True, | ||||
|             'description': uuid.uuid4().hex, | ||||
|             'tags': '/'} | ||||
|  | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           self.client.projects.create, | ||||
|                           **project_ref) | ||||
|  | ||||
|         project_ref = { | ||||
|             'name': fixtures.RESOURCE_NAME_PREFIX + uuid.uuid4().hex, | ||||
|             'domain': self.test_domain.id, | ||||
|             'enabled': True, | ||||
|             'description': uuid.uuid4().hex, | ||||
|             'tags': ''} | ||||
|  | ||||
|         self.assertRaises(exceptions.BadRequest, | ||||
|                           self.client.projects.create, | ||||
|                           **project_ref) | ||||
|   | ||||
| @@ -312,3 +312,86 @@ class ProjectTests(utils.ClientTestCase, utils.CrudTests): | ||||
|         # server, a different implementation might not fail this request. | ||||
|         self.assertRaises(ksa_exceptions.Forbidden, self.manager.update, | ||||
|                           ref['id'], **utils.parameterize(req_ref)) | ||||
|  | ||||
|     def test_add_tag(self): | ||||
|         ref = self.new_ref() | ||||
|         tag_name = "blue" | ||||
|  | ||||
|         self.stub_url("PUT", | ||||
|                       parts=[self.collection_key, ref['id'], "tags", tag_name], | ||||
|                       status_code=201) | ||||
|         self.manager.add_tag(ref['id'], tag_name) | ||||
|  | ||||
|     def test_update_tags(self): | ||||
|         new_tags = ["blue", "orange"] | ||||
|         ref = self.new_ref() | ||||
|  | ||||
|         self.stub_url("PUT", | ||||
|                       parts=[self.collection_key, ref['id'], "tags"], | ||||
|                       json={"tags": new_tags}, | ||||
|                       status_code=200) | ||||
|  | ||||
|         ret = self.manager.update_tags(ref['id'], new_tags) | ||||
|         self.assertEqual(ret, new_tags) | ||||
|  | ||||
|     def test_delete_tag(self): | ||||
|         ref = self.new_ref() | ||||
|         tag_name = "blue" | ||||
|  | ||||
|         self.stub_url("DELETE", | ||||
|                       parts=[self.collection_key, ref['id'], "tags", tag_name], | ||||
|                       status_code=204) | ||||
|  | ||||
|         self.manager.delete_tag(ref['id'], tag_name) | ||||
|  | ||||
|     def test_delete_all_tags(self): | ||||
|         ref = self.new_ref() | ||||
|  | ||||
|         self.stub_url("PUT", | ||||
|                       parts=[self.collection_key, ref['id'], "tags"], | ||||
|                       json={"tags": []}, | ||||
|                       status_code=200) | ||||
|  | ||||
|         ret = self.manager.update_tags(ref['id'], []) | ||||
|         self.assertEqual([], ret) | ||||
|  | ||||
|     def test_list_tags(self): | ||||
|         ref = self.new_ref() | ||||
|         tags = ["blue", "orange", "green"] | ||||
|  | ||||
|         self.stub_url("GET", | ||||
|                       parts=[self.collection_key, ref['id'], "tags"], | ||||
|                       json={"tags": tags}, | ||||
|                       status_code=200) | ||||
|  | ||||
|         ret_tags = self.manager.list_tags(ref['id']) | ||||
|         self.assertEqual(tags, ret_tags) | ||||
|  | ||||
|     def test_check_tag(self): | ||||
|         ref = self.new_ref() | ||||
|  | ||||
|         tag_name = "blue" | ||||
|         self.stub_url("HEAD", | ||||
|                       parts=[self.collection_key, ref['id'], "tags", tag_name], | ||||
|                       status_code=204) | ||||
|         self.assertTrue(self.manager.check_tag(ref['id'], tag_name)) | ||||
|  | ||||
|         no_tag = "orange" | ||||
|         self.stub_url("HEAD", | ||||
|                       parts=[self.collection_key, ref['id'], "tags", no_tag], | ||||
|                       status_code=404) | ||||
|         self.assertFalse(self.manager.check_tag(ref['id'], no_tag)) | ||||
|  | ||||
|     def _build_project_response(self, tags): | ||||
|         project_id = uuid.uuid4().hex | ||||
|         ret = {"projects": [ | ||||
|             {"is_domain": False, | ||||
|              "description": "", | ||||
|              "tags": tags, | ||||
|              "enabled": True, | ||||
|              "id": project_id, | ||||
|              "parent_id": "default", | ||||
|              "domain_id": "default", | ||||
|              "name": project_id} | ||||
|         ]} | ||||
|         return ret | ||||
|   | ||||
| @@ -14,6 +14,8 @@ | ||||
| #    License for the specific language governing permissions and limitations | ||||
| #    under the License. | ||||
|  | ||||
| import six.moves.urllib as urllib | ||||
|  | ||||
| from keystoneclient import base | ||||
| from keystoneclient import exceptions | ||||
| from keystoneclient.i18n import _ | ||||
| @@ -52,6 +54,24 @@ class Project(base.Resource): | ||||
|  | ||||
|         return retval | ||||
|  | ||||
|     def add_tag(self, tag): | ||||
|         self.manager.add_tag(self, tag) | ||||
|  | ||||
|     def update_tags(self, tags): | ||||
|         return self.manager.update_tags(self, tags) | ||||
|  | ||||
|     def delete_tag(self, tag): | ||||
|         self.manager.delete_tag(self, tag) | ||||
|  | ||||
|     def delete_all_tags(self): | ||||
|         return self.manager.update_tags(self, []) | ||||
|  | ||||
|     def list_tags(self): | ||||
|         return self.manager.list_tags(self) | ||||
|  | ||||
|     def check_tag(self, tag): | ||||
|         return self.manager.check_tag(self, tag) | ||||
|  | ||||
|  | ||||
| class ProjectManager(base.CrudManager): | ||||
|     """Manager class for manipulating Identity projects.""" | ||||
| @@ -101,17 +121,24 @@ class ProjectManager(base.CrudManager): | ||||
|                      assignments on. | ||||
|         :type user: str or :class:`keystoneclient.v3.users.User` | ||||
|         :param kwargs: any other attribute provided will filter projects on. | ||||
|                        Project tags filter keyword: ``tags``, ``tags_any``, | ||||
|                        ``not_tags``, and ``not_tags_any``. tag attribute type | ||||
|                        string. Pass in a comma separated string to filter | ||||
|                        with multiple tags. | ||||
|  | ||||
|         :returns: a list of projects. | ||||
|         :rtype: list of :class:`keystoneclient.v3.projects.Project` | ||||
|  | ||||
|         """ | ||||
|         base_url = '/users/%s' % base.getid(user) if user else None | ||||
|         return super(ProjectManager, self).list( | ||||
|         projects = super(ProjectManager, self).list( | ||||
|             base_url=base_url, | ||||
|             domain_id=base.getid(domain), | ||||
|             fallback_to_auth=True, | ||||
|             **kwargs) | ||||
|         for p in projects: | ||||
|             p.tags = self._encode_tags(getattr(p, 'tags', [])) | ||||
|         return projects | ||||
|  | ||||
|     def _check_not_parents_as_ids_and_parents_as_list(self, parents_as_ids, | ||||
|                                                       parents_as_list): | ||||
| @@ -174,7 +201,9 @@ class ProjectManager(base.CrudManager): | ||||
|         query = self.build_key_only_query(query_params) | ||||
|         dict_args = {'project_id': base.getid(project)} | ||||
|         url = self.build_url(dict_args_in_out=dict_args) | ||||
|         return self._get(url + query, self.key) | ||||
|         p = self._get(url + query, self.key) | ||||
|         p.tags = self._encode_tags(getattr(p, 'tags', [])) | ||||
|         return p | ||||
|  | ||||
|     def update(self, project, name=None, domain=None, description=None, | ||||
|                enabled=None, **kwargs): | ||||
| @@ -213,3 +242,82 @@ class ProjectManager(base.CrudManager): | ||||
|         """ | ||||
|         return super(ProjectManager, self).delete( | ||||
|             project_id=base.getid(project)) | ||||
|  | ||||
|     def _encode_tags(self, tags): | ||||
|         """Encode tags to non-unicode string in python2. | ||||
|  | ||||
|         :param tags: list of unicode tags | ||||
|  | ||||
|         :returns: List of strings | ||||
|         """ | ||||
|         return [str(t) for t in tags] | ||||
|  | ||||
|     def add_tag(self, project, tag): | ||||
|         """Add a tag to a project. | ||||
|  | ||||
|         :param project: project to add a tag to. | ||||
|         :param tag: str name of tag. | ||||
|  | ||||
|         """ | ||||
|         url = "/projects/%s/tags/%s" % (base.getid(project), | ||||
|                                         urllib.parse.quote(tag)) | ||||
|         self.client.put(url) | ||||
|  | ||||
|     def update_tags(self, project, tags): | ||||
|         """Update tag list of a project. | ||||
|  | ||||
|         Replaces current tag list with list specified in tags parameter. | ||||
|  | ||||
|         :param project: project to update. | ||||
|         :param tags: list of str tag names to add to the project | ||||
|  | ||||
|         :returns: list of tags | ||||
|  | ||||
|         """ | ||||
|         url = "/projects/%s/tags" % base.getid(project) | ||||
|         for tag in tags: | ||||
|             tag = urllib.parse.quote(tag) | ||||
|         resp, body = self.client.put(url, body={"tags": tags}) | ||||
|         return body['tags'] | ||||
|  | ||||
|     def delete_tag(self, project, tag): | ||||
|         """Remove tag from project. | ||||
|  | ||||
|         :param projectd: project to remove tag from. | ||||
|         :param tag: str name of tag to remove from project | ||||
|  | ||||
|         """ | ||||
|         self._delete( | ||||
|             "/projects/%s/tags/%s" % (base.getid(project), | ||||
|                                       urllib.parse.quote(tag))) | ||||
|  | ||||
|     def list_tags(self, project): | ||||
|         """List tags associated with project. | ||||
|  | ||||
|         :param project: project to list tags for. | ||||
|  | ||||
|         :returns: list of str tag names | ||||
|  | ||||
|         """ | ||||
|         url = "/projects/%s/tags" % base.getid(project) | ||||
|         resp, body = self.client.get(url) | ||||
|         return self._encode_tags(body['tags']) | ||||
|  | ||||
|     def check_tag(self, project, tag): | ||||
|         """Check if tag is associated with project. | ||||
|  | ||||
|         :param project: project to check tags for. | ||||
|         :param tag: str name of tag | ||||
|  | ||||
|         :returns: true if tag is associated, false otherwise | ||||
|  | ||||
|         """ | ||||
|         url = "/projects/%s/tags/%s" % (base.getid(project), | ||||
|                                         urllib.parse.quote(tag)) | ||||
|         try: | ||||
|             self.client.head(url) | ||||
|             # no errors means found the tag | ||||
|             return True | ||||
|         except exceptions.NotFound: | ||||
|             # 404 means tag not in project | ||||
|             return False | ||||
|   | ||||
							
								
								
									
										8
									
								
								releasenotes/notes/project-tags-1f8a32d389951e7a.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								releasenotes/notes/project-tags-1f8a32d389951e7a.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| --- | ||||
| features: | ||||
|   - | | ||||
|     [`blueprint project-tags <https://blueprints.launchpad.net/keystone/+spec/project-tags>`_] | ||||
|     The keystoneclient now supports project tags feature in keystone.  This | ||||
|     allows operators to use the client to associate tags to a project, | ||||
|     retrieve tags associated with a project, delete tags associated with a | ||||
|     project, and filter projects based on tags. | ||||
		Reference in New Issue
	
	Block a user
	 Samuel Pilla
					Samuel Pilla