Skip to content

Users command: option to report on legal hold membership #290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 27, 2021
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The intended audience of this file is for py42 consumers -- as such, changes that don't affect
how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here.

## Unreleased

### Added

- New command 'code42 users list --include-legal-hold-membership' to print the legal hold matter name and ID for any user on legal hold.

## 1.9.0 - 2021-08-19

### Added
Expand Down
51 changes: 50 additions & 1 deletion src/code42cli/cmds/users.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import click
from pandas import DataFrame
from pandas import json_normalize

from code42cli.bulk import generate_template_cmd_factory
from code42cli.bulk import run_bulk_process
Expand Down Expand Up @@ -62,9 +63,17 @@ def username_option(help, required=False):
@role_name_option("Limit results to only users having the specified role.")
@active_option
@inactive_option
@click.option(
"--include-legal-hold-membership",
default=False,
is_flag=True,
help="Include legal hold membership in output.",
)
@format_option
@sdk_options()
def list_users(state, org_uid, role_name, active, inactive, format):
def list_users(
state, org_uid, role_name, active, inactive, include_legal_hold_membership, format
):
"""List users in your Code42 environment."""
if inactive:
active = False
Expand All @@ -75,6 +84,8 @@ def list_users(state, org_uid, role_name, active, inactive, format):
else None
)
df = _get_users_dataframe(state.sdk, columns, org_uid, role_id, active)
if include_legal_hold_membership:
df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df)
if df.empty:
click.echo("No results found.")
else:
Expand Down Expand Up @@ -302,6 +313,44 @@ def _get_users_dataframe(sdk, columns, org_uid, role_id, active):
return DataFrame.from_records(users_list, columns=columns)


def _add_legal_hold_membership_to_user_dataframe(sdk, df):
columns = ["legalHold.legalHoldUid", "legalHold.name", "user.userUid"]

custodians = list(_get_all_active_hold_memberships(sdk))
if len(custodians) == 0:
return df

legal_hold_member_dataframe = (
json_normalize(custodians)[columns]
.groupby(["user.userUid"])
.agg(",".join)
.rename(
{
"legalHold.legalHoldUid": "legalHoldUid",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to be able to pass in user friendly keys for Table format headers for when using DataFrameOutputFormatter, as that was the norm before.

Table format should use label-esque keys while all the other ones should use raw API values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strange format of "legalHold.legalHoldUid" does not (should not) come through the final output. This syntax gets introduced with the json_normalize command, which flattens the json structure into nested columns names.

"legalHold.name": "legalHoldName",
},
axis=1,
)
)
df = df.merge(
legal_hold_member_dataframe,
how="left",
left_on="userUid",
right_on="user.userUid",
)

return df


def _get_all_active_hold_memberships(sdk):
for page in sdk.legalhold.get_all_matters(active=True):
for matter in page["legalHolds"]:
for _page in sdk.legalhold.get_all_matter_custodians(
legal_hold_uid=matter["legalHoldUid"], active=True
):
yield from _page["legalHoldMemberships"]


def _update_user(
sdk,
user_id,
Expand Down
141 changes: 136 additions & 5 deletions tests/cmds/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,42 @@
}
]
}
TEST_MATTER_RESPONSE = {
"legalHolds": [
{"legalHoldUid": "123456789", "name": "Legal Hold #1", "active": True},
{"legalHoldUid": "987654321", "name": "Legal Hold #2", "active": True},
]
}
TEST_CUSTODIANS_RESPONSE = {
"legalHoldMemberships": [
{
"legalHoldMembershipUid": "99999",
"active": True,
"creationDate": "2020-07-16T08:50:23.405Z",
"legalHold": {"legalHoldUid": "123456789", "name": "Legal Hold #1"},
"user": {
"userUid": "911162111513111325",
"username": "[email protected]",
"email": "[email protected]",
"userExtRef": None,
},
},
{
"legalHoldMembershipUid": "11111",
"active": True,
"creationDate": "2020-07-16T08:50:23.405Z",
"legalHold": {"legalHoldUid": "987654321", "name": "Legal Hold #2"},
"user": {
"userUid": "911162111513111325",
"username": "[email protected]",
"email": "[email protected]",
"userExtRef": None,
},
},
]
}
TEST_EMPTY_CUSTODIANS_RESPONSE = {"legalHoldMemberships": []}
TEST_EMPTY_MATTERS_RESPONSE = {"legalHolds": []}
TEST_EMPTY_USERS_RESPONSE = {"users": []}
TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"]
TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"]
Expand Down Expand Up @@ -68,10 +104,6 @@
}


