Skip to content

Commit 7b240f8

Browse files
authored
Feat: convert user to alumni (#28)
2 parents f0c5bf8 + 18a72d0 commit 7b240f8

File tree

15 files changed

+430
-105
lines changed

15 files changed

+430
-105
lines changed

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,17 @@ The following commands are available to work with users in the Compiler domain:
114114
115115
```bash
116116
$ compiler-admin user -h
117-
usage: compiler-admin user [-h] {create,convert,delete,offboard,reset-password,restore,signout} ...
117+
usage: compiler-admin user [-h] {alumni,create,convert,delete,offboard,reset,restore,signout} ...
118118
119119
positional arguments:
120-
{create,convert,delete,offboard,reset-password,restore,signout}
120+
{alumni,create,convert,delete,offboard,reset,restore,signout}
121121
The user command to run.
122+
alumni Convert a user account to a Compiler alumni.
122123
create Create a new user in the Compiler domain.
123124
convert Convert a user account to a new type.
124125
delete Delete a user account.
125126
offboard Offboard a user account.
126-
reset-password Reset a user's password to a randomly generated string.
127+
reset Reset a user's password to a randomly generated string.
127128
restore Restore an email backup from a prior offboarding.
128129
signout Signs a user out from all active sessions.
129130
@@ -151,15 +152,17 @@ Additional options are passed through to GAM, see more about [GAM user create](h
151152
152153
```bash
153154
$ compiler-admin user convert -h
154-
usage: compiler-admin user convert [-h] username {contractor,partner,staff}
155+
usage: compiler-admin user convert [-h] [--force] [--notify NOTIFY] username {alumni,contractor,partner,staff}
155156
156157
positional arguments:
157158
username A Compiler user account name, sans domain.
158-
{contractor,partner,staff}
159+
{alumni,contractor,partner,staff}
159160
Target account type for this conversion.
160161
161162
options:
162163
-h, --help show this help message and exit
164+
--force Don't ask for confirmation before conversion.
165+
--notify NOTIFY An email address to send the alumni's new password.
163166
```
164167
165168
### Offboarding a user

compiler_admin/commands/user/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from argparse import Namespace
22

3+
from compiler_admin.commands.user.alumni import alumni # noqa: F401
34
from compiler_admin.commands.user.create import create # noqa: F401
45
from compiler_admin.commands.user.convert import convert # noqa: F401
56
from compiler_admin.commands.user.delete import delete # noqa: F401
67
from compiler_admin.commands.user.offboard import offboard # noqa: F401
7-
from compiler_admin.commands.user.reset_password import reset_password # noqa: F401
8+
from compiler_admin.commands.user.reset import reset # noqa: F401
89
from compiler_admin.commands.user.restore import restore # noqa: F401
910
from compiler_admin.commands.user.signout import signout # noqa: F401
1011

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from argparse import Namespace
2+
3+
from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS
4+
from compiler_admin.commands.user.reset import reset
5+
from compiler_admin.services.google import (
6+
OU_ALUMNI,
7+
CallGAMCommand,
8+
move_user_ou,
9+
user_account_name,
10+
user_exists,
11+
)
12+
13+
14+
def alumni(args: Namespace) -> int:
15+
"""Convert a user to a Compiler alumni.
16+
17+
Optionally notify an email address with the new randomly generated password.
18+
19+
Args:
20+
username (str): the user account to convert.
21+
22+
notify (str): an email address to send the new password notification.
23+
Returns:
24+
A value indicating if the operation succeeded or failed.
25+
"""
26+
if not hasattr(args, "username"):
27+
raise ValueError("username is required")
28+
29+
account = user_account_name(args.username)
30+
31+
if not user_exists(account):
32+
print(f"User does not exist: {account}")
33+
return RESULT_FAILURE
34+
35+
if getattr(args, "force", False) is False:
36+
cont = input(f"Convert account to alumni for {account}? (Y/n)")
37+
if not cont.lower().startswith("y"):
38+
print("Aborting conversion.")
39+
return RESULT_SUCCESS
40+
41+
res = RESULT_SUCCESS
42+
43+
print("Removing from groups")
44+
res += CallGAMCommand(("user", account, "delete", "groups"))
45+
46+
print(f"Moving to OU: {OU_ALUMNI}")
47+
res += move_user_ou(account, OU_ALUMNI)
48+
49+
# reset password, sign out
50+
res += reset(args)
51+
52+
print("Clearing user profile info")
53+
for prop in ["address", "location", "otheremail", "phone"]:
54+
command = ("update", "user", account, prop, "clear")
55+
res += CallGAMCommand(command)
56+
57+
print("Resetting recovery email")
58+
recovery = getattr(args, "recovery_email", "")
59+
command = ("update", "user", account, "recoveryemail", recovery)
60+
res += CallGAMCommand(command)
61+
62+
print("Resetting recovery phone")
63+
recovery = getattr(args, "recovery_phone", "")
64+
command = ("update", "user", account, "recoveryphone", recovery)
65+
res += CallGAMCommand(command)
66+
67+
print("Turning off 2FA")
68+
command = ("user", account, "turnoff2sv")
69+
res += CallGAMCommand(command)
70+
71+
return res

compiler_admin/commands/user/convert.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from argparse import Namespace
22

33
from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE
4+
from compiler_admin.commands.user.alumni import alumni
45
from compiler_admin.services.google import (
56
GROUP_PARTNERS,
67
GROUP_STAFF,
8+
OU_ALUMNI,
79
OU_CONTRACTORS,
810
OU_PARTNERS,
911
OU_STAFF,
@@ -17,7 +19,7 @@
1719
)
1820

1921

20-
ACCOUNT_TYPE_OU = {"contractor": OU_CONTRACTORS, "partner": OU_PARTNERS, "staff": OU_STAFF}
22+
ACCOUNT_TYPE_OU = {"alumni": OU_ALUMNI, "contractor": OU_CONTRACTORS, "partner": OU_PARTNERS, "staff": OU_STAFF}
2123

2224

2325
def convert(args: Namespace) -> int:
@@ -48,7 +50,10 @@ def convert(args: Namespace) -> int:
4850
print(f"User exists, converting to: {account_type} for {account}")
4951
res = RESULT_SUCCESS
5052

51-
if account_type == "contractor":
53+
if account_type == "alumni":
54+
res = alumni(args)
55+
56+
elif account_type == "contractor":
5257
if user_is_partner(account):
5358
res += remove_user_from_group(account, GROUP_PARTNERS)
5459
res += remove_user_from_group(account, GROUP_STAFF)

compiler_admin/commands/user/offboard.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from tempfile import NamedTemporaryFile
33

44
from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE
5+
from compiler_admin.commands.user.alumni import alumni
56
from compiler_admin.commands.user.delete import delete
6-
from compiler_admin.commands.user.signout import signout
77
from compiler_admin.services.google import (
88
USER_ARCHIVE,
99
CallGAMCommand,
@@ -48,8 +48,7 @@ def offboard(args: Namespace) -> int:
4848
print(f"User exists, offboarding: {account}")
4949
res = RESULT_SUCCESS
5050

51-
print("Removing from groups")
52-
res += CallGAMCommand(("user", account, "delete", "groups"))
51+
res += alumni(args)
5352

5453
print("Backing up email")
5554
res += CallGYBCommand(("--service-account", "--email", account, "--action", "backup"))
@@ -67,8 +66,6 @@ def offboard(args: Namespace) -> int:
6766

6867
res += CallGAMCommand(("user", account, "deprovision", "popimap"))
6968

70-
res += signout(args)
71-
7269
res += delete(args)
7370

7471
if alias_account:

compiler_admin/commands/user/reset_password.py renamed to compiler_admin/commands/user/reset.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from compiler_admin.services.google import USER_HELLO, CallGAMCommand, user_account_name, user_exists
66

77

8-
def reset_password(args: Namespace) -> int:
8+
def reset(args: Namespace) -> int:
99
"""Reset a user's password.
1010
1111
Optionally notify an email address with the new randomly generated password.
@@ -26,6 +26,12 @@ def reset_password(args: Namespace) -> int:
2626
print(f"User does not exist: {account}")
2727
return RESULT_FAILURE
2828

29+
if getattr(args, "force", False) is False:
30+
cont = input(f"Reset password for {account}? (Y/n)")
31+
if not cont.lower().startswith("y"):
32+
print("Aborting password reset.")
33+
return RESULT_SUCCESS
34+
2935
command = ("update", "user", account, "password", "random", "changepassword")
3036

3137
notify = getattr(args, "notify", None)

compiler_admin/main.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,21 @@ def setup_user_command(cmd_parsers: _SubParsersAction):
133133
user_cmd.set_defaults(func=user)
134134
user_subcmds = add_sub_cmd_parser(user_cmd, help="The user command to run.")
135135

136+
user_alumni = add_sub_cmd_with_username_arg(user_subcmds, "alumni", help="Convert a user account to a Compiler alumni.")
137+
user_alumni.add_argument("--notify", help="An email address to send the alumni's new password.")
138+
user_alumni.add_argument(
139+
"--force", action="store_true", default=False, help="Don't ask for confirmation before conversion."
140+
)
141+
136142
user_create = add_sub_cmd_with_username_arg(user_subcmds, "create", help="Create a new user in the Compiler domain.")
137143
user_create.add_argument("--notify", help="An email address to send the newly created account info.")
138144

139145
user_convert = add_sub_cmd_with_username_arg(user_subcmds, "convert", help="Convert a user account to a new type.")
140146
user_convert.add_argument("account_type", choices=ACCOUNT_TYPE_OU.keys(), help="Target account type for this conversion.")
147+
user_convert.add_argument(
148+
"--force", action="store_true", default=False, help="Don't ask for confirmation before conversion."
149+
)
150+
user_convert.add_argument("--notify", help="An email address to send the alumni's new password.")
141151

142152
user_delete = add_sub_cmd_with_username_arg(user_subcmds, "delete", help="Delete a user account.")
143153
user_delete.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before deletion.")
@@ -149,8 +159,9 @@ def setup_user_command(cmd_parsers: _SubParsersAction):
149159
)
150160

151161
user_reset = add_sub_cmd_with_username_arg(
152-
user_subcmds, "reset-password", help="Reset a user's password to a randomly generated string."
162+
user_subcmds, "reset", help="Reset a user's password to a randomly generated string."
153163
)
164+
user_reset.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before reset.")
154165
user_reset.add_argument("--notify", help="An email address to send the newly generated password.")
155166

156167
add_sub_cmd_with_username_arg(user_subcmds, "restore", help="Restore an email backup from a prior offboarding.")

compiler_admin/services/google.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DOMAIN = "compiler.la"
1818

1919
# Org structure
20+
OU_ALUMNI = "alumni"
2021
OU_CONTRACTORS = "contractors"
2122
OU_STAFF = "staff"
2223
OU_PARTNERS = f"{OU_STAFF}/partners"
@@ -77,7 +78,7 @@ def add_user_to_group(username: str, group: str) -> int:
7778

7879

7980
def move_user_ou(username: str, ou: str) -> int:
80-
"""Remove a user from a group."""
81+
"""Move a user into a new OU."""
8182
return CallGAMCommand(("update", "ou", ou, "move", username))
8283

8384

tests/commands/user/test_alumni.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from argparse import Namespace
2+
import pytest
3+
4+
from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS
5+
from compiler_admin.commands.user.alumni import alumni, __name__ as MODULE
6+
from compiler_admin.services.google import OU_ALUMNI
7+
8+
9+
@pytest.fixture
10+
def mock_commands_reset(mock_commands_reset):
11+
return mock_commands_reset(MODULE)
12+
13+
14+
@pytest.fixture
15+
def mock_input_yes(mock_input):
16+
fix = mock_input(MODULE)
17+
fix.return_value = "y"
18+
return fix
19+
20+
21+
@pytest.fixture
22+
def mock_input_no(mock_input):
23+
fix = mock_input(MODULE)
24+
fix.return_value = "n"
25+
return fix
26+
27+
28+
@pytest.fixture
29+
def mock_google_CallGAMCommand(mock_google_CallGAMCommand):
30+
return mock_google_CallGAMCommand(MODULE)
31+
32+
33+
@pytest.fixture
34+
def mock_google_move_user_ou(mock_google_move_user_ou):
35+
return mock_google_move_user_ou(MODULE)
36+
37+
38+
@pytest.fixture
39+
def mock_google_remove_user_from_group(mock_google_remove_user_from_group):
40+
return mock_google_remove_user_from_group(MODULE)
41+
42+
43+
@pytest.fixture
44+
def mock_google_user_exists(mock_google_user_exists):
45+
return mock_google_user_exists(MODULE)
46+
47+
48+
def test_alumni_username_required():
49+
args = Namespace()
50+
51+
with pytest.raises(ValueError, match="username is required"):
52+
alumni(args)
53+
54+
55+
def test_alumni_user_does_not_exists(mock_google_user_exists, mock_google_CallGAMCommand):
56+
mock_google_user_exists.return_value = False
57+
58+
args = Namespace(username="username")
59+
res = alumni(args)
60+
61+
assert res == RESULT_FAILURE
62+
mock_google_CallGAMCommand.assert_not_called()
63+
64+
65+
@pytest.mark.usefixtures("mock_input_yes")
66+
def test_alumni_confirm_yes(
67+
mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou
68+
):
69+
mock_google_user_exists.return_value = True
70+
71+
args = Namespace(username="username", force=False)
72+
res = alumni(args)
73+
74+
assert res == RESULT_SUCCESS
75+
mock_google_CallGAMCommand.assert_called()
76+
mock_google_move_user_ou.assert_called_once_with("[email protected]", OU_ALUMNI)
77+
mock_commands_reset.assert_called_once_with(args)
78+
79+
80+
@pytest.mark.usefixtures("mock_input_no")
81+
def test_alumni_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou):
82+
mock_google_user_exists.return_value = True
83+
84+
args = Namespace(username="username", force=False)
85+
res = alumni(args)
86+
87+
assert res == RESULT_SUCCESS
88+
mock_google_CallGAMCommand.assert_not_called()
89+
mock_commands_reset.assert_not_called()
90+
mock_google_move_user_ou.assert_not_called()
91+
92+
93+
def test_alumni_force(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou):
94+
mock_google_user_exists.return_value = True
95+
96+
args = Namespace(username="username", force=True)
97+
res = alumni(args)
98+
99+
assert res == RESULT_SUCCESS
100+
mock_google_CallGAMCommand.assert_called()
101+
mock_google_move_user_ou.assert_called_once_with("[email protected]", OU_ALUMNI)
102+
mock_commands_reset.assert_called_once_with(args)

tests/commands/user/test_convert.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from compiler_admin.commands.user.convert import convert, __name__ as MODULE
66

77

8+
@pytest.fixture
9+
def mock_commands_alumni(mock_commands_alumni):
10+
return mock_commands_alumni(MODULE)
11+
12+
813
@pytest.fixture
914
def mock_google_user_exists(mock_google_user_exists):
1015
return mock_google_user_exists(MODULE)
@@ -98,6 +103,16 @@ def test_convert_user_exists_bad_account_type(mock_google_move_user_ou):
98103
assert mock_google_move_user_ou.call_count == 0
99104

100105

106+
@pytest.mark.usefixtures("mock_google_user_exists_true")
107+
def test_convert_alumni(mock_commands_alumni, mock_google_move_user_ou):
108+
args = Namespace(username="username", account_type="alumni")
109+
res = convert(args)
110+
111+
assert res == RESULT_SUCCESS
112+
mock_commands_alumni.assert_called_once_with(args)
113+
mock_google_move_user_ou.assert_called_once()
114+
115+
101116
@pytest.mark.usefixtures(
102117
"mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_false"
103118
)

0 commit comments

Comments
 (0)