diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbe0940..b74e6693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Added + +- New command `code42 users update` to update a single user. + +- New command `code42 users bulk update` to update users in bulk. + ### Changed - `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index ec0bd2fb..38d3b6b4 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -12,6 +12,7 @@ from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError +from py42.exceptions import Py42UsernameMustBeEmailError from py42.exceptions import Py42UserNotOnListError from code42cli.errors import Code42CLIError @@ -67,6 +68,7 @@ def invoke(self, ctx): Py42DescriptionLimitExceededError, Py42CaseAlreadyHasEventError, Py42UpdateClosedCaseError, + Py42UsernameMustBeEmailError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index a86ce9fd..92e3fa0e 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -573,7 +573,7 @@ def bulk(state): def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): """Deactivate all devices from the provided CSV containing a 'guid' column.""" sdk = state.sdk - csv_rows[0]["deactivated"] = False + csv_rows[0]["deactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) for row in csv_rows: row["change_device_name"] = change_device_name diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index d368dcca..217b24ca 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -1,14 +1,18 @@ import click from pandas import DataFrame +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with from code42cli.errors import Code42CLIError from code42cli.errors import UserDoesNotExistError +from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import OutputFormat +from code42cli.output_formats import OutputFormatter @click.group(cls=OrderedGroup) @@ -33,6 +37,11 @@ def users(state): ) +user_uid_option = click.option( + "--user-id", help="The unique identifier of the user to be modified.", required=True +) + + def role_name_option(help): return click.option("--role-name", help=help) @@ -84,6 +93,95 @@ def remove_role(state, username, role_name): _remove_user_role(state.sdk, role_name, username) +@users.command(name="update") +@user_uid_option +@click.option("--username", help="The new username for the user.") +@click.option("--password", help="The new password for the user.") +@click.option("--email", help="The new email for the user.") +@click.option("--first-name", help="The new first name for the user.") +@click.option("--last-name", help="The new last name for the user.") +@click.option("--notes", help="Notes about this user.") +@click.option( + "--archive-size-quota", help="The total size (in bytes) allowed for this user." +) +@sdk_options() +def update_user( + state, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, +): + """Update a user with the specified unique identifier.""" + _update_user( + state.sdk, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, + ) + + +_bulk_user_update_headers = [ + "user_id", + "username", + "email", + "password", + "first_name", + "last_name", + "notes", + "archive_size_quota", +] + + +@users.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for managing users in bulk""" + pass + + +users_generate_template = generate_template_cmd_factory( + group_name="users", + commands_dict={"update": _bulk_user_update_headers}, + help_message="Generate the CSV template needed for bulk user commands.", +) +bulk.add_command(users_generate_template) + + +@bulk.command(name="update") +@read_csv_arg(headers=_bulk_user_update_headers) +@format_option +@sdk_options() +def bulk_update(state, csv_rows, format): + """Update a list of users from the provided CSV.""" + csv_rows[0]["updated"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + + def handle_row(**row): + try: + _update_user( + state.sdk, **{key: row[key] for key in row.keys() if key != "updated"} + ) + row["updated"] = "True" + except Exception as err: + row["updated"] = f"False: {err}" + return row + + result_rows = run_bulk_process( + handle_row, csv_rows, progress_label="Updating users:" + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -122,3 +220,26 @@ def _get_users_dataframe(sdk, columns, org_uid, role_id, active): users_list.extend(page["users"]) return DataFrame.from_records(users_list, columns=columns) + + +def _update_user( + sdk, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, +): + return sdk.users.update_user( + user_id, + username=username, + email=email, + password=password, + first_name=first_name, + last_name=last_name, + notes=notes, + archive_size_quota_bytes=archive_size_quota, + ) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index bb10618e..edce17eb 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -822,7 +822,7 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): assert bulk_processor.call_args[0][1] == [ { "guid": "test", - "deactivated": False, + "deactivated": "False", "change_device_name": False, "purge_date": None, } diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 506fbde7..8cc66802 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -7,6 +7,8 @@ from code42cli.main import cli +_NAMESPACE = "code42cli.cmds.users" + TEST_ROLE_RETURN_DATA = { "data": [{"roleName": "Customer Cloud Admin", "roleId": "1234543"}] } @@ -50,6 +52,11 @@ def get_all_users_generator(): yield TEST_USERS_RESPONSE +@pytest.fixture +def update_user_response(mocker): + return _create_py42_response(mocker, "") + + @pytest.fixture def get_available_roles_response(mocker): return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) @@ -75,6 +82,11 @@ def get_available_roles_success(cli_state, get_available_roles_response): cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response +@pytest.fixture +def update_user_success(cli_state, update_user_response): + cli_state.sdk.users.update_user.return_value = update_user_response + + def test_list_when_non_table_format_outputs_expected_columns( runner, cli_state, get_all_users_success ): @@ -263,3 +275,116 @@ def test_remove_user_role_raises_error_when_username_does_not_exist( result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 1 assert "User 'not_a_username@example.com' does not exist." in result.output + + +def test_update_user_calls_update_user_with_correct_parameters_when_only_some_are_passed( + runner, cli_state, update_user_success +): + command = ["users", "update", "--user-id", "12345", "--email", "test_email"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.update_user.assert_called_once_with( + "12345", + username=None, + email="test_email", + password=None, + first_name=None, + last_name=None, + notes=None, + archive_size_quota_bytes=None, + ) + + +def test_update_user_calls_update_user_with_correct_parameters_when_all_are_passed( + runner, cli_state, update_user_success +): + command = [ + "users", + "update", + "--user-id", + "12345", + "--email", + "test_email", + "--username", + "test_username", + "--password", + "test_password", + "--first-name", + "test_fname", + "--last-name", + "test_lname", + "--notes", + "test notes", + "--archive-size-quota", + "123456", + ] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.update_user.assert_called_once_with( + "12345", + username="test_username", + email="test_email", + password="test_password", + first_name="test_fname", + last_name="test_lname", + notes="test notes", + archive_size_quota_bytes="123456", + ) + + +def test_bulk_deactivate_uses_expected_arguments_when_only_some_are_passed( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines( + [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "12345,,test_email,,,,,\n", + ] + ) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + { + "user_id": "12345", + "username": "", + "email": "test_email", + "password": "", + "first_name": "", + "last_name": "", + "notes": "", + "archive_size_quota": "", + "updated": "False", + } + ] + + +def test_bulk_deactivate_uses_expected_arguments_when_all_are_passed( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines( + [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "12345,test_username,test_email,test_pword,test_fname,test_lname,test notes,4321\n", + ] + ) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + { + "user_id": "12345", + "username": "test_username", + "email": "test_email", + "password": "test_pword", + "first_name": "test_fname", + "last_name": "test_lname", + "notes": "test notes", + "archive_size_quota": "4321", + "updated": "False", + } + ]