def get_all_users_generator():
yield TEST_USERS_RESPONSE


@pytest.fixture
def update_user_response(mocker):
return create_mock_response(mocker)
Expand Down Expand Up @@ -103,7 +135,10 @@ def get_org_success(cli_state, get_org_response):


@pytest.fixture
def get_all_users_success(cli_state):
def get_all_users_success(mocker, cli_state):
def get_all_users_generator():
yield create_mock_response(mocker, data=TEST_USERS_RESPONSE)

cli_state.sdk.users.get_all.return_value = get_all_users_generator()


Expand All @@ -120,6 +155,42 @@ def get_user_id_failure(mocker, cli_state):
)


@pytest.fixture
def get_custodian_failure(mocker, cli_state):
def empty_custodian_list_generator():
yield TEST_EMPTY_CUSTODIANS_RESPONSE

cli_state.sdk.legalhold.get_all_matter_custodians.return_value = (
empty_custodian_list_generator()
)


@pytest.fixture
def get_matter_failure(cli_state):
def empty_matter_list_generator():
yield TEST_EMPTY_MATTERS_RESPONSE

cli_state.sdk.legalhold.get_all_matters.return_value = empty_matter_list_generator()


@pytest.fixture
def get_all_matter_success(mocker, cli_state):
def matter_list_generator():
yield create_mock_response(mocker, data=TEST_MATTER_RESPONSE)

cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator()


@pytest.fixture
def get_all_custodian_success(mocker, cli_state):
def custodian_list_generator():
yield create_mock_response(mocker, data=TEST_CUSTODIANS_RESPONSE)

cli_state.sdk.legalhold.get_all_matter_custodians.return_value = (
custodian_list_generator()
)


@pytest.fixture
def get_available_roles_success(cli_state, get_available_roles_response):
cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response
Expand Down Expand Up @@ -241,6 +312,66 @@ def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_
)


def test_list_legal_hold_flag_reports_none_for_users_not_on_legal_hold(
runner,
cli_state,
get_all_users_success,
get_custodian_failure,
get_all_matter_success,
):
result = runner.invoke(
cli,
["users", "list", "--include-legal-hold-membership", "-f", "CSV"],
obj=cli_state,
)

assert "Legal Hold #1,Legal Hold #2" not in result.output
assert "123456789,987654321" not in result.output
assert "legalHoldUid" not in result.output
assert "[email protected]" in result.output


def test_list_legal_hold_flag_reports_none_if_no_matters_exist(
runner, cli_state, get_all_users_success, get_custodian_failure, get_matter_failure
):
result = runner.invoke(
cli, ["users", "list", "--include-legal-hold-membership"], obj=cli_state
)

assert "Legal Hold #1,Legal Hold #2" not in result.output
assert "123456789,987654321" not in result.output
assert "legalHoldUid" not in result.output
assert "[email protected]" in result.output


def test_list_legal_hold_values_not_included_for_legal_hold_user_if_legal_hold_flag_not_passed(
runner,
cli_state,
get_all_users_success,
get_all_custodian_success,
get_all_matter_success,
):
result = runner.invoke(cli, ["users", "list"], obj=cli_state)
assert "Legal Hold #1,Legal Hold #2" not in result.output
assert "123456789,987654321" not in result.output
assert "[email protected]" in result.output


def test_list_include_legal_hold_membership_merges_in_and_concats_legal_hold_info(
runner,
cli_state,
get_all_users_success,
get_all_custodian_success,
get_all_matter_success,
):
result = runner.invoke(
cli, ["users", "list", "--include-legal-hold-membership"], obj=cli_state
)

assert "Legal Hold #1,Legal Hold #2" in result.output
assert "123456789,987654321" in result.output


Copy link
Contributor

@antazoey antazoey Jul 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we get tests for the following two cases:

1.) A user is not on any legal hold but the --include-legal-hold-membership flag was passed in (keys should be there but their values should be None)
2.) A user is on a legal hold but the flag --include-legal-hold-membership was not passed in (keys and values should not be present in the output)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added these tests. The code didn't handle cases where 0 custodians were returned. Thanks for suggesting this test case.

I also added a test for cases where the flag--include-legal-hold-membership and no matters were returned.

def test_add_user_role_adds(
runner, cli_state, get_user_id_success, get_available_roles_success
):
Expand Down