commit
5893e561cd
@ -0,0 +1,4 @@ |
||||
from .cli import cli |
||||
|
||||
if __name__ == '__main__': |
||||
cli(obj={}) |
@ -0,0 +1 @@ |
||||
from .entrypoint import cli |
@ -0,0 +1,48 @@ |
||||
import importlib.resources |
||||
import os |
||||
import socket |
||||
|
||||
import click |
||||
from zope import component |
||||
|
||||
from ..krb_check import krb_check |
||||
from .members import members |
||||
from .groups import groups |
||||
from .updateprograms import updateprograms |
||||
from ceo_common.interfaces import IConfig, IHTTPClient |
||||
from ceo_common.model import Config, HTTPClient |
||||
|
||||
|
||||
@click.group() |
||||
@click.pass_context |
||||
def cli(ctx): |
||||
# ensure ctx exists and is a dict |
||||
ctx.ensure_object(dict) |
||||
|
||||
princ = krb_check() |
||||
user = princ[:princ.index('@')] |
||||
ctx.obj['user'] = user |
||||
|
||||
if os.environ.get('PYTEST') != '1': |
||||
register_services() |
||||
|
||||
|
||||
cli.add_command(members) |
||||
cli.add_command(groups) |
||||
cli.add_command(updateprograms) |
||||
|
||||
|
||||
def register_services(): |
||||
# Config |
||||
# This is a hack to determine if we're in the dev env or not |
||||
if socket.getfqdn().endswith('.csclub.internal'): |
||||
with importlib.resources.path('tests', 'ceo_dev.ini') as p: |
||||
config_file = p.__fspath__() |
||||
else: |
||||
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') |
||||
cfg = Config(config_file) |
||||
component.provideUtility(cfg, IConfig) |
||||
|
||||
# HTTPService |
||||
http_client = HTTPClient() |
||||
component.provideUtility(http_client, IHTTPClient) |
@ -0,0 +1,148 @@ |
||||
from typing import Dict |
||||
|
||||
import click |
||||
from zope import component |
||||
|
||||
from ..utils import http_post, http_get, http_delete |
||||
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \ |
||||
check_if_in_development |
||||
from ceo_common.interfaces import IConfig |
||||
from ceod.transactions.groups import ( |
||||
AddGroupTransaction, |
||||
AddMemberToGroupTransaction, |
||||
RemoveMemberFromGroupTransaction, |
||||
DeleteGroupTransaction, |
||||
) |
||||
|
||||
|
||||
@click.group(short_help='Perform operations on CSC groups/clubs') |
||||
def groups(): |
||||
pass |
||||
|
||||
|
||||
@groups.command(short_help='Add a new group') |
||||
@click.argument('group_name') |
||||
@click.option('-d', '--description', help='Group description', prompt=True) |
||||
def add(group_name, description): |
||||
click.echo('The following group will be created:') |
||||
lines = [ |
||||
('cn', group_name), |
||||
('description', description), |
||||
] |
||||
print_colon_kv(lines) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
body = { |
||||
'cn': group_name, |
||||
'description': description, |
||||
} |
||||
operations = AddGroupTransaction.operations |
||||
resp = http_post('/api/groups', json=body) |
||||
data = handle_stream_response(resp, operations) |
||||
result = data[-1]['result'] |
||||
print_group_lines(result) |
||||
|
||||
|
||||
def print_group_lines(result: Dict): |
||||
"""Pretty-print a group JSON response.""" |
||||
lines = [ |
||||
('cn', result['cn']), |
||||
('description', result.get('description', 'Unknown')), |
||||
('gid_number', str(result['gid_number'])), |
||||
] |
||||
for i, member in enumerate(result['members']): |
||||
if i == 0: |
||||
prefix = 'members' |
||||
else: |
||||
prefix = '' |
||||
lines.append((prefix, member['cn'] + ' (' + member['uid'] + ')')) |
||||
print_colon_kv(lines) |
||||
|
||||
|
||||
@groups.command(short_help='Get info about a group') |
||||
@click.argument('group_name') |
||||
def get(group_name): |
||||
resp = http_get('/api/groups/' + group_name) |
||||
result = handle_sync_response(resp) |
||||
print_group_lines(result) |
||||
|
||||
|
||||
@groups.command(short_help='Add a member to a group') |
||||
@click.argument('group_name') |
||||
@click.argument('username') |
||||
@click.option('--no-subscribe', is_flag=True, default=False, |
||||
help='Do not subscribe the member to any auxiliary mailing lists.') |
||||
def addmember(group_name, username, no_subscribe): |
||||
click.confirm(f'Are you sure you want to add {username} to {group_name}?', |
||||
abort=True) |
||||
base_domain = component.getUtility(IConfig).get('base_domain') |
||||
url = f'/api/groups/{group_name}/members/{username}' |
||||
operations = AddMemberToGroupTransaction.operations |
||||
|
||||
if no_subscribe: |
||||
url += '?subscribe_to_lists=false' |
||||
operations.remove('subscribe_user_to_auxiliary_mailing_lists') |
||||
resp = http_post(url) |
||||
data = handle_stream_response(resp, operations) |
||||
result = data[-1]['result'] |
||||
lines = [] |
||||
for i, group in enumerate(result['added_to_groups']): |
||||
if i == 0: |
||||
prefix = 'Added to groups' |
||||
else: |
||||
prefix = '' |
||||
lines.append((prefix, group)) |
||||
for i, mailing_list in enumerate(result.get('subscribed_to_lists', [])): |
||||
if i == 0: |
||||
prefix = 'Subscribed to lists' |
||||
else: |
||||
prefix = '' |
||||
if '@' not in mailing_list: |
||||
mailing_list += '@' + base_domain |
||||
lines.append((prefix, mailing_list)) |
||||
print_colon_kv(lines) |
||||
|
||||
|
||||
@groups.command(short_help='Remove a member from a group') |
||||
@click.argument('group_name') |
||||
@click.argument('username') |
||||
@click.option('--no-unsubscribe', is_flag=True, default=False, |
||||
help='Do not unsubscribe the member from any auxiliary mailing lists.') |
||||
def removemember(group_name, username, no_unsubscribe): |
||||
click.confirm(f'Are you sure you want to remove {username} from {group_name}?', |
||||
abort=True) |
||||
base_domain = component.getUtility(IConfig).get('base_domain') |
||||
url = f'/api/groups/{group_name}/members/{username}' |
||||
operations = RemoveMemberFromGroupTransaction.operations |
||||
if no_unsubscribe: |
||||
url += '?unsubscribe_from_lists=false' |
||||
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists') |
||||
resp = http_delete(url) |
||||
data = handle_stream_response(resp, operations) |
||||
result = data[-1]['result'] |
||||
lines = [] |
||||
for i, group in enumerate(result['removed_from_groups']): |
||||
if i == 0: |
||||
prefix = 'Removed from groups' |
||||
else: |
||||
prefix = '' |
||||
lines.append((prefix, group)) |
||||
for i, mailing_list in enumerate(result.get('unsubscribed_from_lists', [])): |
||||
if i == 0: |
||||
prefix = 'Unsubscribed from lists' |
||||
else: |
||||
prefix = '' |
||||
if '@' not in mailing_list: |
||||
mailing_list += '@' + base_domain |
||||
lines.append((prefix, mailing_list)) |
||||
print_colon_kv(lines) |
||||
|
||||
|
||||
@groups.command(short_help='Delete a group') |
||||
@click.argument('group_name') |
||||
def delete(group_name): |
||||
check_if_in_development() |
||||
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True) |
||||
resp = http_delete(f'/api/groups/{group_name}') |
||||
handle_stream_response(resp, DeleteGroupTransaction.operations) |
@ -0,0 +1,218 @@ |
||||
import sys |
||||
from typing import Dict |
||||
|
||||
import click |
||||
from zope import component |
||||
|
||||
from ..utils import http_post, http_get, http_patch, http_delete, get_failed_operations |
||||
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \ |
||||
check_if_in_development |
||||
from ceo_common.interfaces import IConfig |
||||
from ceo_common.model import Term |
||||
from ceod.transactions.members import ( |
||||
AddMemberTransaction, |
||||
DeleteMemberTransaction, |
||||
) |
||||
|
||||
|
||||
@click.group(short_help='Perform operations on CSC members and club reps') |
||||
def members(): |
||||
pass |
||||
|
||||
|
||||
@members.command(short_help='Add a new member or club rep') |
||||
@click.argument('username') |
||||
@click.option('--cn', help='Full name', prompt='Full name') |
||||
@click.option('--program', required=False, help='Academic program') |
||||
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100), |
||||
help='Number of terms to add', prompt='Number of terms') |
||||
@click.option('--clubrep', is_flag=True, default=False, |
||||
help='Add non-member terms instead of member terms') |
||||
@click.option('--forwarding-address', required=False, |
||||
help=('Forwarding address to set in ~/.forward. ' |
||||
'Default is UW address. ' |
||||
'Set to the empty string to disable forwarding.')) |
||||
def add(username, cn, program, num_terms, clubrep, forwarding_address): |
||||
cfg = component.getUtility(IConfig) |
||||
uw_domain = cfg.get('uw_domain') |
||||
|
||||
current_term = Term.current() |
||||
terms = [current_term + i for i in range(num_terms)] |
||||
terms = list(map(str, terms)) |
||||
|
||||
if forwarding_address is None: |
||||
forwarding_address = username + '@' + uw_domain |
||||
|
||||
click.echo("The following user will be created:") |
||||
lines = [ |
||||
('uid', username), |
||||
('cn', cn), |
||||
] |
||||
if program is not None: |
||||
lines.append(('program', program)) |
||||
if clubrep: |
||||
lines.append(('non-member terms', ','.join(terms))) |
||||
else: |
||||
lines.append(('member terms', ','.join(terms))) |
||||
if forwarding_address != '': |
||||
lines.append(('forwarding address', forwarding_address)) |
||||
print_colon_kv(lines) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
body = { |
||||
'uid': username, |
||||
'cn': cn, |
||||
} |
||||
if program is not None: |
||||
body['program'] = program |
||||
if clubrep: |
||||
body['non_member_terms'] = terms |
||||
else: |
||||
body['terms'] = terms |
||||
if forwarding_address != '': |
||||
body['forwarding_addresses'] = [forwarding_address] |
||||
operations = AddMemberTransaction.operations |
||||
if forwarding_address == '': |
||||
# don't bother displaying this because it won't be run |
||||
operations.remove('set_forwarding_addresses') |
||||
|
||||
resp = http_post('/api/members', json=body) |
||||
data = handle_stream_response(resp, operations) |
||||
result = data[-1]['result'] |
||||
print_user_lines(result) |
||||
|
||||
failed_operations = get_failed_operations(data) |
||||
if 'send_welcome_message' in failed_operations: |
||||
click.echo(click.style( |
||||
'Warning: welcome message was not sent. You now need to manually ' |
||||
'send the user their password.', fg='yellow')) |
||||
|
||||
|
||||
def print_user_lines(result: Dict): |
||||
"""Pretty-print a user JSON response.""" |
||||
lines = [ |
||||
('uid', result['uid']), |
||||
('cn', result['cn']), |
||||
('program', result.get('program', 'Unknown')), |
||||
('UID number', result['uid_number']), |
||||
('GID number', result['gid_number']), |
||||
('login shell', result['login_shell']), |
||||
('home directory', result['home_directory']), |
||||
('is a club', result['is_club']), |
||||
] |
||||
if 'forwarding_addresses' in result: |
||||
if len(result['forwarding_addresses']) != 0: |
||||
lines.append(('forwarding addresses', result['forwarding_addresses'][0])) |
||||
for address in result['forwarding_addresses'][1:]: |
||||
lines.append(('', address)) |
||||
if 'terms' in result: |
||||
lines.append(('terms', ','.join(result['terms']))) |
||||
if 'non_member_terms' in result: |
||||
lines.append(('non-member terms', ','.join(result['non_member_terms']))) |
||||
if 'password' in result: |
||||
lines.append(('password', result['password'])) |
||||
print_colon_kv(lines) |
||||
|
||||
|
||||
@members.command(short_help='Get info about a user') |
||||
@click.argument('username') |
||||
def get(username): |
||||
resp = http_get('/api/members/' + username) |
||||
result = handle_sync_response(resp) |
||||
print_user_lines(result) |
||||
|
||||
|
||||
@members.command(short_help="Replace a user's login shell or forwarding addresses") |
||||
@click.argument('username') |
||||
@click.option('--login-shell', required=False, help='Login shell') |
||||
@click.option('--forwarding-addresses', required=False, |
||||
help=( |
||||
'Comma-separated list of forwarding addresses. ' |
||||
'Set to the empty string to disable forwarding.' |
||||
)) |
||||
def modify(username, login_shell, forwarding_addresses): |
||||
if login_shell is None and forwarding_addresses is None: |
||||
click.echo('Nothing to do.') |
||||
sys.exit() |
||||
operations = [] |
||||
body = {} |
||||
if login_shell is not None: |
||||
body['login_shell'] = login_shell |
||||
operations.append('replace_login_shell') |
||||
click.echo('Login shell will be set to: ' + login_shell) |
||||
if forwarding_addresses is not None: |
||||
if forwarding_addresses == '': |
||||
forwarding_addresses = [] |
||||
else: |
||||
forwarding_addresses = forwarding_addresses.split(',') |
||||
body['forwarding_addresses'] = forwarding_addresses |
||||
operations.append('replace_forwarding_addresses') |
||||
prefix = '~/.forward will be set to: ' |
||||
if len(forwarding_addresses) > 0: |
||||
click.echo(prefix + forwarding_addresses[0]) |
||||
for address in forwarding_addresses[1:]: |
||||
click.echo((' ' * len(prefix)) + address) |
||||
else: |
||||
click.echo(prefix) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
resp = http_patch('/api/members/' + username, json=body) |
||||
handle_stream_response(resp, operations) |
||||
|
||||
|
||||
@members.command(short_help="Renew a member or club rep's membership") |
||||
@click.argument('username') |
||||
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100), |
||||
help='Number of terms to add', prompt='Number of terms') |
||||
@click.option('--clubrep', is_flag=True, default=False, |
||||
help='Add non-member terms instead of member terms') |
||||
def renew(username, num_terms, clubrep): |
||||
resp = http_get('/api/members/' + username) |
||||
result = handle_sync_response(resp) |
||||
max_term = None |
||||
current_term = Term.current() |
||||
if clubrep and 'non_member_terms' in result: |
||||
max_term = max(Term(s) for s in result['non_member_terms']) |
||||
elif not clubrep and 'terms' in result: |
||||
max_term = max(Term(s) for s in result['terms']) |
||||
|
||||
if max_term is not None and max_term >= current_term: |
||||
next_term = max_term + 1 |
||||
else: |
||||
next_term = Term.current() |
||||
|
||||
terms = [next_term + i for i in range(num_terms)] |
||||
terms = list(map(str, terms)) |
||||
|
||||
if clubrep: |
||||
body = {'non_member_terms': terms} |
||||
click.echo('The following non-member terms will be added: ' + ','.join(terms)) |
||||
else: |
||||
body = {'terms': terms} |
||||
click.echo('The following member terms will be added: ' + ','.join(terms)) |
||||
|
||||
click.confirm('Do you want to continue?', abort=True) |
||||
|
||||
resp = http_post(f'/api/members/{username}/renew', json=body) |
||||
handle_sync_response(resp) |
||||
click.echo('Done.') |
||||
|
||||
|
||||
@members.command(short_help="Reset a user's password") |
||||
@click.argument('username') |
||||
def pwreset(username): |
||||
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True) |
||||
resp = http_post(f'/api/members/{username}/pwreset') |
||||
result = handle_sync_response(resp) |
||||
click.echo('New password: ' + result['password']) |
||||
|
||||
|
||||
@members.command(short_help="Delete a user") |
||||
@click.argument('username') |
||||
def delete(username): |
||||
check_if_in_development() |
||||
click.confirm(f"Are you sure you want to delete {username}?", abort=True) |
||||
resp = http_delete(f'/api/members/{username}') |
||||
handle_stream_response(resp, DeleteMemberTransaction.operations) |
@ -0,0 +1,35 @@ |
||||
import click |
||||
|
||||
from ..utils import http_post |
||||
from .utils import handle_sync_response, print_colon_kv |
||||
|
||||
|
||||
@click.command(short_help="Sync the 'program' attribute with UWLDAP") |
||||
@click.option('--dry-run', is_flag=True, default=False) |
||||
@click.option('--members', required=False) |
||||
def updateprograms(dry_run, members): |
||||
body = {} |
||||
if dry_run: |
||||
body['dry_run'] = True |
||||
if members is not None: |
||||
body['members'] = ','.split(members) |
||||
|
||||
if not dry_run: |
||||
click.confirm('Are you sure that you want to sync programs with UWLDAP?', abort=True) |
||||
|
||||
resp = http_post('/api/uwldap/updateprograms', json=body) |
||||
result = handle_sync_response(resp) |
||||
if len(result) == 0: |
||||
click.echo('All programs are up-to-date.') |
||||
return |
||||
if dry_run: |
||||
click.echo('Members whose program would be changed:') |
||||
else: |
||||
click.echo('Members whose program was changed:') |
||||
lines = [] |
||||
for uid, csc_program, uw_program in result: |
||||
csc_program = csc_program or 'Unknown' |
||||
csc_program = click.style(csc_program, fg='yellow') |
||||
uw_program = click.style(uw_program, fg='green') |
||||
lines.append((uid, csc_program + ' -> ' + uw_program)) |
||||
print_colon_kv(lines) |
@ -0,0 +1,121 @@ |
||||
import json |
||||
import socket |
||||
import sys |
||||
from typing import List, Tuple, Dict |
||||
|
||||
import click |
||||
import requests |
||||
|
||||
from ..operation_strings import descriptions as op_desc |
||||
|
||||
|
||||
class Abort(click.ClickException): |
||||
"""Abort silently.""" |
||||
|
||||
def __init__(self, exit_code=1): |
||||
super().__init__('') |
||||
self.exit_code = exit_code |
||||
|
||||
def show(self): |
||||
pass |
||||
|
||||
|
||||
def print_colon_kv(pairs: List[Tuple[str, str]]): |
||||
""" |
||||
Pretty-print a list of key-value pairs such that the key and value |
||||
columns align. |
||||
Example: |
||||
key1: value1 |
||||
key1000: value2 |
||||
""" |
||||
maxlen = max(len(key) for key, val in pairs) |
||||
for key, val in pairs: |
||||
if key != '': |
||||
click.echo(key + ': ', nl=False) |
||||
else: |
||||
# assume this is a continuation from the previous line |
||||
click.echo(' ', nl=False) |
||||
extra_space = ' ' * (maxlen - len(key)) |
||||
click.echo(extra_space, nl=False) |
||||
click.echo(val) |
||||
|
||||
|
||||
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]: |
||||
""" |
||||
Print output to the console while operations are being streamed |
||||
from the server over HTTP. |
||||
Returns the parsed JSON data streamed from the server. |
||||
""" |
||||
if resp.status_code != 200: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text.rstrip()) |
||||
raise Abort() |
||||
click.echo(op_desc[operations[0]] + '... ', nl=False) |
||||
idx = 0 |
||||
data = [] |
||||
for line in resp.iter_lines(decode_unicode=True, chunk_size=8): |
||||
d = json.loads(line) |
||||
data.append(d) |
||||
if d['status'] == 'aborted': |
||||
click.echo(click.style('ABORTED', fg='red')) |
||||
click.echo('The transaction was rolled back.') |
||||
click.echo('The error was: ' + d['error']) |
||||
click.echo('Please check the ceod logs.') |
||||
sys.exit(1) |
||||
elif d['status'] == 'completed': |
||||
if idx < len(operations): |
||||
click.echo('Skipped') |
||||
click.echo('Transaction successfully completed.') |
||||
return data |
||||
|
||||
operation = d['operation'] |
||||
oper_failed = False |
||||
err_msg = None |
||||
prefix = 'failed_to_' |
||||
if operation.startswith(prefix): |
||||
operation = operation[len(prefix):] |
||||
oper_failed = True |
||||
# sometimes the operation looks like |
||||
# "failed_to_do_something: error message" |
||||
if ':' in operation: |
||||
operation, err_msg = operation.split(': ', 1) |
||||
|
||||
while idx < len(operations) and operations[idx] != operation: |
||||
click.echo('Skipped') |
||||
idx += 1 |
||||
if idx == len(operations): |
||||
break |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
if idx == len(operations): |
||||
click.echo('Unrecognized operation: ' + operation) |
||||
continue |
||||
if oper_failed: |
||||
click.echo(click.style('Failed', fg='red')) |
||||
if err_msg is not None: |
||||
click.echo(' Error message: ' + err_msg) |
||||
else: |
||||
click.echo(click.style('Done', fg='green')) |
||||
idx += 1 |
||||
if idx < len(operations): |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
|
||||
raise Exception('server response ended abruptly') |
||||
|
||||
|
||||
def handle_sync_response(resp: requests.Response): |
||||
""" |
||||
Exit the program if the request was not successful. |
||||
Returns the parsed JSON response. |
||||
""" |
||||
if resp.status_code != 200: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text.rstrip()) |
||||
raise Abort() |
||||
return resp.json() |
||||
|
||||
|
||||
def check_if_in_development() -> bool: |
||||
"""Aborts if we are not currently in the dev environment.""" |
||||
if not socket.getfqdn().endswith('.csclub.internal'): |
||||
click.echo('This command may only be called during development.') |
||||
raise Abort() |
@ -0,0 +1,24 @@ |
||||
import subprocess |
||||
|
||||
import gssapi |
||||
|
||||
|
||||
def krb_check(): |
||||
""" |
||||
Spawns a `kinit` process if no credentials are available or the |
||||
credentials have expired. |
||||
Returns the principal string 'user@REALM'. |
||||
""" |
||||
for _ in range(2): |
||||
try: |
||||
creds = gssapi.Credentials(usage='initiate') |
||||
result = creds.inquire() |
||||
return str(result.name) |
||||
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError): |
||||
kinit() |
||||
|
||||
raise Exception('could not acquire GSSAPI credentials') |
||||
|
||||
|
||||
def kinit(): |
||||
subprocess.run(['kinit'], check=True) |
@ -0,0 +1,27 @@ |
||||
# These descriptions are printed to the console while a transaction |
||||
# is performed, in real time. |
||||
descriptions = { |
||||
'add_user_to_ldap': 'Add user to LDAP', |
||||
'add_group_to_ldap': 'Add group to LDAP', |
||||
'add_user_to_kerberos': 'Add user to Kerberos', |
||||
'create_home_dir': 'Create home directory', |
||||
'set_forwarding_addresses': 'Set forwarding addresses', |
||||
'send_welcome_message': 'Send welcome message', |
||||
'subscribe_to_mailing_list': 'Subscribe to mailing list', |
||||
'announce_new_user': 'Announce new user to mailing list', |
||||
'replace_login_shell': 'Replace login shell', |
||||
'replace_forwarding_addresses': 'Replace forwarding addresses', |
||||
'remove_user_from_ldap': 'Remove user from LDAP', |
||||
'remove_group_from_ldap': 'Remove group from LDAP', |
||||
'remove_user_from_kerberos': 'Remove user from Kerberos', |
||||
'delete_home_dir': 'Delete home directory', |
||||
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list', |
||||
'add_sudo_role': 'Add sudo role to LDAP', |
||||
'add_user_to_group': 'Add user to group', |
||||
'add_user_to_auxiliary_groups': 'Add user to auxiliary groups', |
||||
'subscribe_user_to_auxiliary_mailing_lists': 'Subscribe user to auxiliary mailing lists', |
||||
'remove_user_from_group': 'Remove user from group', |
||||
'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', |
||||
} |
@ -0,0 +1,59 @@ |
||||
from typing import List, Dict |
||||
|
||||
import requests |
||||
from zope import component |
||||
|
||||
from ceo_common.interfaces import IHTTPClient, IConfig |
||||
|
||||
|
||||
def http_request(method: str, path: str, **kwargs) -> requests.Response: |
||||
client = component.getUtility(IHTTPClient) |
||||
cfg = component.getUtility(IConfig) |
||||
if path.startswith('/api/db'): |
||||
host = cfg.get('ceod_db_host') |
||||
need_cred = False |
||||
else: |
||||
host = cfg.get('ceod_admin_host') |
||||
# The forwarded TGT is only needed for endpoints which write to LDAP |
||||
need_cred = method != 'GET' |
||||
return client.request( |
||||
host, path, method, principal=None, need_cred=need_cred, |
||||
stream=True, **kwargs) |
||||
|
||||
|
||||
def http_get(path: str, **kwargs) -> requests.Response: |
||||
return http_request('GET', path, **kwargs) |
||||
|
||||
|
||||
def http_post(path: str, **kwargs) -> requests.Response: |
||||
return http_request('POST', path, **kwargs) |
||||
|
||||
|
||||
def http_patch(path: str, **kwargs) -> requests.Response: |
||||
return http_request('PATCH', path, **kwargs) |
||||
|
||||
|
||||
def http_delete(path: str, **kwargs) -> requests.Response: |
||||
return http_request('DELETE', path, **kwargs) |
||||
|
||||
|
||||
def get_failed_operations(data: List[Dict]) -> List[str]: |
||||
""" |
||||
Get a list of the failed operations using the JSON objects |
||||
streamed from the server. |
||||
""" |
||||
prefix = 'failed_to_' |
||||
failed = [] |
||||
for d in data: |
||||
if 'operation' not in d: |
||||
continue |
||||
operation = d['operation'] |
||||
if not operation.startswith(prefix): |
||||
continue |
||||
operation = operation[len(prefix):] |
||||
if ':' in operation: |
||||
# sometimes the operation looks like |
||||
# "failed_to_do_something: error message" |
||||
operation = operation[:operation.index(':')] |
||||
failed.append(operation) |
||||
return failed |
@ -1,14 +1,27 @@ |
||||
from typing import Union |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
|
||||
class IHTTPClient(Interface): |
||||
"""A helper class for HTTP requests to ceod.""" |
||||
|
||||
def get(host: str, api_path: str, **kwargs): |
||||
def request(host: str, api_path: str, method: str, principal: str, |
||||
need_cred: bool, **kwargs): |
||||
"""Make an HTTP request.""" |
||||
|
||||
def get(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a GET request.""" |
||||
|
||||
def post(host: str, api_path: str, **kwargs): |
||||
def post(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a POST request.""" |
||||
|
||||
def delete(host: str, api_path: str, **kwargs): |
||||
def patch(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a PATCH request.""" |
||||
|
||||
def delete(host: str, api_path: str, principal: Union[str, None] = None, |
||||
need_cred: bool = True, **kwargs): |
||||
"""Make a DELETE request.""" |
||||
|
@ -0,0 +1,67 @@ |
||||
import datetime |
||||
|
||||
|
||||
class Term: |
||||
"""A representation of a term in the CSC LDAP, e.g. 's2021'.""" |
||||
|
||||
seasons = ['w', 's', 'f'] |
||||
|
||||
def __init__(self, s_term: str): |
||||
assert len(s_term) == 5 and s_term[0] in self.seasons and \ |
||||
s_term[1:].isdigit() |
||||
self.s_term = s_term |
||||
|
||||
def __repr__(self): |
||||
return self.s_term |
||||
|
||||
@staticmethod |
||||
def current(): |
||||
"""Get a Term object for the current date.""" |
||||
dt = datetime.datetime.now() |
||||
c = 'w' |
||||
if 5 <= dt.month <= 8: |
||||
c = 's' |
||||
elif 9 <= dt.month: |
||||
c = 'f' |
||||
s_term = c + str(dt.year) |
||||
return Term(s_term) |
||||
|
||||
def __add__(self, other): |
||||
assert type(other) is int and other >= 0 |
||||
c = self.s_term[0] |
||||
season_idx = self.seasons.index(c) |
||||
year = int(self.s_term[1:]) |
||||
year += other // 3 |
||||
season_idx += other % 3 |
||||
if season_idx >= 3: |
||||
year += 1 |
||||
season_idx -= 3 |
||||
s_term = self.seasons[season_idx] + str(year) |
||||
return Term(s_term) |
||||
|
||||
def __eq__(self, other): |
||||
return isinstance(other, Term) and self.s_term == other.s_term |
||||
|
||||
def __lt__(self, other): |
||||
if not isinstance(other, Term): |
||||
return NotImplemented |
||||
c1, c2 = self.s_term[0], other.s_term[0] |
||||
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:]) |
||||
return year1 < year2 or ( |
||||
year1 == year2 and self.seasons.index(c1) < self.seasons.index(c2) |
||||
) |
||||
|
||||
def __gt__(self, other): |
||||
if not isinstance(other, Term): |
||||
return NotImplemented |
||||
c1, c2 = self.s_term[0], other.s_term[0] |
||||
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:]) |
||||
return year1 > year2 or ( |
||||
year1 == year2 and self.seasons.index(c1) > self.seasons.index(c2) |
||||
) |
||||
|
||||
def __ge__(self, other): |
||||
return self > other or self == other |
||||
|
||||
def __le__(self, other): |
||||
return self < other or self == other |
@ -1,3 +1,4 @@ |
||||
from .Config import Config |
||||
from .HTTPClient import HTTPClient |
||||
from .RemoteMailmanService import RemoteMailmanService |
||||
from .Term import Term |
||||
|