diff --git a/CHANGELOG.md b/CHANGELOG.md index b11e2d0e5..2de6bbd9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,20 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Added + +- `code42 legal-hold search-events` command: + - `--matter-id` filters based on a legal hold uid. + - `--begin` filters based on a beginning timestamp. + - `--end` filters based on an end timestamp. + - `--event-type` filters based on a list of event types. + +## 1.4.1 - 2021-04-15 + ### Fixed -- Arguments/options that read data from files now attempt to autodetect file encodings. Resolving a bug where CSVs written - on Windows with Powershell would fail to be read properly. +- Arguments/options that read data from files now attempt to autodetect file encodings. + Resolving a bug where CSVs written on Windows with Powershell would fail to be read properly. ## 1.4.0 - 2021-03-09 diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index 3319f8571..bfc717860 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -93,4 +93,12 @@ To view all custodians (including inactive) for a legal hold matter, enter `code42 legal-hold show --include-inactive` +### List legal hold events + +To view a list of legal hold administrative events, use the following command: + +`code42 legal-hold search-events` + +This command takes the optional filters of a specific matter uid, beginning timestamp, end timestamp, and event type. + Learn more about the [Legal Hold](../commands/legalhold.md) commands. diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index ece6bbe34..a7c3882b9 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -1,5 +1,4 @@ import json -from collections import OrderedDict from functools import lru_cache from pprint import pformat @@ -14,33 +13,52 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.options import set_begin_default_dict +from code42cli.options import set_end_default_dict from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter from code42cli.util import format_string_list_to_columns -_MATTER_KEYS_MAP = OrderedDict() -_MATTER_KEYS_MAP["legalHoldUid"] = "Matter ID" -_MATTER_KEYS_MAP["name"] = "Name" -_MATTER_KEYS_MAP["description"] = "Description" -_MATTER_KEYS_MAP["creator_username"] = "Creator" -_MATTER_KEYS_MAP["creationDate"] = "Creation Date" +_MATTER_KEYS_MAP = { + "legalHoldUid": "Matter ID", + "name": "Name", + "description": "Description", + "creator_username": "Creator", + "creationDate": "Creation Date", +} +_EVENT_KEYS_MAP = { + "eventUid": "Event ID", + "eventType": "Event Type", + "eventDate": "Event Date", + "legalHoldUid": "Legal Hold ID", + "actorUsername": "Actor Username", + "custodianUsername": "Custodian Username", +} +LEGAL_HOLD_KEYWORD = "legal hold events" +LEGAL_HOLD_EVENT_TYPES = [ + "MembershipCreated", + "MembershipReactivated", + "MembershipDeactivated", + "HoldCreated", + "HoldDeactivated", + "HoldReactivated", + "Restore", +] +BEGIN_DATE_DICT = set_begin_default_dict(LEGAL_HOLD_KEYWORD) +END_DATE_DICT = set_end_default_dict(LEGAL_HOLD_KEYWORD) @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def legal_hold(state): """Add and remove custodians from legal hold matters.""" - pass -matter_id_option = click.option( - "-m", - "--matter-id", - required=True, - type=str, - help="Identification number of the legal hold matter the custodian will be added to.", -) +def matter_id_option(required, help): + return click.option("-m", "--matter-id", required=required, type=str, help=help) + + user_id_option = click.option( "-u", "--username", @@ -51,7 +69,10 @@ def legal_hold(state): @legal_hold.command() -@matter_id_option +@matter_id_option( + True, + "Identification number of the legal hold matter the custodian will be added to.", +) @user_id_option @sdk_options() def add_user(state, matter_id, username): @@ -60,7 +81,10 @@ def add_user(state, matter_id, username): @legal_hold.command() -@matter_id_option +@matter_id_option( + True, + "Identification number of the legal hold matter the custodian will be removed from.", +) @user_id_option @sdk_options() def remove_user(state, matter_id, username): @@ -124,6 +148,30 @@ def show(state, matter_id, include_inactive=False, include_policy=False): echo("") +@legal_hold.command() +@matter_id_option(False, "Filter results by legal hold UID.") +@click.option( + "--event-type", + type=click.Choice(LEGAL_HOLD_EVENT_TYPES), + help="Filter results by event types.", +) +@click.option("--begin", **BEGIN_DATE_DICT) +@click.option("--end", **END_DATE_DICT) +@format_option +@sdk_options() +def search_events(state, matter_id, event_type, begin, end, format): + """Tools for getting legal hold event data.""" + formatter = OutputFormatter(format, _EVENT_KEYS_MAP) + events = _get_all_events(state.sdk, matter_id, begin, end) + if event_type: + events = [event for event in events if event["eventType"] == event_type] + if len(events) > 10: + output = formatter.get_formatted_output(events) + click.echo_via_pager(output) + else: + formatter.echo_formatted_list(events) + + @legal_hold.group(cls=OrderedGroup) @sdk_options(hidden=True) def bulk(state): @@ -230,6 +278,14 @@ def _get_all_active_matters(sdk): return matters +def _get_all_events(sdk, legal_hold_uid, begin_date, end_date): + events_generator = sdk.legalhold.get_all_events( + legal_hold_uid, begin_date, end_date + ) + events = [event for page in events_generator for event in page["legalHoldEvents"]] + return events + + def _print_matter_members(username_list, member_type="active"): if username_list: echo("\n{} matter members:\n".format(member_type.capitalize())) diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 4c1124f56..13f6bc2cf 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -1,3 +1,5 @@ +import datetime + import pytest from py42.exceptions import Py42BadRequestError from py42.response import Py42Response @@ -6,9 +8,9 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.legal_hold import _check_matter_is_accessible +from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.main import cli - _NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) TEST_MATTER_ID = "99999" TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" @@ -18,6 +20,8 @@ INACTIVE_TEST_USERNAME = "inactive@example.com" INACTIVE_TEST_USER_ID = "54321" TEST_POLICY_UID = "66666" +_CREATE_EVENT_ID = "564564654566" +_MEMBERSHIP_EVENT_ID = "74533457745" TEST_PRESERVATION_POLICY_UID = "1010101010" MATTER_RESPONSE = """ { @@ -169,6 +173,42 @@ ] } """ +TEST_EVENT_PAGE = { + "legalHoldEvents": [ + { + "eventUid": "564564654566", + "eventType": "HoldCreated", + "eventDate": "2015-05-16T15:07:44.820Z", + "legalHoldUid": "88888", + "actorUserUid": "12345", + "actorUsername": "holdcreator@example.com", + "actorFirstName": "john", + "actorLastName": "doe", + "actorUserExtRef": None, + "actorEmail": "holdcreatorr@example.com", + }, + { + "eventUid": "74533457745", + "eventType": "MembershipCreated", + "eventDate": "2019-05-17T15:07:44.820Z", + "legalHoldUid": "88888", + "legalHoldMembershipUid": "645576514441664433", + "custodianUserUid": "12345", + "custodianUsername": "kim.jones@code42.com", + "custodianFirstName": "kim", + "custodianLastName": "jones", + "custodianUserExtRef": None, + "custodianEmail": "user@example.com", + "actorUserUid": "1234512345", + "actorUsername": "creator@example.com", + "actorFirstName": "john", + "actorLastName": "doe", + "actorUserExtRef": None, + "actorEmail": "user@example.com", + }, + ] +} +EMPTY_EVENTS_RESPONSE = """{"legalHoldEvents": []}""" EMPTY_MATTERS_RESPONSE = """{"legalHolds": []}""" ALL_MATTERS_RESPONSE = """{{"legalHolds": [{}]}}""".format(MATTER_RESPONSE) LEGAL_HOLD_COMMAND = "legal-hold" @@ -212,6 +252,15 @@ def active_and_inactive_legal_hold_memberships_response(mocker): return [_create_py42_response(mocker, ALL_ACTIVE_AND_INACTIVE_CUSTODIANS_RESPONSE)] +@pytest.fixture +def empty_events_response(mocker): + return _create_py42_response(mocker, EMPTY_EVENTS_RESPONSE) + + +def events_list_generator(): + yield TEST_EVENT_PAGE + + @pytest.fixture def get_user_id_success(cli_state): cli_state.sdk.users.get_by_username.return_value = { @@ -246,6 +295,11 @@ def check_matter_accessible_failure(cli_state, custom_error): ) +@pytest.fixture +def get_all_events_success(cli_state): + cli_state.sdk.legalhold.get_all_events.return_value = events_list_generator() + + @pytest.fixture def user_already_added_response(mocker): mock_response = mocker.MagicMock(spec=Response) @@ -575,6 +629,44 @@ def test_list_with_csv_format_returns_no_response_when_response_is_empty( assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output +def test_search_events_shows_events_that_respect_type_filters( + runner, cli_state, get_all_events_success +): + + result = runner.invoke( + cli, + ["legal-hold", "search-events", "--event-type", "HoldCreated"], + obj=cli_state, + ) + + assert _CREATE_EVENT_ID in result.output + assert _MEMBERSHIP_EVENT_ID not in result.output + + +def test_search_events_with_csv_returns_no_events_when_response_is_empty( + runner, cli_state, get_all_events_success, empty_events_response +): + cli_state.sdk.legalhold.get_all_events.return_value = empty_events_response + result = runner.invoke(cli, ["legal-hold", "events", "-f", "csv"], obj=cli_state) + + assert ( + "actorEmail,actorUsername,actorLastName,actorUserUid,actorUserExtRef" + not in result.output + ) + + +def test_search_events_is_called_with_expected_begin_timestamp(runner, cli_state): + expected_timestamp = convert_datetime_to_timestamp( + datetime.datetime.strptime("2017-01-01", "%Y-%m-%d") + ) + command = ["legal-hold", "search-events", "--begin", "2017-01-01T00:00:00"] + runner.invoke(cli, command, obj=cli_state) + + cli_state.sdk.legalhold.get_all_events.assert_called_once_with( + None, expected_timestamp, None + ) + + @pytest.mark.parametrize( "command, error_msg", [