From 9cfbf96620a8d01605c9eb553ad72b76068b79b4 Mon Sep 17 00:00:00 2001 From: Samuel Pilla Date: Fri, 18 Aug 2017 16:16:12 -0500 Subject: 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 Co-Authored-By: Rohan Arora Co-Authored-By: Tin Lam Partially Implements: bp project-tags Change-Id: I486b2969ae0aa2638842d842fb8b0955cc086d25 --- keystoneclient/base.py | 7 + .../tests/functional/v3/client_fixtures.py | 6 +- .../tests/functional/v3/test_projects.py | 255 +++++++++++++++++++++ keystoneclient/tests/unit/v3/test_projects.py | 83 +++++++ keystoneclient/v3/projects.py | 112 ++++++++- .../notes/project-tags-1f8a32d389951e7a.yaml | 8 + 6 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 releasenotes/notes/project-tags-1f8a32d389951e7a.yaml diff --git a/keystoneclient/base.py b/keystoneclient/base.py index 80e09a0..c466b1b 100644 --- a/keystoneclient/base.py +++ b/keystoneclient/base.py @@ -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): diff --git a/keystoneclient/tests/functional/v3/client_fixtures.py b/keystoneclient/tests/functional/v3/client_fixtures.py index dd7209a..edffc9b 100644 --- a/keystoneclient/tests/functional/v3/client_fixtures.py +++ b/keystoneclient/tests/functional/v3/client_fixtures.py @@ -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) diff --git a/keystoneclient/tests/functional/v3/test_projects.py b/keystoneclient/tests/functional/v3/test_projects.py index 4b8d749..c40da50 100644 --- a/keystoneclient/tests/functional/v3/test_projects.py +++ b/keystoneclient/tests/functional/v3/test_projects.py @@ -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) diff --git a/keystoneclient/tests/unit/v3/test_projects.py b/keystoneclient/tests/unit/v3/test_projects.py index 48477ed..8933bbf 100644 --- a/keystoneclient/tests/unit/v3/test_projects.py +++ b/keystoneclient/tests/unit/v3/test_projects.py @@ -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 diff --git a/keystoneclient/v3/projects.py b/keystoneclient/v3/projects.py index 3b2c4d8..470d818 100644 --- a/keystoneclient/v3/projects.py +++ b/keystoneclient/v3/projects.py @@ -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 diff --git a/releasenotes/notes/project-tags-1f8a32d389951e7a.yaml b/releasenotes/notes/project-tags-1f8a32d389951e7a.yaml new file mode 100644 index 0000000..c0c868c --- /dev/null +++ b/releasenotes/notes/project-tags-1f8a32d389951e7a.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + [`blueprint 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. -- cgit v1.2.1