diff --git a/azure-devops/azext_devops/dev/team/_format.py b/azure-devops/azext_devops/dev/team/_format.py index 1e5dd2bc..b3f04901 100644 --- a/azure-devops/azext_devops/dev/team/_format.py +++ b/azure-devops/azext_devops/dev/team/_format.py @@ -122,6 +122,29 @@ def _transform_team_member_row(row): return table_row +def transform_users_table_output(result): + table_output = [] + for item in result: + table_output.append(_transform_user_row(item)) + return table_output + + +def transform_user_table_output(result): + table_output = [_transform_user_row(result)] + return table_output + + +def _transform_user_row(row): + table_row = OrderedDict() + table_row['ID'] = row['id'] + table_row['Display Name'] = row['user']['displayName'] + table_row['Email'] = row['user']['mailAddress'] + table_row['License Type'] = row['accessLevel']['accountLicenseType'] + table_row['Access Level'] = row['accessLevel']['licenseDisplayName'] + table_row['Status'] = row['accessLevel']['status'] + return table_row + + def _get_extension_key(extension): return extension['extensionName'].lower() diff --git a/azure-devops/azext_devops/dev/team/_help.py b/azure-devops/azext_devops/dev/team/_help.py index 26ee3408..e57082ae 100644 --- a/azure-devops/azext_devops/dev/team/_help.py +++ b/azure-devops/azext_devops/dev/team/_help.py @@ -33,6 +33,11 @@ def load_team_help(): short-summary: Manage teams """ + helps['devops user'] = """ + type: group + short-summary: Manage users + """ + helps['devops extension'] = """ type: group short-summary: Manage extensions diff --git a/azure-devops/azext_devops/dev/team/arguments.py b/azure-devops/azext_devops/dev/team/arguments.py index 6d3fafc1..4daeff87 100644 --- a/azure-devops/azext_devops/dev/team/arguments.py +++ b/azure-devops/azext_devops/dev/team/arguments.py @@ -20,6 +20,7 @@ _SERVICE_ENDPOINT_TYPE = [SERVICE_ENDPOINT_TYPE_GITHUB, SERVICE_ENDPOINT_TYPE_AZURE_RM] _SERVICE_ENDPOINT_AUTHORIZATION_SCHEME = [SERVICE_ENDPOINT_AUTHORIZATION_PERSONAL_ACCESS_TOKEN, SERVICE_ENDPOINT_AUTHORIZATION_SERVICE_PRINCIPAL] +_LICENSE_TYPES = ['advanced', 'earlyAdopter', 'express', 'none', 'professional', 'stakeholder'] def load_global_args(context): @@ -51,6 +52,14 @@ def load_team_arguments(self, _): context.argument('use_git_aliases', **enum_choice_list(_YES_NO_SWITCH_VALUES)) context.argument('list_config', options_list=('--list', '-l')) + with self.argument_context('devops user') as context: + from azure.cli.core.commands.parameters import get_enum_type + context.argument('license_type', arg_type=get_enum_type(_LICENSE_TYPES)) + with self.argument_context('devops user add') as context: + from azure.cli.core.commands.parameters import get_enum_type + context.argument('send_email_invite', arg_type=get_enum_type(_TRUE_FALSE_SWITCH), + help='Whether to send email invite for new user or not.') + with self.argument_context('devops extension') as context: from azure.cli.core.commands.parameters import get_enum_type context.argument('include_built_in', arg_type=get_enum_type(_TRUE_FALSE_SWITCH), diff --git a/azure-devops/azext_devops/dev/team/commands.py b/azure-devops/azext_devops/dev/team/commands.py index 95b3b8ce..1d1fd762 100644 --- a/azure-devops/azext_devops/dev/team/commands.py +++ b/azure-devops/azext_devops/dev/team/commands.py @@ -10,6 +10,8 @@ transform_team_table_output, transform_teams_table_output, transform_team_members_table_output, + transform_users_table_output, + transform_user_table_output, transform_extension_table_output, transform_extensions_table_output) @@ -44,6 +46,11 @@ exception_handler=azure_devops_exception_handler ) +userOps = CliCommandType( + operations_tmpl='azext_devops.dev.team.user#{}', + exception_handler=azure_devops_exception_handler +) + extensionOps = CliCommandType( operations_tmpl='azext_devops.dev.team.extension#{}', exception_handler=azure_devops_exception_handler @@ -80,6 +87,13 @@ def load_team_commands(self, _): g.command('list-member', 'get_team_members', table_transformer=transform_team_members_table_output) g.command('update', 'update_team', table_transformer=transform_team_table_output) + with self.command_group('devops user', command_type=userOps) as g: + g.command('list', 'get_user_entitlements', table_transformer=transform_users_table_output) + g.command('show', 'get_user_entitlement', table_transformer=transform_user_table_output) + g.command('remove', 'delete_user_entitlement', confirmation='Are you sure you want to remove this user?') + g.command('update', 'update_user_entitlement', table_transformer=transform_user_table_output) + g.command('add', 'add_user_entitlement', table_transformer=transform_user_table_output) + with self.command_group('devops extension', command_type=extensionOps) as g: g.command('list', 'list_extensions', table_transformer=transform_extensions_table_output) g.command('uninstall', 'uninstall_extension', confirmation='Are you sure you want to uninstall this extension?') diff --git a/azure-devops/azext_devops/dev/team/user.py b/azure-devops/azext_devops/dev/team/user.py new file mode 100644 index 00000000..0d31b168 --- /dev/null +++ b/azure-devops/azext_devops/dev/team/user.py @@ -0,0 +1,108 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_devops.vstsCompressed.member_entitlement_management.v4_1.models.models import (AccessLevel, + GraphUser, + JsonPatchOperation) +from azext_devops.dev.common.services import (get_member_entitlement_management_client, + resolve_instance) +from azext_devops.dev.common.arguments import resolve_true_false +from azext_devops.dev.common.identities import resolve_identity_as_id + + +def get_user_entitlements(top=100, skip=None, organization=None, detect=None): + """List users for an organization. + :param int top: Maximum number of the users to return. Max value is 10000. + :param int skip: Offset: Number of records to skip. + :rtype: [UserEntitlement] + """ + organization = resolve_instance(detect=detect, organization=organization) + client = get_member_entitlement_management_client(organization) + user_entitlements = client.get_user_entitlements(top=top, skip=skip) + return user_entitlements + + +def get_user_entitlement(user, organization=None, detect=None): + """Show user details. + :param user: The Email id or UUID of the user. + :type user: str + :rtype: UserEntitlement + """ + organization = resolve_instance(detect=detect, organization=organization) + if '@' in user: + user = resolve_identity_as_id(user, organization) + client = get_member_entitlement_management_client(organization) + user_entitlement_details = client.get_user_entitlement(user_id=user) + return user_entitlement_details + + +def delete_user_entitlement(user, organization=None, detect=None): + """Remove user from an organization. + :param user: The Email id or UUID of the user. + :type user: str + """ + organization = resolve_instance(detect=detect, organization=organization) + if '@' in user: + user = resolve_identity_as_id(user, organization) + client = get_member_entitlement_management_client(organization) + delete_user_entitlement_details = client.delete_user_entitlement(user_id=user) + return delete_user_entitlement_details + + +def update_user_entitlement(user, license_type, organization=None, detect=None): + """Update license type for a user. + :param user: The Email id or UUID of the user. + :type user: str + :param license_type: License type for the user. + :type license_type: str + :rtype: UserEntitlementsPatchResponse + """ + patch_document = [] + value = {} + value['accountLicenseType'] = license_type + patch_document.append(_create_patch_operation('replace', '/accessLevel', value)) + organization = resolve_instance(detect=detect, organization=organization) + if '@' in user: + user = resolve_identity_as_id(user, organization) + client = get_member_entitlement_management_client(organization) + user_entitlement_update = client.update_user_entitlement(document=patch_document, user_id=user) + return user_entitlement_update.user_entitlement + + +def add_user_entitlement(user, license_type, send_email_invite='true', organization=None, detect=None): + """Add user. + :param user: The Email id of the user. + :type user: str + :param license_type: License type for the user. + :type license_type: str + :rtype: UserEntitlementsPatchResponse + """ + do_not_send_invite = False + do_not_send_invite = not resolve_true_false(send_email_invite) + organization = resolve_instance(detect=detect, organization=organization) + client = get_member_entitlement_management_client(organization) + user_access_level = AccessLevel() + user_access_level.account_license_type = license_type + graph_user = GraphUser() + graph_user.subject_kind = 'user' + graph_user.principal_name = user + value = {} + value['accessLevel'] = user_access_level + value['extensions'] = [] + value['projectEntitlements'] = [] + value['user'] = graph_user + patch_document = [] + patch_document.append(_create_patch_operation('add', '', value)) + user_entitlement_details = client.update_user_entitlements(document=patch_document, + do_not_send_invite_for_new_users=do_not_send_invite) + return user_entitlement_details.results[0].result + + +def _create_patch_operation(op, path, value): + patch_operation = JsonPatchOperation() + patch_operation.op = op + patch_operation.path = path + patch_operation.value = value + return patch_operation diff --git a/azure-devops/azext_devops/test/team/test_user.py b/azure-devops/azext_devops/test/team/test_user.py new file mode 100644 index 00000000..583a9b8f --- /dev/null +++ b/azure-devops/azext_devops/test/team/test_user.py @@ -0,0 +1,103 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + +try: + # Attempt to load mock (works on Python 3.3 and above) + from unittest.mock import patch +except ImportError: + # Attempt to load mock (works on Python version below 3.3) + from mock import patch + +from azext_devops.vstsCompressed.member_entitlement_management.v4_1.member_entitlement_management_client import (MemberEntitlementManagementClient) +from azext_devops.dev.team.user import (get_user_entitlements, + get_user_entitlement, + add_user_entitlement, + delete_user_entitlement, + update_user_entitlement) + +from azext_devops.dev.common.services import clear_connection_cache + + +class TestUserMethods(unittest.TestCase): + + _TEST_DEVOPS_ORGANIZATION = 'https://someorganization.visualstudio.com' + _TEST_PROJECT_NAME = 'sample_project' + _TOP_VALUE = 10 + _SKIP_VALUE = 2 + _OFF = 'Off' + _TEST_USER_ID = 'adda517c-0398-42dc-b2a8-0d3f240757f9' + _USER_MGMT_CLIENT_LOCATION = 'azext_devops.vstsCompressed.member_entitlement_management.v4_1.member_entitlement_management_client.MemberEntitlementManagementClient.' + + def setUp(self): + self.get_client = patch('azext_devops.vstsCompressed.vss_connection.VssConnection.get_client') + self.get_credential_patcher = patch('azext_devops.dev.common.services.get_credential') + self.get_patch_op_patcher = patch('azext_devops.dev.team.user._create_patch_operation') + self.list_user_patcher = patch(self._USER_MGMT_CLIENT_LOCATION + 'get_user_entitlements') + self.get_user_patcher = patch(self._USER_MGMT_CLIENT_LOCATION + 'get_user_entitlement') + self.add_user_patcher = patch(self._USER_MGMT_CLIENT_LOCATION + 'update_user_entitlements') + self.remove_user_patcher = patch(self._USER_MGMT_CLIENT_LOCATION + 'delete_user_entitlement') + self.update_user_patcher = patch(self._USER_MGMT_CLIENT_LOCATION + 'update_user_entitlement') + + self.mock_get_client = self.get_client.start() + self.mock_get_users = self.list_user_patcher.start() + self.mock_add_user = self.add_user_patcher.start() + self.mock_get_user = self.get_user_patcher.start() + self.mock_remove_user = self.remove_user_patcher.start() + self.mock_update_user = self.update_user_patcher.start() + self.mock_get_credential = self.get_credential_patcher.start() + + #set return values + self.mock_get_client.return_value = MemberEntitlementManagementClient(base_url=self._TEST_DEVOPS_ORGANIZATION) + + #clear connection cache before running each test + clear_connection_cache() + + def tearDown(self): + patch.stopall() + + def test_list_user(self): + get_user_entitlements(top=self._TOP_VALUE, skip=self._SKIP_VALUE, + organization=self._TEST_DEVOPS_ORGANIZATION, detect=self._OFF) + #assert + self.mock_get_users.assert_called_once() + list_user_param = self.mock_get_users.call_args_list[0][1] + self.assertEqual(10,list_user_param['top']) + self.assertEqual(2, list_user_param['skip']) + + def test_show_user(self): + get_user_entitlement(user=self._TEST_USER_ID, organization=self._TEST_DEVOPS_ORGANIZATION, detect=self._OFF) + #assert + self.mock_get_user.assert_called_once_with(user_id = 'adda517c-0398-42dc-b2a8-0d3f240757f9') + + def test_add_user(self): + add_user_entitlement(user='someuser@xyz.com', license_type='stakeholder', + organization=self._TEST_DEVOPS_ORGANIZATION, detect=self._OFF) + #assert + self.mock_add_user.assert_called_once() + add_user_param = self.mock_add_user.call_args_list[0][1] + add_user_param_document = add_user_param['document'][0].value + self.assertEqual(False, add_user_param['do_not_send_invite_for_new_users']) + self.assertEqual('stakeholder', add_user_param_document['accessLevel'].account_license_type) + self.assertEqual('user', add_user_param_document['user'].subject_kind) + self.assertEqual('someuser@xyz.com', add_user_param_document['user'].principal_name) + + def test_remove_user(self): + delete_user_entitlement(user=self._TEST_USER_ID, organization= self._TEST_DEVOPS_ORGANIZATION, detect=self._OFF) + #assert + self.mock_remove_user.assert_called_once() + + def test_update_user(self): + update_user_entitlement(user=self._TEST_USER_ID, license_type='express', + organization=self._TEST_DEVOPS_ORGANIZATION, detect=self._OFF) + #assert + self.mock_update_user.assert_called_once() + update_user_param = self.mock_update_user.call_args_list[0][1] + update_user_param_document = update_user_param['document'][0].value + print(update_user_param_document) + self.assertEqual('express', update_user_param_document['accountLicenseType']) + self.assertEqual('adda517c-0398-42dc-b2a8-0d3f240757f9', update_user_param['user_id']) + \ No newline at end of file