From 651988bb086c37c24c24b501e28fc6b2945dac02 Mon Sep 17 00:00:00 2001 From: Rio Date: Wed, 8 Sep 2021 09:32:34 -0400 Subject: [PATCH] Positions CLI (#11) Closes #9 Co-authored-by: Rio6 Co-authored-by: Rio Liu Co-authored-by: Max Erenberg Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/11 Co-authored-by: Rio Co-committed-by: Rio --- ceo/__main__.py | 7 +++-- ceo/cli/entrypoint.py | 2 ++ ceo/cli/positions.py | 44 ++++++++++++++++++++++++++ ceo/operation_strings.py | 3 ++ ceo/utils.py | 2 ++ ceod/api/positions.py | 6 ++++ tests/ceo/cli/test_positions.py | 55 +++++++++++++++++++++++++++++++++ tests/ceo_dev.ini | 5 +++ tests/conftest.py | 20 ++++++------ 9 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 ceo/cli/positions.py create mode 100644 tests/ceo/cli/test_positions.py diff --git a/ceo/__main__.py b/ceo/__main__.py index af29d1c..fbc4473 100644 --- a/ceo/__main__.py +++ b/ceo/__main__.py @@ -13,6 +13,9 @@ from ceo_common.model import Config, HTTPClient def register_services(): + # Using base component directly so events get triggered + baseComponent = component.getGlobalSiteManager() + # Config # This is a hack to determine if we're in the dev env or not if socket.getfqdn().endswith('.csclub.internal'): @@ -21,11 +24,11 @@ def register_services(): else: config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') cfg = Config(config_file) - component.provideUtility(cfg, IConfig) + baseComponent.registerUtility(cfg, IConfig) # HTTPService http_client = HTTPClient() - component.provideUtility(http_client, IHTTPClient) + baseComponent.registerUtility(http_client, IHTTPClient) def main(): diff --git a/ceo/cli/entrypoint.py b/ceo/cli/entrypoint.py index 1221144..e686133 100644 --- a/ceo/cli/entrypoint.py +++ b/ceo/cli/entrypoint.py @@ -2,6 +2,7 @@ import click from .members import members from .groups import groups +from .positions import positions from .updateprograms import updateprograms @@ -12,4 +13,5 @@ def cli(): cli.add_command(members) cli.add_command(groups) +cli.add_command(positions) cli.add_command(updateprograms) diff --git a/ceo/cli/positions.py b/ceo/cli/positions.py new file mode 100644 index 0000000..733e8c0 --- /dev/null +++ b/ceo/cli/positions.py @@ -0,0 +1,44 @@ +import click +from zope import component + +from ..utils import http_get, http_post +from .utils import handle_sync_response, handle_stream_response, print_colon_kv +from ceo_common.interfaces import IConfig +from ceod.transactions.members import UpdateMemberPositionsTransaction + + +@click.group(short_help='List or change exec positions') +def positions(): + update_commands() + + +@positions.command(short_help='Get current positions') +def get(): + resp = http_get('/api/positions') + result = handle_sync_response(resp) + print_colon_kv(result.items()) + + +@positions.command(short_help='Update positions') +def set(**kwargs): + body = {k.replace('_', '-'): v for k, v in kwargs.items()} + print_body = {k: v or '' for k, v in body.items()} + click.echo('The positions will be updated:') + print_colon_kv(print_body.items()) + click.confirm('Do you want to continue?', abort=True) + + resp = http_post('/api/positions', json=body) + handle_stream_response(resp, UpdateMemberPositionsTransaction.operations) + + +# Provides dynamic parameters for `set' command using config file +def update_commands(): + global set + + cfg = component.getUtility(IConfig) + avail = cfg.get('positions_available') + required = cfg.get('positions_required') + + for pos in avail: + r = pos in required + set = click.option(f'--{pos}', metavar='USERNAME', required=r, prompt=r)(set) diff --git a/ceo/operation_strings.py b/ceo/operation_strings.py index 8ff44f7..2d874e4 100644 --- a/ceo/operation_strings.py +++ b/ceo/operation_strings.py @@ -24,4 +24,7 @@ descriptions = { 'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups', 'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists', 'remove_sudo_role': 'Remove sudo role from LDAP', + 'update_positions_ldap': 'Update positions in LDAP', + 'update_exec_group_ldap': 'Update executive group in LDAP', + 'subscribe_to_mailing_lists': 'Subscribe to mailing lists', } diff --git a/ceo/utils.py b/ceo/utils.py index b23604f..28e4a13 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -69,6 +69,8 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]: key1000: val3 val4 """ + if not pairs: + return [] lines = [] maxlen = max(len(key) for key, val in pairs) for key, val in pairs: diff --git a/ceod/api/positions.py b/ceod/api/positions.py index 9d9f4c4..a194565 100644 --- a/ceod/api/positions.py +++ b/ceod/api/positions.py @@ -29,6 +29,12 @@ def update_positions(): required = cfg.get('positions_required') available = cfg.get('positions_available') + # remove falsy values + body = { + positions: username for positions, username in body.items() + if username + } + for position in body.keys(): if position not in available: return { diff --git a/tests/ceo/cli/test_positions.py b/tests/ceo/cli/test_positions.py new file mode 100644 index 0000000..c7ae111 --- /dev/null +++ b/tests/ceo/cli/test_positions.py @@ -0,0 +1,55 @@ +from click.testing import CliRunner +from ceo.cli import cli + + +def test_positions(cli_setup): + runner = CliRunner() + + # Setup test data + for i in range(5): + runner.invoke(cli, ['members', 'add', f'test_{i}', '--cn', f'Test {i}', '--program', 'Math', '--terms', '1'], input='y\n') + runner.invoke(cli, ['groups', 'add', 'exec', '--description', 'Test Group'], input='y\n') + + result = runner.invoke(cli, [ + 'positions', 'set', + '--president', 'test_0', + '--vice-president', 'test_1', + '--sysadmin', 'test_2', + '--secretary', 'test_3', + '--webmaster', 'test_4', + ], input='y\n') + + assert result.exit_code == 0 + assert result.output == ''' +The positions will be updated: +president: test_0 +vice-president: test_1 +sysadmin: test_2 +secretary: test_3 +webmaster: test_4 +treasurer: +cro: +librarian: +imapd: +offsck: +Do you want to continue? [y/N]: y +Update positions in LDAP... Done +Update executive group in LDAP... Done +Subscribe to mailing lists... Done +Transaction successfully completed. +'''[1:] # noqa: W291 + + result = runner.invoke(cli, ['positions', 'get']) + assert result.exit_code == 0 + assert result.output == ''' +president: test_0 +secretary: test_3 +sysadmin: test_2 +vice-president: test_1 +webmaster: test_4 +'''[1:] + + # Cleanup test data + for i in range(5): + runner.invoke(cli, ['members', 'delete', f'test_{i}'], input='y\n') + runner.invoke(cli, ['groups', 'delete', 'exec'], input='y\n') diff --git a/tests/ceo_dev.ini b/tests/ceo_dev.ini index e74895e..978f6f5 100644 --- a/tests/ceo_dev.ini +++ b/tests/ceo_dev.ini @@ -7,3 +7,8 @@ uw_domain = uwaterloo.internal admin_host = phosphoric-acid use_https = false port = 9987 + +[positions] +required = president,vice-president,sysadmin +available = president,vice-president,treasurer,secretary, + sysadmin,cro,librarian,imapd,webmaster,offsck diff --git a/tests/conftest.py b/tests/conftest.py index 61547b6..7d4ce62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def cfg(_drone_hostname_mock): with importlib.resources.path('tests', 'ceod_test_local.ini') as p: config_file = p.__fspath__() _cfg = Config(config_file) - component.provideUtility(_cfg, IConfig) + component.getGlobalSiteManager().registerUtility(_cfg, IConfig) return _cfg @@ -75,7 +75,7 @@ def krb_srv(cfg): else: principal = 'ceod/' + socket.getfqdn() krb = KerberosService(principal) - component.provideUtility(krb, IKerberosService) + component.getGlobalSiteManager().registerUtility(krb, IKerberosService) delete_test_princs(krb) yield krb @@ -160,7 +160,7 @@ def ldap_srv_session(cfg, krb_srv, ldap_conn): conn.add(base_dn, 'organizationalUnit') _ldap_srv = LDAPService() - component.provideUtility(_ldap_srv, ILDAPService) + component.getGlobalSiteManager().registerUtility(_ldap_srv, ILDAPService) yield _ldap_srv @@ -180,7 +180,7 @@ def ldap_srv(ldap_srv_session, g_admin_ctx): @pytest.fixture(scope='session') def file_srv(cfg): _file_srv = FileService() - component.provideUtility(_file_srv, IFileService) + component.getGlobalSiteManager().registerUtility(_file_srv, IFileService) members_home = cfg.get('members_home') clubs_home = cfg.get('clubs_home') @@ -194,7 +194,7 @@ def file_srv(cfg): @pytest.fixture(scope='session') def http_client(cfg): _client = HTTPClient() - component.provideUtility(_client, IHTTPClient) + component.getGlobalSiteManager().registerUtility(_client, IHTTPClient) return _client @@ -210,7 +210,7 @@ def mock_mailman_server(): def mailman_srv(mock_mailman_server, cfg, http_client): # TODO: test the RemoteMailmanService as well mailman = MailmanService() - component.provideUtility(mailman, IMailmanService) + component.getGlobalSiteManager().registerUtility(mailman, IMailmanService) return mailman @@ -223,7 +223,7 @@ def uwldap_srv(cfg, ldap_conn): conn.add(base_dn, 'organizationalUnit') _uwldap_srv = UWLDAPService() - component.provideUtility(_uwldap_srv, IUWLDAPService) + component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService) yield _uwldap_srv delete_subtree(conn, base_dn) @@ -240,21 +240,21 @@ def mock_mail_server(): @pytest.fixture(scope='session') def mail_srv(cfg, mock_mail_server): _mail_srv = MailService() - component.provideUtility(_mail_srv, IMailService) + component.getGlobalSiteManager().registerUtility(_mail_srv, IMailService) return _mail_srv @pytest.fixture(scope='session') def mysql_srv(cfg): mysql_srv = MySQLService() - component.provideUtility(mysql_srv, IDatabaseService, 'mysql') + component.getGlobalSiteManager().registerUtility(mysql_srv, IDatabaseService, 'mysql') return mysql_srv @pytest.fixture(scope='session') def postgresql_srv(cfg): psql_srv = PostgreSQLService() - component.provideUtility(psql_srv, IDatabaseService, 'postgresql') + component.getGlobalSiteManager().registerUtility(psql_srv, IDatabaseService, 'postgresql') return psql_srv