From bb56870652d4f2fc68cd7cba19efd7e71a2ca723 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Sun, 29 Aug 2021 03:09:02 +0000 Subject: [PATCH 01/17] add skeleton for TUI --- ceo/StreamResponseHandler.py | 43 +++++++ ceo/__main__.py | 39 +++++- ceo/cli/CLIStreamResponseHandler.py | 67 ++++++++++ ceo/cli/entrypoint.py | 37 +----- ceo/cli/members.py | 72 +++-------- ceo/cli/utils.py | 87 ++----------- ceo/krb_check.py | 15 ++- ceo/tui/ConfirmView.py | 59 +++++++++ ceo/tui/Model.py | 12 ++ ceo/tui/TUIStreamResponseHandler.py | 98 +++++++++++++++ ceo/tui/TransactionView.py | 81 ++++++++++++ ceo/tui/WelcomeView.py | 57 +++++++++ ceo/tui/__init__.py | 0 ceo/tui/members/AddUserView.py | 105 ++++++++++++++++ ceo/tui/members/__init__.py | 0 ceo/tui/start.py | 46 +++++++ ceo/utils.py | 152 ++++++++++++++++++++++- ceo_common/interfaces/IHTTPClient.py | 13 +- ceo_common/model/HTTPClient.py | 58 +++++---- ceo_common/model/RemoteMailmanService.py | 6 +- gen_cred.py | 14 --- requirements.txt | 1 + tests/ceo/cli/test_groups.py | 2 + tests/ceo/cli/test_members.py | 31 ++--- tests/conftest.py | 17 ++- tests/conftest_ceod_api.py | 26 ++-- 26 files changed, 882 insertions(+), 256 deletions(-) create mode 100644 ceo/StreamResponseHandler.py create mode 100644 ceo/cli/CLIStreamResponseHandler.py create mode 100644 ceo/tui/ConfirmView.py create mode 100644 ceo/tui/Model.py create mode 100644 ceo/tui/TUIStreamResponseHandler.py create mode 100644 ceo/tui/TransactionView.py create mode 100644 ceo/tui/WelcomeView.py create mode 100644 ceo/tui/__init__.py create mode 100644 ceo/tui/members/AddUserView.py create mode 100644 ceo/tui/members/__init__.py create mode 100644 ceo/tui/start.py delete mode 100755 gen_cred.py diff --git a/ceo/StreamResponseHandler.py b/ceo/StreamResponseHandler.py new file mode 100644 index 0000000..00187fa --- /dev/null +++ b/ceo/StreamResponseHandler.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod +from typing import Union + +import requests + + +class StreamResponseHandler(ABC): + """ + An abstract class to handle stream responses from the server. + The CLI and TUI should implement a child class. + """ + + @abstractmethod + def handle_non_200(self, resp: requests.Response): + """Handle a non-200 response.""" + + @abstractmethod + def begin(self): + """Begin the transaction.""" + + @abstractmethod + def handle_aborted(self, err_msg: str): + """Handle an aborted transaction.""" + + @abstractmethod + def handle_completed(self): + """Handle a completed transaction.""" + + @abstractmethod + def handle_successful_operation(self): + """Handle a successful operation.""" + + @abstractmethod + def handle_failed_operation(self, err_msg: Union[str, None]): + """Handle a failed operation.""" + + @abstractmethod + def handle_skipped_operation(self): + """Handle a skipped operation.""" + + @abstractmethod + def handle_unrecognized_operation(self, operation: str): + """Handle an unrecognized operation.""" diff --git a/ceo/__main__.py b/ceo/__main__.py index aa3acee..af29d1c 100644 --- a/ceo/__main__.py +++ b/ceo/__main__.py @@ -1,4 +1,41 @@ +import importlib.resources +import os +import socket +import sys + +from zope import component + from .cli import cli +from .krb_check import krb_check +from .tui.start import main as tui_main +from ceo_common.interfaces import IConfig, IHTTPClient +from ceo_common.model import Config, HTTPClient + + +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) + + +def main(): + krb_check() + register_services() + if len(sys.argv) > 1: + cli(obj={}) + else: + tui_main() + if __name__ == '__main__': - cli(obj={}) + main() diff --git a/ceo/cli/CLIStreamResponseHandler.py b/ceo/cli/CLIStreamResponseHandler.py new file mode 100644 index 0000000..de0dae0 --- /dev/null +++ b/ceo/cli/CLIStreamResponseHandler.py @@ -0,0 +1,67 @@ +from typing import List, Union + +import click +import requests + +from ..StreamResponseHandler import StreamResponseHandler +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 + + +class CLIStreamResponseHandler(StreamResponseHandler): + def __init__(self, operations: List[str]): + self.operations = operations + self.idx = 0 + + def handle_non_200(self, resp: requests.Response): + click.echo('An error occurred:') + click.echo(resp.text.rstrip()) + raise Abort() + + def begin(self): + click.echo(op_desc[self.operations[0]] + '... ', nl=False) + + def handle_aborted(self, err_msg: str): + click.echo(click.style('ABORTED', fg='red')) + click.echo('The transaction was rolled back.') + click.echo('The error was: ' + err_msg) + click.echo('Please check the ceod logs.') + + def handle_completed(self): + click.echo('Transaction successfully completed.') + + def _go_to_next_op(self): + """ + Increment the operation index and print the next operation, if + there is one. + """ + self.idx += 1 + if self.idx < len(self.operations): + click.echo(op_desc[self.operations[self.idx]] + '... ', nl=False) + + def handle_successful_operation(self): + click.echo(click.style('Done', fg='green')) + self._go_to_next_op() + + def handle_failed_operation(self, err_msg: Union[str, None]): + click.echo(click.style('Failed', fg='red')) + if err_msg is not None: + click.echo(' Error message: ' + err_msg) + self._go_to_next_op() + + def handle_skipped_operation(self): + click.echo('Skipped') + self._go_to_next_op() + + def handle_unrecognized_operation(self, operation: str): + click.echo('Unrecognized operation: ' + operation) diff --git a/ceo/cli/entrypoint.py b/ceo/cli/entrypoint.py index 04f5306..1221144 100644 --- a/ceo/cli/entrypoint.py +++ b/ceo/cli/entrypoint.py @@ -1,48 +1,15 @@ -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() +def cli(): + pass 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) diff --git a/ceo/cli/members.py b/ceo/cli/members.py index dd6a3e8..6ad0243 100644 --- a/ceo/cli/members.py +++ b/ceo/cli/members.py @@ -4,15 +4,14 @@ 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, \ +from ..utils import http_post, http_get, http_patch, http_delete, \ + get_failed_operations, get_terms_for_new_user, user_dict_lines, \ + get_adduser_operations +from .utils import handle_stream_response, handle_sync_response, print_lines, \ check_if_in_development from ceo_common.interfaces import IConfig from ceo_common.model import Term -from ceod.transactions.members import ( - AddMemberTransaction, - DeleteMemberTransaction, -) +from ceod.transactions.members import DeleteMemberTransaction @click.group(short_help='Perform operations on CSC members and club reps') @@ -36,30 +35,12 @@ 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)) + terms = get_terms_for_new_user(num_terms) + # TODO: get email address from UWLDAP 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, @@ -72,10 +53,14 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address): 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') + else: + body['forwarding_addresses'] = [] + + click.echo("The following user will be created:") + print_user_lines(body) + click.confirm('Do you want to continue?', abort=True) + + operations = get_adduser_operations(body) resp = http_post('/api/members', json=body) data = handle_stream_response(resp, operations) @@ -89,30 +74,9 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address): '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) +def print_user_lines(d: Dict): + """Pretty-print a serialized User.""" + print_lines(user_dict_lines(d)) @members.command(short_help='Get info about a user') diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index 98aa23f..76389c9 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -7,6 +7,8 @@ import click import requests from ..operation_strings import descriptions as op_desc +from ..utils import space_colon_kv, generic_handle_stream_response +from .CLIStreamResponseHandler import CLIStreamResponseHandler class Abort(click.ClickException): @@ -20,86 +22,23 @@ class Abort(click.ClickException): pass +def print_lines(lines: List[str]): + """Print multiple lines to stdout.""" + for line in lines: + click.echo(line) + + 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 + Pretty-print a list of key-value pairs. """ - 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) + for line in space_colon_kv(pairs): + click.echo(line) 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') + handler = CLIStreamResponseHandler(operations) + return generic_handle_stream_response(resp, operations, handler) def handle_sync_response(resp: requests.Response): diff --git a/ceo/krb_check.py b/ceo/krb_check.py index 7824da7..fcecbdc 100644 --- a/ceo/krb_check.py +++ b/ceo/krb_check.py @@ -3,17 +3,28 @@ import subprocess import gssapi +_username = None + + +def get_username(): + """Get the user currently logged into CEO.""" + return _username + + def krb_check(): """ Spawns a `kinit` process if no credentials are available or the credentials have expired. - Returns the principal string 'user@REALM'. + Stores the username for later use by get_username(). """ + global _username for _ in range(2): try: creds = gssapi.Credentials(usage='initiate') result = creds.inquire() - return str(result.name) + princ = str(result.name) + _username = princ[:princ.index('@')] + return except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError): kinit() diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py new file mode 100644 index 0000000..ebf3fbe --- /dev/null +++ b/ceo/tui/ConfirmView.py @@ -0,0 +1,59 @@ +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Frame, Layout, Button, Divider, Label + + +class ConfirmView(Frame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, + height, + width, + can_scroll=False, + on_load=self._on_load, + title='Confirmation', + ) + self._model = model + + def _add_buttons(self): + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Divider()) + + layout = Layout([1, 1]) + self.add_layout(layout) + layout.add_widget(Button('No', self._back), 0) + layout.add_widget(Button('Yes', self._next), 1) + + def _add_line(self, text: str = ''): + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Label(text, align='^')) + + def _add_pair(self, key: str, val: str): + layout = Layout([10, 1, 10]) + self.add_layout(layout) + layout.add_widget(Label(key + ':', align='>'), 0) + layout.add_widget(Label(val, align='<'), 2) + + def _on_load(self): + for _ in range(2): + self._add_line() + for line in self._model.confirm_lines: + if isinstance(line, str): + self._add_line(line) + else: + # assume tuple + key, val = line + self._add_pair(key, val) + # fill the rest of the space + self.add_layout(Layout([100], fill_frame=True)) + + self._add_buttons() + self.fix() + + def _back(self): + raise NextScene(self._model.scene_stack.pop()) + + def _next(self): + self._model.scene_stack.append('Confirm') + raise NextScene('Transaction') diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py new file mode 100644 index 0000000..01c4453 --- /dev/null +++ b/ceo/tui/Model.py @@ -0,0 +1,12 @@ +class Model: + """A convenient place to share data beween views.""" + + def __init__(self): + # simple key-value pairs + self.screen = None + self.title = None + self.for_member = True + self.scene_stack = [] + self.confirm_lines = None + self.operations = None + self.deferred_req = None diff --git a/ceo/tui/TUIStreamResponseHandler.py b/ceo/tui/TUIStreamResponseHandler.py new file mode 100644 index 0000000..d400448 --- /dev/null +++ b/ceo/tui/TUIStreamResponseHandler.py @@ -0,0 +1,98 @@ +from typing import Dict, Union + +from asciimatics.widgets import Label, Button, Layout, Frame +import requests + +from .Model import Model +from ..StreamResponseHandler import StreamResponseHandler + + +class TUIStreamResponseHandler(StreamResponseHandler): + def __init__( + self, + model: Model, + labels: Dict[str, Label], + next_btn: Button, + msg_layout: Layout, + frame: Frame, + ): + self.screen = model.screen + self.operations = model.operations + self.idx = 0 + self.labels = labels + self.next_btn = next_btn + self.msg_layout = msg_layout + self.frame = frame + self.error_messages = [] + + def _update(self): + # Since we're running in a separate thread, we need to force the + # screen to update. See + # https://github.com/peterbrittain/asciimatics/issues/56 + self.frame.fix() + self.screen.force_update() + + def _enable_next_btn(self): + self.next_btn.disabled = False + self.frame.reset() + + def _show_msg(self, msg: str = ''): + for line in msg.splitlines(): + self.msg_layout.add_widget(Label(line, align='^')) + + def _abort(self): + for operation in self.operations[self.idx:]: + self.labels[operation].text = 'ABORTED' + self._enable_next_btn() + + def handle_non_200(self, resp: requests.Response): + self._abort() + self._show_msg('An error occurred:') + self._show_msg(resp.text) + self._update() + + def begin(self): + pass + + def handle_aborted(self, err_msg: str): + self._abort() + self._show_msg('The transaction was rolled back.') + self._show_msg('The error was:') + self._show_msg(err_msg) + self._show_msg('Please check the ceod logs.') + self._update() + + def handle_completed(self): + self._show_msg('Transaction successfully completed.') + if len(self.error_messages) > 0: + self._show_msg('There were some errors, please check the ' + 'ceod logs.') + # we don't have enough space in the TUI to actually + # show the error messages + self._enable_next_btn() + self._update() + + def handle_successful_operation(self): + operation = self.operations[self.idx] + self.labels[operation].text = 'Done' + self.idx += 1 + self._update() + + def handle_failed_operation(self, err_msg: Union[str, None]): + operation = self.operations[self.idx] + self.labels[operation].text = 'Failed' + if err_msg is not None: + self.error_messages.append(err_msg) + self.idx += 1 + self._update() + + def handle_skipped_operation(self): + operation = self.operations[self.idx] + self.labels[operation].text = 'Skipped' + self.idx += 1 + self._update() + + def handle_unrecognized_operation(self, operation: str): + self.error_messages.append('Unrecognized operation: ' + operation) + self.idx += 1 + self._update() diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py new file mode 100644 index 0000000..1773cfd --- /dev/null +++ b/ceo/tui/TransactionView.py @@ -0,0 +1,81 @@ +from threading import Thread + +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Frame, Layout, Button, Divider, Label + +from ..operation_strings import descriptions as op_desc +from ..utils import generic_handle_stream_response +from .TUIStreamResponseHandler import TUIStreamResponseHandler + + +class TransactionView(Frame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, + height, + width, + can_scroll=False, + on_load=self._on_load, + title='Running Transaction', + ) + self._model = model + # map operation names to label widgets + self._labels = {} + # this is an ugly hack to get around the fact that _on_load() + # will be called again when we reset() in the TUIStreamResponseHandler + self._loaded = False + + def _add_buttons(self): + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Divider()) + + layout = Layout([1, 1]) + self.add_layout(layout) + self._next_btn = Button('Next', self._next) + self._next_btn.disabled = True + layout.add_widget(self._next_btn, 1) + + def _add_line(self, text: str = ''): + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Label(text, align='^')) + + def _on_load(self): + if self._loaded: + return + self._loaded = True + + for _ in range(2): + self._add_line() + for operation in self._model.operations: + desc = op_desc[operation] + layout = Layout([10, 1, 10]) + self.add_layout(layout) + layout.add_widget(Label(desc + '...', align='>'), 0) + desc_label = Label('', align='<') + layout.add_widget(desc_label, 2) + self._labels[operation] = desc_label + self._add_line() + self._msg_layout = Layout([100]) + self.add_layout(self._msg_layout) + self.add_layout(Layout([100], fill_frame=True)) + + self._add_buttons() + self.fix() + Thread(target=self._do_txn).start() + + def _do_txn(self): + resp = self._model.deferred_req() + handler = TUIStreamResponseHandler( + model=self._model, + labels=self._labels, + next_btn=self._next_btn, + msg_layout=self._msg_layout, + frame=self, + ) + generic_handle_stream_response(resp, self._model.operations, handler) + + def _next(self): + self._model.scene_stack.clear() + raise NextScene('Welcome') diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py new file mode 100644 index 0000000..52a269a --- /dev/null +++ b/ceo/tui/WelcomeView.py @@ -0,0 +1,57 @@ +from asciimatics.widgets import Frame, ListBox, Layout, Divider, \ + Button, Widget +from asciimatics.exceptions import NextScene, StopApplication + + +class WelcomeView(Frame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, + height, + width, + can_scroll=False, + title='CSC Electronic Office', + ) + self._model = model + self._members_menu_items = [ + ('Add member', 'AddUser'), + ('Add club rep', 'AddUser'), + ('Renew member', 'RenewUser'), + ('Renew club rep', 'RenewUser'), + ('Get user info', 'GetUserInfo'), + ('Reset password', 'ResetPassword'), + ('Modify user', 'ModifyUser'), + ] + self._members_menu = ListBox( + Widget.FILL_FRAME, + [ + (desc, i) for i, (desc, view) in + enumerate(self._members_menu_items) + ], + name='members', + label='Members', + on_select=self._members_menu_select, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + layout.add_widget(self._members_menu) + layout.add_widget(Divider()) + + layout = Layout([1, 1, 1]) + self.add_layout(layout) + layout.add_widget(Button("Quit", self._quit), 2) + self.fix() + + def _members_menu_select(self): + self.save() + item_id = self.data['members'] + desc, view = self._members_menu_items[item_id] + if desc.endswith('club rep'): + self._model.for_member = False + self._model.title = desc + self._model.scene_stack.append('Welcome') + raise NextScene(view) + + @staticmethod + def _quit(): + raise StopApplication("User pressed quit") diff --git a/ceo/tui/__init__.py b/ceo/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ceo/tui/members/AddUserView.py b/ceo/tui/members/AddUserView.py new file mode 100644 index 0000000..ee2d747 --- /dev/null +++ b/ceo/tui/members/AddUserView.py @@ -0,0 +1,105 @@ +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Frame, Layout, Text, Button, Divider + +from ...utils import http_get, http_post, defer, user_dict_kv, \ + get_terms_for_new_user, get_adduser_operations + + +class AddUserView(Frame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, + height, + width, + can_scroll=False, + on_load=self._on_load, + ) + self._model = model + self._username_changed = False + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text( + "Username:", "uid", + on_change=self._on_username_change, + on_blur=self._on_username_blur, + ) + layout.add_widget(self._username) + self._full_name = Text("Full name:", "cn") + layout.add_widget(self._full_name) + self._program = Text("Program:", "program") + layout.add_widget(self._program) + self._forwarding_address = Text("Forwarding address:", "forwarding_address") + layout.add_widget(self._forwarding_address) + self._num_terms = Text( + "Number of terms:", "num_terms", + validator=lambda s: s.isdigit() and s[0] != '0') + self._num_terms.value = '1' + layout.add_widget(self._num_terms) + + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Divider()) + + layout = Layout([1, 1]) + self.add_layout(layout) + layout.add_widget(Button('Back', self._back), 0) + layout.add_widget(Button("Next", self._next), 1) + self.fix() + + def _on_load(self): + self.title = self._model.title + + def _on_username_change(self): + self._username_changed = True + + def _on_username_blur(self): + if not self._username_changed: + return + self._username_changed = False + username = self._username.value + if username == '': + return + self._get_uwldap_info(username) + + def _get_uwldap_info(self, username): + resp = http_get('/api/uwldap/' + username) + if resp.status_code != 200: + return + data = resp.json() + self._full_name.value = data['cn'] + self._program.value = data.get('program', '') + if data.get('mail_local_addresses'): + self._forwarding_address.value = data['mail_local_addresses'][0] + + def _back(self): + raise NextScene(self._model.scene_stack.pop()) + + def _next(self): + self._model.prev_scene = 'AddUser' + body = { + 'uid': self._username.value, + 'cn': self._full_name.value, + } + if self._program.value: + body['program'] = self._program.value + if self._forwarding_address.value: + body['forwarding_addresses'] = [self._forwarding_address.value] + new_terms = get_terms_for_new_user(int(self._num_terms.value)) + if self._model.for_member: + body['terms'] = new_terms + else: + body['non_member_terms'] = new_terms + pairs = user_dict_kv(body) + self._model.confirm_lines = [ + 'The following user will be created:', + '', + ] + pairs + [ + '', + 'Are you sure you want to continue?', + ] + + self._model.deferred_req = defer(http_post, '/api/members', json=body) + self._model.operations = get_adduser_operations(body) + + self._model.scene_stack.append('AddUser') + raise NextScene('Confirm') diff --git a/ceo/tui/members/__init__.py b/ceo/tui/members/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ceo/tui/start.py b/ceo/tui/start.py new file mode 100644 index 0000000..d5e8031 --- /dev/null +++ b/ceo/tui/start.py @@ -0,0 +1,46 @@ +import sys + +from asciimatics.event import KeyboardEvent +from asciimatics.exceptions import ResizeScreenError, StopApplication +from asciimatics.scene import Scene +from asciimatics.screen import Screen + +from .ConfirmView import ConfirmView +from .Model import Model +from .TransactionView import TransactionView +from .WelcomeView import WelcomeView +from .members.AddUserView import AddUserView + + +def unhandled(event): + if isinstance(event, KeyboardEvent): + c = event.key_code + # Stop on 'q' or 'Esc' + if c in (113, 27): + raise StopApplication("User terminated app") + + +def screen_wrapper(screen, scene, model): + model.screen = screen + width = min(screen.width, 90) + height = min(screen.height, 24) + scenes = [ + Scene([WelcomeView(screen, width, height, model)], -1, name='Welcome'), + Scene([AddUserView(screen, width, height, model)], -1, name='AddUser'), + Scene([ConfirmView(screen, width, height, model)], -1, name='Confirm'), + Scene([TransactionView(screen, width, height, model)], -1, name='Transaction'), + ] + screen.play( + scenes, stop_on_resize=True, start_scene=scene, allow_int=True, + unhandled_input=unhandled) + + +def main(): + last_scene = None + model = Model() + while True: + try: + Screen.wrapper(screen_wrapper, arguments=[last_scene, model]) + sys.exit(0) + except ResizeScreenError as e: + last_scene = e.scene diff --git a/ceo/utils.py b/ceo/utils.py index 5673d8c..32ef574 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -1,9 +1,15 @@ -from typing import List, Dict +import functools +import json +import sys +from typing import List, Dict, Tuple, Callable import requests from zope import component +from .StreamResponseHandler import StreamResponseHandler from ceo_common.interfaces import IHTTPClient, IConfig +from ceo_common.model import Term +from ceod.transactions.members import AddMemberTransaction def http_request(method: str, path: str, **kwargs) -> requests.Response: @@ -11,13 +17,10 @@ def http_request(method: str, path: str, **kwargs) -> requests.Response: cfg = component.getUtility(IConfig) if path.startswith('/api/db'): host = cfg.get('ceod_db_host') - delegate = False else: host = cfg.get('ceod_admin_host') - # The forwarded TGT is only needed for endpoints which write to LDAP - delegate = method != 'GET' return client.request( - host, path, method, delegate=delegate, stream=True, **kwargs) + method, host, path, stream=True, **kwargs) def http_get(path: str, **kwargs) -> requests.Response: @@ -56,3 +59,142 @@ def get_failed_operations(data: List[Dict]) -> List[str]: operation = operation[:operation.index(':')] failed.append(operation) return failed + + +def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]: + """ + Pretty-format the lines so that the keys and values + are aligned into columns. + Example: + key1: val1 + key2: val2 + key1000: val3 + val4 + """ + lines = [] + maxlen = max(len(key) for key, val in pairs) + for key, val in pairs: + if key != '': + prefix = key + ': ' + else: + # assume this is a continuation from the previous line + prefix = ' ' + extra_space = ' ' * (maxlen - len(key)) + line = prefix + extra_space + str(val) + lines.append(line) + return lines + + +def get_terms_for_new_user(num_terms: int) -> List[str]: + current_term = Term.current() + terms = [current_term + i for i in range(num_terms)] + return list(map(str, terms)) + + +def user_dict_kv(d: Dict) -> List[Tuple[str]]: + """Pretty-format a serialized User as (key, value) pairs.""" + pairs = [ + ('uid', d['uid']), + ('cn', d['cn']), + ('program', d.get('program', 'Unknown')), + ] + if 'uid_number' in d: + pairs.append(('UID number', d['uid_number'])) + if 'gid_number' in d: + pairs.append(('GID number', d['gid_number'])) + if 'login_shell' in d: + pairs.append(('login shell', d['login_shell'])) + if 'home_directory' in d: + pairs.append(('home directory', d['home_directory'])) + if 'is_club' in d: + pairs.append(('is a club', str(d['is_club']))) + if 'forwarding_addresses' in d: + if len(d['forwarding_addresses']) > 0: + pairs.append(('forwarding addresses', d['forwarding_addresses'][0])) + for address in d['forwarding_addresses'][1:]: + pairs.append(('', address)) + else: + pairs.append(('forwarding addresses', '')) + if 'terms' in d: + pairs.append(('member terms', ','.join(d['terms']))) + if 'non_member_terms' in d: + pairs.append(('non-member terms', ','.join(d['non_member_terms']))) + if 'password' in d: + pairs.append(('password', d['password'])) + return pairs + + +def user_dict_lines(d: Dict) -> List[str]: + """Pretty-format a serialized User.""" + return space_colon_kv(user_dict_kv(d)) + + +def get_adduser_operations(body: Dict): + operations = AddMemberTransaction.operations.copy() + if not body.get('forwarding_addresses'): + # don't bother displaying this because it won't be run + operations.remove('set_forwarding_addresses') + return operations + + +def generic_handle_stream_response( + resp: requests.Response, + operations: List[str], + handler: StreamResponseHandler, +) -> 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: + handler.handle_non_200(resp) + handler.begin() + 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': + handler.handle_aborted(d['error']) + sys.exit(1) + elif d['status'] == 'completed': + while idx < len(operations): + handler.handle_skipped_operation() + idx += 1 + handler.handle_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: + handler.handle_skipped_operation() + idx += 1 + if idx == len(operations): + handler.handle_unrecognized_operation(operation) + continue + if oper_failed: + handler.handle_failed_operation(err_msg) + else: + handler.handle_successful_operation() + idx += 1 + + raise Exception('server response ended abruptly') + + +def defer(f: Callable, *args, **kwargs): + """Defer a function's execution.""" + @functools.wraps(f) + def wrapper(): + return f(*args, **kwargs) + return wrapper diff --git a/ceo_common/interfaces/IHTTPClient.py b/ceo_common/interfaces/IHTTPClient.py index b65eec6..f42564a 100644 --- a/ceo_common/interfaces/IHTTPClient.py +++ b/ceo_common/interfaces/IHTTPClient.py @@ -4,21 +4,20 @@ from zope.interface import Interface class IHTTPClient(Interface): """A helper class for HTTP requests to ceod.""" - def request(host: str, api_path: str, method: str, delegate: bool, **kwargs): + def request(host: str, path: str, method: str, **kwargs): """ Make an HTTP request. - If `delegate` is True, GSSAPI credentials will be forwarded to the - remote. + **kwargs are passed to requests.request(). """ - def get(host: str, api_path: str, delegate: bool = True, **kwargs): + def get(host: str, path: str, **kwargs): """Make a GET request.""" - def post(host: str, api_path: str, delegate: bool = True, **kwargs): + def post(host: str, path: str, **kwargs): """Make a POST request.""" - def patch(host: str, api_path: str, delegate: bool = True, **kwargs): + def patch(host: str, path: str, **kwargs): """Make a PATCH request.""" - def delete(host: str, api_path: str, delegate: bool = True, **kwargs): + def delete(host: str, path: str, **kwargs): """Make a DELETE request.""" diff --git a/ceo_common/model/HTTPClient.py b/ceo_common/model/HTTPClient.py index d63b85a..67fbc11 100644 --- a/ceo_common/model/HTTPClient.py +++ b/ceo_common/model/HTTPClient.py @@ -1,4 +1,5 @@ import flask +from flask import g import gssapi import requests from requests_gssapi import HTTPSPNEGOAuth @@ -20,40 +21,51 @@ class HTTPClient: self.ceod_port = cfg.get('ceod_port') self.base_domain = cfg.get('base_domain') - def request(self, host: str, api_path: str, method: str, delegate: bool, **kwargs): + def request(self, method: str, host: str, path: str, **kwargs): # always use the FQDN if '.' not in host: host = host + '.' + self.base_domain + if method == 'GET': + # This is the only GET endpoint which requires auth + need_auth = path.startswith('/api/members') + delegate = False + else: + need_auth = True + delegate = True + # SPNEGO - spnego_kwargs = { - 'opportunistic_auth': True, - 'target_name': gssapi.Name('ceod/' + host), - } - if flask.has_request_context() and 'client_token' in flask.g: - # This is reached when we are the server and the client has forwarded - # their credentials to us. - spnego_kwargs['creds'] = gssapi.Credentials(token=flask.g.client_token) - if delegate: - # This is reached when we are the client and we want to forward our - # credentials to the server. - spnego_kwargs['delegate'] = True - auth = HTTPSPNEGOAuth(**spnego_kwargs) + if need_auth: + spnego_kwargs = { + 'opportunistic_auth': True, + 'target_name': gssapi.Name('ceod/' + host), + } + if flask.has_request_context() and 'client_token' in g: + # This is reached when we are the server and the client has + # forwarded their credentials to us. + spnego_kwargs['creds'] = gssapi.Credentials(token=flask.g.client_token) + elif delegate: + # This is reached when we are the client and we want to + # forward our credentials to the server. + spnego_kwargs['delegate'] = True + auth = HTTPSPNEGOAuth(**spnego_kwargs) + else: + auth = None return requests.request( method, - f'{self.scheme}://{host}:{self.ceod_port}{api_path}', + f'{self.scheme}://{host}:{self.ceod_port}{path}', auth=auth, **kwargs, ) - def get(self, host: str, api_path: str, delegate: bool = True, **kwargs): - return self.request(host, api_path, 'GET', delegate, **kwargs) + def get(self, host: str, path: str, **kwargs): + return self.request('GET', host, path, **kwargs) - def post(self, host: str, api_path: str, delegate: bool = True, **kwargs): - return self.request(host, api_path, 'POST', delegate, **kwargs) + def post(self, host: str, path: str, **kwargs): + return self.request('POST', host, path, **kwargs) - def patch(self, host: str, api_path: str, delegate: bool = True, **kwargs): - return self.request(host, api_path, 'PATCH', delegate, **kwargs) + def patch(self, host: str, path: str, **kwargs): + return self.request('PATCH', host, path, **kwargs) - def delete(self, host: str, api_path: str, delegate: bool = True, **kwargs): - return self.request(host, api_path, 'DELETE', delegate, **kwargs) + def delete(self, host: str, path: str, **kwargs): + return self.request('DELETE', host, path, **kwargs) diff --git a/ceo_common/model/RemoteMailmanService.py b/ceo_common/model/RemoteMailmanService.py index f6f23de..c1de3a2 100644 --- a/ceo_common/model/RemoteMailmanService.py +++ b/ceo_common/model/RemoteMailmanService.py @@ -15,8 +15,7 @@ class RemoteMailmanService: def subscribe(self, address: str, mailing_list: str): resp = self.http_client.post( - self.mailman_host, f'/api/mailman/{mailing_list}/{address}', - delegate=False) + self.mailman_host, f'/api/mailman/{mailing_list}/{address}') if not resp.ok: if resp.status_code == 409: raise UserAlreadySubscribedError() @@ -26,8 +25,7 @@ class RemoteMailmanService: def unsubscribe(self, address: str, mailing_list: str): resp = self.http_client.delete( - self.mailman_host, f'/api/mailman/{mailing_list}/{address}', - delegate=False) + self.mailman_host, f'/api/mailman/{mailing_list}/{address}') if not resp.ok: if resp.status_code == 404: raise UserNotSubscribedError() diff --git a/gen_cred.py b/gen_cred.py deleted file mode 100755 index f4de443..0000000 --- a/gen_cred.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 - -from base64 import b64encode -import sys - -from ceo_common.krb5.utils import get_fwd_tgt - -if len(sys.argv) != 2: - print(f'Usage: {sys.argv[0]} ', file=sys.stderr) - sys.exit(1) - -b = get_fwd_tgt('ceod/' + sys.argv[1]) -with open('cred', 'wb') as f: - f.write(b64encode(b)) diff --git a/requirements.txt b/requirements.txt index d416050..537266e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +asciimatics==1.13.0 click==8.0.1 Flask==2.0.1 gssapi==1.6.14 diff --git a/tests/ceo/cli/test_groups.py b/tests/ceo/cli/test_groups.py index c81802e..8db4f6e 100644 --- a/tests/ceo/cli/test_groups.py +++ b/tests/ceo/cli/test_groups.py @@ -45,6 +45,7 @@ def test_groups(cli_setup, ldap_user): f"Are you sure you want to add {ldap_user.uid} to test_group_1? [y/N]: y\n" "Add user to group... Done\n" "Add user to auxiliary groups... Skipped\n" + "Subscribe user to auxiliary mailing lists... Skipped\n" "Transaction successfully completed.\n" "Added to groups: test_group_1\n" ) @@ -65,6 +66,7 @@ def test_groups(cli_setup, ldap_user): f"Are you sure you want to remove {ldap_user.uid} from test_group_1? [y/N]: y\n" "Remove user from group... Done\n" "Remove user from auxiliary groups... Skipped\n" + "Unsubscribe user from auxiliary mailing lists... Skipped\n" "Transaction successfully completed.\n" "Removed from groups: test_group_1\n" ) diff --git a/tests/ceo/cli/test_members.py b/tests/ceo/cli/test_members.py index a4fce2a..2670e69 100644 --- a/tests/ceo/cli/test_members.py +++ b/tests/ceo/cli/test_members.py @@ -12,15 +12,16 @@ def test_members_get(cli_setup, ldap_user): runner = CliRunner() result = runner.invoke(cli, ['members', 'get', ldap_user.uid]) expected = ( - f"uid: {ldap_user.uid}\n" - f"cn: {ldap_user.cn}\n" - f"program: {ldap_user.program}\n" - f"UID number: {ldap_user.uid_number}\n" - f"GID number: {ldap_user.gid_number}\n" - f"login shell: {ldap_user.login_shell}\n" - f"home directory: {ldap_user.home_directory}\n" - f"is a club: {ldap_user.is_club()}\n" - f"terms: {','.join(ldap_user.terms)}\n" + f"uid: {ldap_user.uid}\n" + f"cn: {ldap_user.cn}\n" + f"program: {ldap_user.program}\n" + f"UID number: {ldap_user.uid_number}\n" + f"GID number: {ldap_user.gid_number}\n" + f"login shell: {ldap_user.login_shell}\n" + f"home directory: {ldap_user.home_directory}\n" + f"is a club: {ldap_user.is_club()}\n" + "forwarding addresses: \n" + f"member terms: {','.join(ldap_user.terms)}\n" ) assert result.exit_code == 0 assert result.output == expected @@ -34,11 +35,11 @@ def test_members_add(cli_setup): ], input='y\n') expected_pat = re.compile(( "^The following user will be created:\n" - "uid: test_1\n" - "cn: Test One\n" - "program: Math\n" - "member terms: [sfw]\\d{4}\n" - "forwarding address: test_1@uwaterloo.internal\n" + "uid: test_1\n" + "cn: Test One\n" + "program: Math\n" + "forwarding addresses: test_1@uwaterloo.internal\n" + "member terms: [sfw]\\d{4}\n" "Do you want to continue\\? \\[y/N\\]: y\n" "Add user to LDAP... Done\n" "Add group to LDAP... Done\n" @@ -58,7 +59,7 @@ def test_members_add(cli_setup): "home directory: [a-z0-9/_-]+/test_1\n" "is a club: False\n" "forwarding addresses: test_1@uwaterloo.internal\n" - "terms: [sfw]\\d{4}\n" + "member terms: [sfw]\\d{4}\n" "password: \\S+\n$" ), re.MULTILINE) assert result.exit_code == 0 diff --git a/tests/conftest.py b/tests/conftest.py index e6c51d1..d659e93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -369,16 +369,13 @@ def app_process(cfg, app, http_client): proc.start() try: - # Currently the HTTPClient uses SPNEGO for all requests, - # even GETs - with gssapi_token_ctx('ctdalek'): - for i in range(5): - try: - http_client.get(hostname, '/ping', delegate=False) - except requests.exceptions.ConnectionError: - time.sleep(1) - continue - break + for i in range(5): + try: + http_client.get(hostname, '/ping') + except requests.exceptions.ConnectionError: + time.sleep(1) + continue + break assert i != 5, 'Timed out' yield finally: diff --git a/tests/conftest_ceod_api.py b/tests/conftest_ceod_api.py index c2941ab..f60c37b 100644 --- a/tests/conftest_ceod_api.py +++ b/tests/conftest_ceod_api.py @@ -46,12 +46,14 @@ class CeodTestClient: headers = list(req.prepare().headers.items()) return headers - def request(self, method: str, path: str, principal: str, delegate: bool, **kwargs): + def request(self, method, path, principal, need_auth, delegate, **kwargs): # make sure that we're not already in a Flask context assert not flask.has_app_context() - if principal is None: - principal = self.syscom_principal - headers = self.get_headers(principal, delegate) + if need_auth: + principal = principal or self.syscom_principal + headers = self.get_headers(principal, delegate) + else: + headers = [] resp = self.client.open(path, method=method, headers=headers, **kwargs) status = int(resp.status.split(' ', 1)[0]) if resp.headers['content-type'] == 'application/json': @@ -60,14 +62,14 @@ class CeodTestClient: data = [json.loads(line) for line in resp.data.splitlines()] return status, data - def get(self, path, principal=None, delegate=True, **kwargs): - return self.request('GET', path, principal, delegate, **kwargs) + def get(self, path, principal=None, need_auth=True, delegate=True, **kwargs): + return self.request('GET', path, principal, need_auth, delegate, **kwargs) - def post(self, path, principal=None, delegate=True, **kwargs): - return self.request('POST', path, principal, delegate, **kwargs) + def post(self, path, principal=None, need_auth=True, delegate=True, **kwargs): + return self.request('POST', path, principal, need_auth, delegate, **kwargs) - def patch(self, path, principal=None, delegate=True, **kwargs): - return self.request('PATCH', path, principal, delegate, **kwargs) + def patch(self, path, principal=None, need_auth=True, delegate=True, **kwargs): + return self.request('PATCH', path, principal, need_auth, delegate, **kwargs) - def delete(self, path, principal=None, delegate=True, **kwargs): - return self.request('DELETE', path, principal, delegate, **kwargs) + def delete(self, path, principal=None, need_auth=True, delegate=True, **kwargs): + return self.request('DELETE', path, principal, need_auth, delegate, **kwargs) From c6c01d8720f5f2fb2fd29ef818caf2f8a2d88ea9 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 4 Sep 2021 22:25:37 -0400 Subject: [PATCH 02/17] allow mysql connections from unix socket (#14) Co-authored-by: Andrew Wang Co-authored-by: Max Erenberg Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/14 Co-authored-by: Andrew Wang Co-committed-by: Andrew Wang --- ceo/cli/utils.py | 3 --- ceo/tui/TransactionView.py | 2 +- ceod/db/MySQLService.py | 16 +++++++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index 76389c9..d50f38e 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -1,12 +1,9 @@ -import json import socket -import sys from typing import List, Tuple, Dict import click import requests -from ..operation_strings import descriptions as op_desc from ..utils import space_colon_kv, generic_handle_stream_response from .CLIStreamResponseHandler import CLIStreamResponseHandler diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py index 1773cfd..7adc26b 100644 --- a/ceo/tui/TransactionView.py +++ b/ceo/tui/TransactionView.py @@ -45,7 +45,7 @@ class TransactionView(Frame): if self._loaded: return self._loaded = True - + for _ in range(2): self._add_line() for operation in self._model.operations: diff --git a/ceod/db/MySQLService.py b/ceod/db/MySQLService.py index 043a906..e6a194d 100644 --- a/ceod/db/MySQLService.py +++ b/ceod/db/MySQLService.py @@ -46,17 +46,21 @@ class MySQLService: password = gen_password() search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" search_for_db = f"SHOW DATABASES LIKE '{username}'" - create_user = f""" - CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s; - """ + # CREATE USER can't be used in a query with multiple statements + create_user_commands = [ + f"CREATE USER '{username}'@'localhost' IDENTIFIED BY %(password)s", + f"CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s", + ] create_database = f""" CREATE DATABASE {username}; + GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost'; GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'%'; """ with self.mysql_connection() as con, con.cursor() as cursor: if response_is_empty(search_for_user, con): - cursor.execute(create_user, {'password': password}) + for cmd in create_user_commands: + cursor.execute(cmd, {'password': password}) if response_is_empty(search_for_db, con): cursor.execute(create_database) else: @@ -67,7 +71,8 @@ class MySQLService: password = gen_password() search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" reset_password = f""" - ALTER USER '{username}'@'%' IDENTIFIED BY %(password)s + ALTER USER '{username}'@'localhost' IDENTIFIED BY %(password)s; + ALTER USER '{username}'@'%' IDENTIFIED BY %(password)s; """ with self.mysql_connection() as con, con.cursor() as cursor: @@ -80,6 +85,7 @@ class MySQLService: def delete_db(self, username: str): drop_db = f"DROP DATABASE IF EXISTS {username}" drop_user = f""" + DROP USER IF EXISTS '{username}'@'localhost'; DROP USER IF EXISTS '{username}'@'%'; """ From cce920d6baeb35855d8b1c019f61c736bd0c6d1d Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Sun, 5 Sep 2021 22:48:20 +0000 Subject: [PATCH 03/17] save view state in model --- ceo/cli/CLIStreamResponseHandler.py | 3 ++ ceo/tui/Model.py | 29 +++++++++-- ceo/tui/TUIStreamResponseHandler.py | 26 ++++------ ceo/tui/TransactionView.py | 77 ++++++++++++++++++++--------- ceo/tui/members/AddUserView.py | 44 ++++++++++++----- ceo/tui/start.py | 24 ++++++--- ceo/utils.py | 5 +- 7 files changed, 146 insertions(+), 62 deletions(-) diff --git a/ceo/cli/CLIStreamResponseHandler.py b/ceo/cli/CLIStreamResponseHandler.py index de0dae0..cb6ca76 100644 --- a/ceo/cli/CLIStreamResponseHandler.py +++ b/ceo/cli/CLIStreamResponseHandler.py @@ -1,3 +1,4 @@ +import sys from typing import List, Union import click @@ -20,6 +21,7 @@ class Abort(click.ClickException): class CLIStreamResponseHandler(StreamResponseHandler): def __init__(self, operations: List[str]): + super().__init__() self.operations = operations self.idx = 0 @@ -36,6 +38,7 @@ class CLIStreamResponseHandler(StreamResponseHandler): click.echo('The transaction was rolled back.') click.echo('The error was: ' + err_msg) click.echo('Please check the ceod logs.') + sys.exit(1) def handle_completed(self): click.echo('Transaction successfully completed.') diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 01c4453..de73991 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -1,12 +1,35 @@ +from copy import deepcopy + class Model: - """A convenient place to share data beween views.""" + """A convenient place to store View data persistently.""" def __init__(self): # simple key-value pairs self.screen = None self.title = None - self.for_member = True self.scene_stack = [] + self.deferred_req = None + # view-specific data, to be used when e.g. resizing the window + self._initial_viewdata = { + 'adduser': { + 'uid': '', + 'cn': '', + 'program': '', + 'forwarding_address': '', + 'num_terms': '1', + }, + 'transaction': { + 'op_layout': None, + 'msg_layout': None, + 'labels': {}, + 'status': 'not started', + }, + } + self.viewdata = deepcopy(self._initial_viewdata) + # data which is shared between multiple views + self.for_member = True self.confirm_lines = None self.operations = None - self.deferred_req = None + + def reset_viewdata(self): + self.viewdata = deepcopy(self._initial_viewdata) diff --git a/ceo/tui/TUIStreamResponseHandler.py b/ceo/tui/TUIStreamResponseHandler.py index d400448..1c83f21 100644 --- a/ceo/tui/TUIStreamResponseHandler.py +++ b/ceo/tui/TUIStreamResponseHandler.py @@ -1,6 +1,6 @@ from typing import Dict, Union -from asciimatics.widgets import Label, Button, Layout, Frame +from asciimatics.widgets import Label, Layout import requests from .Model import Model @@ -12,30 +12,25 @@ class TUIStreamResponseHandler(StreamResponseHandler): self, model: Model, labels: Dict[str, Label], - next_btn: Button, msg_layout: Layout, - frame: Frame, + txn_view, # TransactionView ): + super().__init__() self.screen = model.screen self.operations = model.operations self.idx = 0 self.labels = labels - self.next_btn = next_btn self.msg_layout = msg_layout - self.frame = frame + self.txn_view = txn_view self.error_messages = [] def _update(self): # Since we're running in a separate thread, we need to force the # screen to update. See # https://github.com/peterbrittain/asciimatics/issues/56 - self.frame.fix() + self.txn_view.fix() self.screen.force_update() - def _enable_next_btn(self): - self.next_btn.disabled = False - self.frame.reset() - def _show_msg(self, msg: str = ''): for line in msg.splitlines(): self.msg_layout.add_widget(Label(line, align='^')) @@ -43,7 +38,7 @@ class TUIStreamResponseHandler(StreamResponseHandler): def _abort(self): for operation in self.operations[self.idx:]: self.labels[operation].text = 'ABORTED' - self._enable_next_btn() + self.txn_view.enable_next_btn() def handle_non_200(self, resp: requests.Response): self._abort() @@ -65,11 +60,10 @@ class TUIStreamResponseHandler(StreamResponseHandler): def handle_completed(self): self._show_msg('Transaction successfully completed.') if len(self.error_messages) > 0: - self._show_msg('There were some errors, please check the ' - 'ceod logs.') - # we don't have enough space in the TUI to actually - # show the error messages - self._enable_next_btn() + self._show_msg('There were some errors:') + for msg in self.error_messages: + self._show_msg(msg) + self.txn_view.enable_next_btn() self._update() def handle_successful_operation(self): diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py index 1773cfd..2a98684 100644 --- a/ceo/tui/TransactionView.py +++ b/ceo/tui/TransactionView.py @@ -20,9 +20,9 @@ class TransactionView(Frame): ) self._model = model # map operation names to label widgets - self._labels = {} + self._labels = model.viewdata['transaction']['labels'] # this is an ugly hack to get around the fact that _on_load() - # will be called again when we reset() in the TUIStreamResponseHandler + # will be called again when we reset() in enable_next_btn. self._loaded = False def _add_buttons(self): @@ -33,49 +33,80 @@ class TransactionView(Frame): layout = Layout([1, 1]) self.add_layout(layout) self._next_btn = Button('Next', self._next) - self._next_btn.disabled = True + # we don't want to disable the button if the txn completed + # and the user just resized the window + if self._model.viewdata['transaction']['status'] != 'completed': + self._next_btn.disabled = True layout.add_widget(self._next_btn, 1) - def _add_line(self, text: str = ''): - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Label(text, align='^')) + def _add_blank_line(self): + self._op_layout.add_widget(Label(''), 0) + self._op_layout.add_widget(Label(''), 2) def _on_load(self): if self._loaded: return self._loaded = True - for _ in range(2): - self._add_line() - for operation in self._model.operations: - desc = op_desc[operation] - layout = Layout([10, 1, 10]) - self.add_layout(layout) - layout.add_widget(Label(desc + '...', align='>'), 0) - desc_label = Label('', align='<') - layout.add_widget(desc_label, 2) - self._labels[operation] = desc_label - self._add_line() - self._msg_layout = Layout([100]) - self.add_layout(self._msg_layout) + d = self._model.viewdata['transaction'] + first_time = True + if d['op_layout'] is None: + self._op_layout = Layout([10, 1, 10]) + self.add_layout(self._op_layout) + # store the layouts so that we can re-use them when the screen + # gets resized + d['op_layout'] = self._op_layout + for _ in range(2): + self._add_blank_line() + for operation in self._model.operations: + desc = op_desc[operation] + self._op_layout.add_widget(Label(desc + '...', align='>'), 0) + desc_label = Label('', align='<') + self._op_layout.add_widget(desc_label, 2) + self._labels[operation] = desc_label + self._add_blank_line() + # this is the where success/failure messages etc. get placed + self._msg_layout = Layout([100]) + self.add_layout(self._msg_layout) + d['msg_layout'] = self._msg_layout + else: + # we arrive here when the screen has been resized + first_time = False + # restore the layouts which we saved + self._op_layout = d['op_layout'] + self.add_layout(self._op_layout) + self._msg_layout = d['msg_layout'] + self.add_layout(self._msg_layout) + # fill up the rest of the space self.add_layout(Layout([100], fill_frame=True)) self._add_buttons() self.fix() - Thread(target=self._do_txn).start() + # only send the API request the first time we arrive at this + # scene, not when the screen gets resized + if first_time: + Thread(target=self._do_txn).start() def _do_txn(self): + self._model.viewdata['transaction']['status'] = 'in progress' resp = self._model.deferred_req() handler = TUIStreamResponseHandler( model=self._model, labels=self._labels, - next_btn=self._next_btn, msg_layout=self._msg_layout, - frame=self, + txn_view=self, ) generic_handle_stream_response(resp, self._model.operations, handler) + def enable_next_btn(self): + self._next_btn.disabled = False + # If we don't reset, the button isn't selectable, even though we + # enabled it + self.reset() + # save the fact that the transaction is completed + self._model.viewdata['transaction']['status'] = 'completed' + def _next(self): + self._model.reset_viewdata() self._model.scene_stack.clear() raise NextScene('Welcome') diff --git a/ceo/tui/members/AddUserView.py b/ceo/tui/members/AddUserView.py index ee2d747..8bf07cb 100644 --- a/ceo/tui/members/AddUserView.py +++ b/ceo/tui/members/AddUserView.py @@ -1,5 +1,7 @@ +from threading import Thread + from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Text, Button, Divider +from asciimatics.widgets import Frame, Layout, Text, Button, Divider, Label from ...utils import http_get, http_post, defer, user_dict_kv, \ get_terms_for_new_user, get_adduser_operations @@ -16,6 +18,7 @@ class AddUserView(Frame): ) self._model = model self._username_changed = False + layout = Layout([100], fill_frame=True) self.add_layout(layout) self._username = Text( @@ -33,9 +36,13 @@ class AddUserView(Frame): self._num_terms = Text( "Number of terms:", "num_terms", validator=lambda s: s.isdigit() and s[0] != '0') - self._num_terms.value = '1' layout.add_widget(self._num_terms) + layout = Layout([100]) + self.add_layout(layout) + self._status_label = Label('') + layout.add_widget(self._status_label) + layout = Layout([100]) self.add_layout(layout) layout.add_widget(Divider()) @@ -48,6 +55,14 @@ class AddUserView(Frame): def _on_load(self): self.title = self._model.title + # restore the saved input fields' values + self.data = self._model.viewdata['adduser'] + + def _on_unload(self): + # save the input fields' values so that they don't disappear when + # the window gets resized + self.save() + self._model.viewdata['adduser'] = self.data def _on_username_change(self): self._username_changed = True @@ -59,23 +74,28 @@ class AddUserView(Frame): username = self._username.value if username == '': return - self._get_uwldap_info(username) + Thread(target=self._get_uwldap_info, args=[username]).start() + #self._get_uwldap_info(username) def _get_uwldap_info(self, username): - resp = http_get('/api/uwldap/' + username) - if resp.status_code != 200: - return - data = resp.json() - self._full_name.value = data['cn'] - self._program.value = data.get('program', '') - if data.get('mail_local_addresses'): - self._forwarding_address.value = data['mail_local_addresses'][0] + self._status_label.text = 'Searching for user...' + try: + resp = http_get('/api/uwldap/' + username) + if resp.status_code != 200: + return + data = resp.json() + self._status_label.text = '' + self._full_name.value = data['cn'] + self._program.value = data.get('program', '') + if data.get('mail_local_addresses'): + self._forwarding_address.value = data['mail_local_addresses'][0] + finally: + self._status_label.text = '' def _back(self): raise NextScene(self._model.scene_stack.pop()) def _next(self): - self._model.prev_scene = 'AddUser' body = { 'uid': self._username.value, 'cn': self._full_name.value, diff --git a/ceo/tui/start.py b/ceo/tui/start.py index d5e8031..0726b5c 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -20,18 +20,30 @@ def unhandled(event): raise StopApplication("User terminated app") -def screen_wrapper(screen, scene, model): +# tuples of (name, view) +views = [] + + +def screen_wrapper(screen, last_scene, model): + global views model.screen = screen + # unload the old views + for name, view in views: + if hasattr(view, '_on_unload'): + view._on_unload() width = min(screen.width, 90) height = min(screen.height, 24) + views = [ + ('Welcome', WelcomeView(screen, width, height, model)), + ('AddUser', AddUserView(screen, width, height, model)), + ('Confirm', ConfirmView(screen, width, height, model)), + ('Transaction', TransactionView(screen, width, height, model)), + ] scenes = [ - Scene([WelcomeView(screen, width, height, model)], -1, name='Welcome'), - Scene([AddUserView(screen, width, height, model)], -1, name='AddUser'), - Scene([ConfirmView(screen, width, height, model)], -1, name='Confirm'), - Scene([TransactionView(screen, width, height, model)], -1, name='Transaction'), + Scene([view], -1, name=name) for name, view in views ] screen.play( - scenes, stop_on_resize=True, start_scene=scene, allow_int=True, + scenes, stop_on_resize=True, start_scene=last_scene, allow_int=True, unhandled_input=unhandled) diff --git a/ceo/utils.py b/ceo/utils.py index 8a71d68..eb1b812 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -149,15 +149,16 @@ def generic_handle_stream_response( """ if resp.status_code != 200: handler.handle_non_200(resp) + return handler.begin() idx = 0 data = [] - for line in resp.iter_lines(decode_unicode=True, chunk_size=8): + for line in resp.iter_lines(decode_unicode=True, chunk_size=1): d = json.loads(line) data.append(d) if d['status'] == 'aborted': handler.handle_aborted(d['error']) - sys.exit(1) + return elif d['status'] == 'completed': while idx < len(operations): handler.handle_skipped_operation() From ee21873ad743db386f150280eabb49cb049b91c9 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Mon, 6 Sep 2021 16:40:05 +0000 Subject: [PATCH 04/17] implement membership renewals in TUI --- ceo/cli/members.py | 24 ++------- ceo/cli/utils.py | 3 -- ceo/term_utils.py | 38 +++++++++++++ ceo/tui/CeoFrame.py | 92 ++++++++++++++++++++++++++++++++ ceo/tui/ConfirmView.py | 43 +++++---------- ceo/tui/ErrorView.py | 29 ++++++++++ ceo/tui/Model.py | 25 ++++++--- ceo/tui/ResultView.py | 66 +++++++++++++++++++++++ ceo/tui/TransactionView.py | 21 ++++---- ceo/tui/WelcomeView.py | 2 +- ceo/tui/members/AddUserView.py | 56 ++++++------------- ceo/tui/members/RenewUserView.py | 64 ++++++++++++++++++++++ ceo/tui/start.py | 8 ++- ceo/tui/utils.py | 10 ++++ ceo/utils.py | 8 --- 15 files changed, 369 insertions(+), 120 deletions(-) create mode 100644 ceo/term_utils.py create mode 100644 ceo/tui/CeoFrame.py create mode 100644 ceo/tui/ErrorView.py create mode 100644 ceo/tui/ResultView.py create mode 100644 ceo/tui/members/RenewUserView.py create mode 100644 ceo/tui/utils.py diff --git a/ceo/cli/members.py b/ceo/cli/members.py index 6ad0243..0411463 100644 --- a/ceo/cli/members.py +++ b/ceo/cli/members.py @@ -4,13 +4,12 @@ from typing import Dict import click from zope import component +from ..term_utils import get_terms_for_new_user, get_terms_for_renewal from ..utils import http_post, http_get, http_patch, http_delete, \ - get_failed_operations, get_terms_for_new_user, user_dict_lines, \ - get_adduser_operations + get_failed_operations, user_dict_lines, get_adduser_operations from .utils import handle_stream_response, handle_sync_response, print_lines, \ check_if_in_development from ceo_common.interfaces import IConfig -from ceo_common.model import Term from ceod.transactions.members import DeleteMemberTransaction @@ -24,7 +23,7 @@ def members(): @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') + help='Number of terms to add', default=1) @click.option('--clubrep', is_flag=True, default=False, help='Add non-member terms instead of member terms') @click.option('--forwarding-address', required=False, @@ -133,22 +132,7 @@ def modify(username, login_shell, forwarding_addresses): @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)) + terms = get_terms_for_renewal(username, num_terms, clubrep) if clubrep: body = {'non_member_terms': terms} diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index 76389c9..d50f38e 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -1,12 +1,9 @@ -import json import socket -import sys from typing import List, Tuple, Dict import click import requests -from ..operation_strings import descriptions as op_desc from ..utils import space_colon_kv, generic_handle_stream_response from .CLIStreamResponseHandler import CLIStreamResponseHandler diff --git a/ceo/term_utils.py b/ceo/term_utils.py new file mode 100644 index 0000000..5460b33 --- /dev/null +++ b/ceo/term_utils.py @@ -0,0 +1,38 @@ +from typing import List + +from .utils import http_get +from ceo_common.model import Term +import ceo.cli.utils as cli_utils +import ceo.tui.utils as tui_utils + +# Had to put these in a separate file to avoid a circular import. + + +def get_terms_for_new_user(num_terms: int) -> List[str]: + current_term = Term.current() + terms = [current_term + i for i in range(num_terms)] + return list(map(str, terms)) + + +def get_terms_for_renewal( + username: str, num_terms: int, clubrep: bool, tui_model=None, +) -> List[str]: + resp = http_get('/api/members/' + username) + if tui_model is None: + result = cli_utils.handle_sync_response(resp) + else: + result = tui_utils.handle_sync_response(resp, tui_model) + 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)] + return list(map(str, terms)) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py new file mode 100644 index 0000000..fb113ba --- /dev/null +++ b/ceo/tui/CeoFrame.py @@ -0,0 +1,92 @@ +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Frame, Layout, Divider, Button + + +class CeoFrame(Frame): + def __init__( + self, + screen, + height, + width, + model, + name, # key in model.viewdata + on_load=None, + title=None, + save_data=False, # whether to save widget state for resizing + ): + super().__init__( + screen, + height, + width, + name=name, + can_scroll=False, + title=title, + on_load=self._ceoframe_on_load, + ) + self._save_data = save_data + self._extra_on_load = on_load + self._model = model + self._name = name + self._loaded = False + + def _ceoframe_on_load(self): + # We usually don't want _on_load() to be called multiple times + # e.g. when switching back to a scene + if self._loaded: + return + self._loaded = True + if self._model.title is not None: + self.title = self._model.title + self._model.title = None + if self._save_data: + # restore the saved input fields' values + self.data = self._model.viewdata[self._name] + if self._extra_on_load is not None: + self._extra_on_load() + + def _on_unload(self): + if not self._save_data: + return + # save the input fields' values so that they don't disappear when + # the window gets resized + self.save() + self._model.viewdata[self._name] = self.data + + def add_buttons( + self, back_btn=False, back_btn_text='Back', + next_scene=None, next_scene_text='Next', on_next=None, + on_next_excl=None, + ): + """ + Add a new layout at the bottom of the frame with buttons. + If back_btn is True, a Back button is added. + If next_scene is set to the name of the next scene, or on_next_excl + is set, a Next button will be added. + If on_next is set to a function, it will be called when the Next + button is pressed, and the screen will switch to the next scene. + If on_next_excl is set to a function, it will be called when the Next + button is pressed, and the scene will not be switched. + If both on_next and on_next_excl are set, on_next will be ignored. + """ + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Divider()) + + def _back(): + raise NextScene(self._model.scene_stack.pop()) + + def _next(): + if on_next_excl is not None: + on_next_excl() + return + if on_next is not None: + on_next() + self._model.scene_stack.append(self._name) + raise NextScene(next_scene) + + layout = Layout([1, 1]) + self.add_layout(layout) + if back_btn: + layout.add_widget(Button(back_btn_text, _back), 0) + if next_scene is not None or on_next_excl is not None: + layout.add_widget(Button(next_scene_text, _next), 1) diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py index ebf3fbe..8574643 100644 --- a/ceo/tui/ConfirmView.py +++ b/ceo/tui/ConfirmView.py @@ -1,28 +1,14 @@ -from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Button, Divider, Label +from asciimatics.widgets import Layout, Label + +from .CeoFrame import CeoFrame -class ConfirmView(Frame): +class ConfirmView(CeoFrame): def __init__(self, screen, width, height, model): super().__init__( - screen, - height, - width, - can_scroll=False, - on_load=self._on_load, - title='Confirmation', + screen, height, width, model, 'Confirm', + on_load=self._confirmview_on_load, title='Confirmation', ) - self._model = model - - def _add_buttons(self): - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Divider()) - - layout = Layout([1, 1]) - self.add_layout(layout) - layout.add_widget(Button('No', self._back), 0) - layout.add_widget(Button('Yes', self._next), 1) def _add_line(self, text: str = ''): layout = Layout([100]) @@ -35,7 +21,7 @@ class ConfirmView(Frame): layout.add_widget(Label(key + ':', align='>'), 0) layout.add_widget(Label(val, align='<'), 2) - def _on_load(self): + def _confirmview_on_load(self): for _ in range(2): self._add_line() for line in self._model.confirm_lines: @@ -48,12 +34,11 @@ class ConfirmView(Frame): # fill the rest of the space self.add_layout(Layout([100], fill_frame=True)) - self._add_buttons() + if self._model.operations is not None: + next_scene = 'Transaction' + else: + next_scene = 'Result' + self.add_buttons( + back_btn=True, back_btn_text='No', + next_scene=next_scene, next_scene_text='Yes') self.fix() - - def _back(self): - raise NextScene(self._model.scene_stack.pop()) - - def _next(self): - self._model.scene_stack.append('Confirm') - raise NextScene('Transaction') diff --git a/ceo/tui/ErrorView.py b/ceo/tui/ErrorView.py new file mode 100644 index 0000000..2615ed5 --- /dev/null +++ b/ceo/tui/ErrorView.py @@ -0,0 +1,29 @@ +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Layout, Label + +from .CeoFrame import CeoFrame + + +class ErrorView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'Error', + on_load=self._errorview_on_load, title='Error', + ) + + def _errorview_on_load(self): + layout = Layout([1, 10], fill_frame=True) + self.add_layout(layout) + for _ in range(2): + layout.add_widget(Label(''), 1) + layout.add_widget(Label('An error occurred:'), 1) + layout.add_widget(Label(''), 1) + for line in self._model.error_message.splitlines(): + layout.add_widget(Label(line), 1) + + self.add_buttons(on_next_excl=self._next) + self.fix() + + def _next(self): + self._model.reset() + raise NextScene('Welcome') diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index de73991..793ce32 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -1,35 +1,48 @@ from copy import deepcopy + class Model: """A convenient place to store View data persistently.""" def __init__(self): - # simple key-value pairs self.screen = None self.title = None self.scene_stack = [] - self.deferred_req = None + self.error_message = None # view-specific data, to be used when e.g. resizing the window self._initial_viewdata = { - 'adduser': { + 'AddUser': { 'uid': '', 'cn': '', 'program': '', 'forwarding_address': '', 'num_terms': '1', }, - 'transaction': { + 'RenewUser': { + 'uid': '', + 'num_terms': '1', + }, + 'Transaction': { 'op_layout': None, 'msg_layout': None, 'labels': {}, 'status': 'not started', }, + 'Result': {}, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views - self.for_member = True + self.is_club_rep = False self.confirm_lines = None self.operations = None + self.deferred_req = None - def reset_viewdata(self): + def reset(self): self.viewdata = deepcopy(self._initial_viewdata) + self.is_club_rep = False + self.confirm_lines = None + self.operations = None + self.deferred_req = None + self.title = None + self.error_message = None + self.scene_stack.clear() diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py new file mode 100644 index 0000000..a22a1df --- /dev/null +++ b/ceo/tui/ResultView.py @@ -0,0 +1,66 @@ +from threading import Thread + +from asciimatics.exceptions import NextScene +from asciimatics.widgets import Layout, Label, Button, Divider +import requests + +from .CeoFrame import CeoFrame + + +class ResultView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'Result', + on_load=self._resultview_on_load, title='Result', + ) + layout = Layout([1, 10]) + self.add_layout(layout) + layout.add_widget(Label(''), 1) + self._status_label = Label('Sending request... ') + layout.add_widget(self._status_label, 1) + layout.add_widget(Label(''), 1) + self._summary_layout = Layout([1, 10], fill_frame=True) + self.add_layout(self._summary_layout) + + self._add_buttons() + self.fix() + + def _add_buttons(self): + layout = Layout([100]) + self.add_layout(layout) + layout.add_widget(Divider()) + + layout = Layout([1, 1]) + self.add_layout(layout) + self._next_btn = Button('Next', self._next) + self._next_btn.disabled = True + layout.add_widget(self._next_btn, 1) + + def _show_msg(self, text: str = ''): + for line in text.splitlines(): + self._summary_layout.add_widget(Label(line), 1) + + # override this method in child classes if desired + def show_result(self, resp: requests.Response): + self._show_msg('The operation was successfully performed.') + + def _resultview_on_load(self): + def target(): + try: + resp = self._model.deferred_req() + self._status_label.text += 'Done.' + self._next_btn.disabled = False + if resp.status_code != 200: + self._show_msg('An error occurred:') + self._show_msg(resp.text.rstrip()) + return + self.show_result(resp) + finally: + self.fix() + self.reset() + self._model.screen.force_update() + Thread(target=target).start() + + def _next(self): + self._model.reset() + raise NextScene('Welcome') diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py index 2a98684..26f6527 100644 --- a/ceo/tui/TransactionView.py +++ b/ceo/tui/TransactionView.py @@ -15,12 +15,12 @@ class TransactionView(Frame): height, width, can_scroll=False, - on_load=self._on_load, + on_load=self._txnview_on_load, title='Running Transaction', ) self._model = model # map operation names to label widgets - self._labels = model.viewdata['transaction']['labels'] + self._labels = model.viewdata['Transaction']['labels'] # this is an ugly hack to get around the fact that _on_load() # will be called again when we reset() in enable_next_btn. self._loaded = False @@ -35,7 +35,7 @@ class TransactionView(Frame): self._next_btn = Button('Next', self._next) # we don't want to disable the button if the txn completed # and the user just resized the window - if self._model.viewdata['transaction']['status'] != 'completed': + if self._model.viewdata['Transaction']['status'] != 'completed': self._next_btn.disabled = True layout.add_widget(self._next_btn, 1) @@ -43,15 +43,15 @@ class TransactionView(Frame): self._op_layout.add_widget(Label(''), 0) self._op_layout.add_widget(Label(''), 2) - def _on_load(self): + def _txnview_on_load(self): if self._loaded: return self._loaded = True - - d = self._model.viewdata['transaction'] + + d = self._model.viewdata['Transaction'] first_time = True if d['op_layout'] is None: - self._op_layout = Layout([10, 1, 10]) + self._op_layout = Layout([12, 1, 10]) self.add_layout(self._op_layout) # store the layouts so that we can re-use them when the screen # gets resized @@ -88,7 +88,7 @@ class TransactionView(Frame): Thread(target=self._do_txn).start() def _do_txn(self): - self._model.viewdata['transaction']['status'] = 'in progress' + self._model.viewdata['Transaction']['status'] = 'in progress' resp = self._model.deferred_req() handler = TUIStreamResponseHandler( model=self._model, @@ -104,9 +104,8 @@ class TransactionView(Frame): # enabled it self.reset() # save the fact that the transaction is completed - self._model.viewdata['transaction']['status'] = 'completed' + self._model.viewdata['Transaction']['status'] = 'completed' def _next(self): - self._model.reset_viewdata() - self._model.scene_stack.clear() + self._model.reset() raise NextScene('Welcome') diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 52a269a..2cab71f 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -47,7 +47,7 @@ class WelcomeView(Frame): item_id = self.data['members'] desc, view = self._members_menu_items[item_id] if desc.endswith('club rep'): - self._model.for_member = False + self._model.is_club_rep = True self._model.title = desc self._model.scene_stack.append('Welcome') raise NextScene(view) diff --git a/ceo/tui/members/AddUserView.py b/ceo/tui/members/AddUserView.py index 8bf07cb..782c4cd 100644 --- a/ceo/tui/members/AddUserView.py +++ b/ceo/tui/members/AddUserView.py @@ -1,24 +1,21 @@ from threading import Thread -from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Text, Button, Divider, Label +from asciimatics.widgets import Layout, Text, Label +from ...term_utils import get_terms_for_new_user from ...utils import http_get, http_post, defer, user_dict_kv, \ - get_terms_for_new_user, get_adduser_operations + get_adduser_operations +from ..CeoFrame import CeoFrame -class AddUserView(Frame): +class AddUserView(CeoFrame): def __init__(self, screen, width, height, model): super().__init__( - screen, - height, - width, - can_scroll=False, - on_load=self._on_load, + screen, height, width, model, 'AddUser', + save_data=True, ) - self._model = model self._username_changed = False - + layout = Layout([100], fill_frame=True) self.add_layout(layout) self._username = Text( @@ -43,27 +40,11 @@ class AddUserView(Frame): self._status_label = Label('') layout.add_widget(self._status_label) - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Divider()) - - layout = Layout([1, 1]) - self.add_layout(layout) - layout.add_widget(Button('Back', self._back), 0) - layout.add_widget(Button("Next", self._next), 1) + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) self.fix() - def _on_load(self): - self.title = self._model.title - # restore the saved input fields' values - self.data = self._model.viewdata['adduser'] - - def _on_unload(self): - # save the input fields' values so that they don't disappear when - # the window gets resized - self.save() - self._model.viewdata['adduser'] = self.data - def _on_username_change(self): self._username_changed = True @@ -75,10 +56,9 @@ class AddUserView(Frame): if username == '': return Thread(target=self._get_uwldap_info, args=[username]).start() - #self._get_uwldap_info(username) def _get_uwldap_info(self, username): - self._status_label.text = 'Searching for user...' + self._status_label.text = 'Looking up user...' try: resp = http_get('/api/uwldap/' + username) if resp.status_code != 200: @@ -92,9 +72,6 @@ class AddUserView(Frame): finally: self._status_label.text = '' - def _back(self): - raise NextScene(self._model.scene_stack.pop()) - def _next(self): body = { 'uid': self._username.value, @@ -105,10 +82,10 @@ class AddUserView(Frame): if self._forwarding_address.value: body['forwarding_addresses'] = [self._forwarding_address.value] new_terms = get_terms_for_new_user(int(self._num_terms.value)) - if self._model.for_member: - body['terms'] = new_terms - else: + if self._model.is_club_rep: body['non_member_terms'] = new_terms + else: + body['terms'] = new_terms pairs = user_dict_kv(body) self._model.confirm_lines = [ 'The following user will be created:', @@ -120,6 +97,3 @@ class AddUserView(Frame): self._model.deferred_req = defer(http_post, '/api/members', json=body) self._model.operations = get_adduser_operations(body) - - self._model.scene_stack.append('AddUser') - raise NextScene('Confirm') diff --git a/ceo/tui/members/RenewUserView.py b/ceo/tui/members/RenewUserView.py new file mode 100644 index 0000000..70aa1f9 --- /dev/null +++ b/ceo/tui/members/RenewUserView.py @@ -0,0 +1,64 @@ +from asciimatics.widgets import Layout, Text, Label + +from ...term_utils import get_terms_for_renewal +from ...utils import http_post, defer +from ..CeoFrame import CeoFrame + + +class RenewUserView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'RenewUser', + save_data=True, + ) + self._model = model + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text("Username:", "uid") + layout.add_widget(self._username) + self._num_terms = Text( + "Number of terms:", "num_terms", + validator=lambda s: s.isdigit() and s[0] != '0') + layout.add_widget(self._num_terms) + + layout = Layout([100]) + self.add_layout(layout) + self._status_label = Label('') + layout.add_widget(self._status_label) + + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + def _next(self): + uid = self._username.value + self._status_label.text = 'Looking up user...' + self._model.screen.force_update() + self._model.screen.draw_next_frame() + new_terms = get_terms_for_renewal( + uid, + int(self._num_terms.value), + self._model.is_club_rep, + self._model, + ) + self._status_label.text = '' + + body = {'uid': uid} + if self._model.is_club_rep: + body['non_member_terms'] = new_terms + terms_str = 'non-member terms' + else: + body['terms'] = new_terms + terms_str = 'member terms' + + self._model.confirm_lines = [ + 'The following ' + terms_str + ' will be added:', + '', + ','.join(new_terms), + '', + 'Are you sure you want to continue?', + ] + self._model.deferred_req = defer( + http_post, f'/api/members/{uid}/renew', json=body) diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 0726b5c..72b5029 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -6,10 +6,13 @@ from asciimatics.scene import Scene from asciimatics.screen import Screen from .ConfirmView import ConfirmView +from .ErrorView import ErrorView from .Model import Model +from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView from .members.AddUserView import AddUserView +from .members.RenewUserView import RenewUserView def unhandled(event): @@ -35,9 +38,12 @@ def screen_wrapper(screen, last_scene, model): height = min(screen.height, 24) views = [ ('Welcome', WelcomeView(screen, width, height, model)), - ('AddUser', AddUserView(screen, width, height, model)), ('Confirm', ConfirmView(screen, width, height, model)), ('Transaction', TransactionView(screen, width, height, model)), + ('Result', ResultView(screen, width, height, model)), + ('Error', ErrorView(screen, width, height, model)), + ('AddUser', AddUserView(screen, width, height, model)), + ('RenewUser', RenewUserView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views diff --git a/ceo/tui/utils.py b/ceo/tui/utils.py new file mode 100644 index 0000000..caa1ab0 --- /dev/null +++ b/ceo/tui/utils.py @@ -0,0 +1,10 @@ +from asciimatics.exceptions import NextScene + +import requests + + +def handle_sync_response(resp: requests.Response, model): + if resp.status_code != 200: + model.error_message = resp.text.rstrip() + raise NextScene('Error') + return resp.json() diff --git a/ceo/utils.py b/ceo/utils.py index eb1b812..b23604f 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -1,6 +1,5 @@ import functools import json -import sys from typing import List, Dict, Tuple, Callable import requests @@ -8,7 +7,6 @@ from zope import component from .StreamResponseHandler import StreamResponseHandler from ceo_common.interfaces import IHTTPClient, IConfig -from ceo_common.model import Term from ceod.transactions.members import AddMemberTransaction @@ -85,12 +83,6 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]: return lines -def get_terms_for_new_user(num_terms: int) -> List[str]: - current_term = Term.current() - terms = [current_term + i for i in range(num_terms)] - return list(map(str, terms)) - - def user_dict_kv(d: Dict) -> List[Tuple[str]]: """Pretty-format a serialized User as (key, value) pairs.""" pairs = [ From 39158676ae8e47e21f9a07dbe9873cf518ad328f Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Mon, 6 Sep 2021 16:48:07 +0000 Subject: [PATCH 05/17] use CeoFrame as parent class for TransactionView --- ceo/tui/CeoFrame.py | 2 +- ceo/tui/TransactionView.py | 22 ++++++---------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index fb113ba..96778fd 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -31,7 +31,7 @@ class CeoFrame(Frame): def _ceoframe_on_load(self): # We usually don't want _on_load() to be called multiple times - # e.g. when switching back to a scene + # e.g. when switching back to a scene, or after calling reset() if self._loaded: return self._loaded = True diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py index 26f6527..ed7809d 100644 --- a/ceo/tui/TransactionView.py +++ b/ceo/tui/TransactionView.py @@ -1,29 +1,23 @@ from threading import Thread from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Button, Divider, Label +from asciimatics.widgets import Layout, Button, Divider, Label from ..operation_strings import descriptions as op_desc from ..utils import generic_handle_stream_response +from .CeoFrame import CeoFrame from .TUIStreamResponseHandler import TUIStreamResponseHandler -class TransactionView(Frame): +class TransactionView(CeoFrame): def __init__(self, screen, width, height, model): super().__init__( - screen, - height, - width, - can_scroll=False, - on_load=self._txnview_on_load, - title='Running Transaction', + screen, height, width, model, 'Transaction', + on_load=self._txnview_on_load, title='Running Transaction', ) self._model = model # map operation names to label widgets self._labels = model.viewdata['Transaction']['labels'] - # this is an ugly hack to get around the fact that _on_load() - # will be called again when we reset() in enable_next_btn. - self._loaded = False def _add_buttons(self): layout = Layout([100]) @@ -44,13 +38,9 @@ class TransactionView(Frame): self._op_layout.add_widget(Label(''), 2) def _txnview_on_load(self): - if self._loaded: - return - self._loaded = True - d = self._model.viewdata['Transaction'] - first_time = True if d['op_layout'] is None: + first_time = True self._op_layout = Layout([12, 1, 10]) self.add_layout(self._op_layout) # store the layouts so that we can re-use them when the screen From af73dd713d9f74679ad1b0ef54d820be38690255 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Mon, 6 Sep 2021 20:16:45 +0000 Subject: [PATCH 06/17] add flash_message --- ceo/tui/CeoFrame.py | 28 ++++++++++--- ceo/tui/ConfirmView.py | 20 ++++++--- ceo/tui/Model.py | 2 + ceo/tui/ResultView.py | 70 +++++++++++--------------------- ceo/tui/WelcomeView.py | 2 +- ceo/tui/members/AddUserView.py | 12 ++---- ceo/tui/members/RenewUserView.py | 26 +++++------- 7 files changed, 79 insertions(+), 81 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index 96778fd..39f357d 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -1,5 +1,5 @@ from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Divider, Button +from asciimatics.widgets import Frame, Layout, Divider, Button, Label class CeoFrame(Frame): @@ -54,7 +54,7 @@ class CeoFrame(Frame): def add_buttons( self, back_btn=False, back_btn_text='Back', - next_scene=None, next_scene_text='Next', on_next=None, + next_scene=None, next_btn_text='Next', on_next=None, on_next_excl=None, ): """ @@ -81,12 +81,30 @@ class CeoFrame(Frame): return if on_next is not None: on_next() - self._model.scene_stack.append(self._name) - raise NextScene(next_scene) + self.go_to_next_scene(next_scene) layout = Layout([1, 1]) self.add_layout(layout) if back_btn: layout.add_widget(Button(back_btn_text, _back), 0) if next_scene is not None or on_next_excl is not None: - layout.add_widget(Button(next_scene_text, _next), 1) + layout.add_widget(Button(next_btn_text, _next), 1) + + def go_to_next_scene(self, next_scene: str): + self._model.scene_stack.append(self._name) + raise NextScene(next_scene) + + def add_flash_message_layout(self): + layout = Layout([100]) + self.add_layout(layout) + self._status_label = Label('') + layout.add_widget(self._status_label) + + def flash_message(self, msg: str, force_update: bool = False): + self._status_label.text = msg + if force_update: + self._model.screen.force_update() + self._model.screen.draw_next_frame() + + def clear_flash_message(self): + self.flash_message('') diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py index 8574643..34646cd 100644 --- a/ceo/tui/ConfirmView.py +++ b/ceo/tui/ConfirmView.py @@ -34,11 +34,21 @@ class ConfirmView(CeoFrame): # fill the rest of the space self.add_layout(Layout([100], fill_frame=True)) + kwargs = { + 'back_btn': True, 'back_btn_text': 'No', 'next_btn_text': 'Yes', + } if self._model.operations is not None: - next_scene = 'Transaction' + kwargs['next_scene'] = 'Transaction' else: - next_scene = 'Result' - self.add_buttons( - back_btn=True, back_btn_text='No', - next_scene=next_scene, next_scene_text='Yes') + self.add_flash_message_layout() + kwargs['on_next_excl'] = self._next + self.add_buttons(**kwargs) self.fix() + + def _next(self): + self.flash_message('Sending request...', force_update=True) + try: + self._model.deferred_req_resp = self._model.deferred_req() + finally: + self.clear_flash_message() + self.go_to_next_scene('Result') diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 793ce32..aaf0bd3 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -36,6 +36,7 @@ class Model: self.confirm_lines = None self.operations = None self.deferred_req = None + self.deferred_req_resp = None def reset(self): self.viewdata = deepcopy(self._initial_viewdata) @@ -43,6 +44,7 @@ class Model: self.confirm_lines = None self.operations = None self.deferred_req = None + self.deferred_req_resp = None self.title = None self.error_message = None self.scene_stack.clear() diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index a22a1df..24bbaeb 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -1,7 +1,5 @@ -from threading import Thread - from asciimatics.exceptions import NextScene -from asciimatics.widgets import Layout, Label, Button, Divider +from asciimatics.widgets import Layout, Label import requests from .CeoFrame import CeoFrame @@ -13,54 +11,32 @@ class ResultView(CeoFrame): screen, height, width, model, 'Result', on_load=self._resultview_on_load, title='Result', ) - layout = Layout([1, 10]) - self.add_layout(layout) - layout.add_widget(Label(''), 1) - self._status_label = Label('Sending request... ') - layout.add_widget(self._status_label, 1) - layout.add_widget(Label(''), 1) self._summary_layout = Layout([1, 10], fill_frame=True) self.add_layout(self._summary_layout) + self._show_msg() - self._add_buttons() - self.fix() - - def _add_buttons(self): - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Divider()) - - layout = Layout([1, 1]) - self.add_layout(layout) - self._next_btn = Button('Next', self._next) - self._next_btn.disabled = True - layout.add_widget(self._next_btn, 1) - - def _show_msg(self, text: str = ''): - for line in text.splitlines(): - self._summary_layout.add_widget(Label(line), 1) - - # override this method in child classes if desired - def show_result(self, resp: requests.Response): - self._show_msg('The operation was successfully performed.') - - def _resultview_on_load(self): - def target(): - try: - resp = self._model.deferred_req() - self._status_label.text += 'Done.' - self._next_btn.disabled = False - if resp.status_code != 200: - self._show_msg('An error occurred:') - self._show_msg(resp.text.rstrip()) - return - self.show_result(resp) - finally: - self.fix() - self.reset() - self._model.screen.force_update() - Thread(target=target).start() + self.add_buttons(on_next_excl=self._next) def _next(self): self._model.reset() raise NextScene('Welcome') + + def _show_msg(self, text: str = '\n', center=False): + for line in text.splitlines(): + align = '^' if center else '<' + self._summary_layout.add_widget(Label(line, align=align), 1) + + # override this method in child classes if desired + def show_result(self, resp: requests.Response): + self._show_msg('The operation was successfully performed.', center=True) + + def _resultview_on_load(self): + resp = self._model.deferred_req_resp + try: + if resp.status_code != 200: + self._show_msg('An error occurred:') + self._show_msg(resp.text.rstrip()) + return + self.show_result(resp) + finally: + self.fix() diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 2cab71f..ba57b1d 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -18,7 +18,7 @@ class WelcomeView(Frame): ('Add club rep', 'AddUser'), ('Renew member', 'RenewUser'), ('Renew club rep', 'RenewUser'), - ('Get user info', 'GetUserInfo'), + ('Get user info', 'GetUser'), ('Reset password', 'ResetPassword'), ('Modify user', 'ModifyUser'), ] diff --git a/ceo/tui/members/AddUserView.py b/ceo/tui/members/AddUserView.py index 782c4cd..8bde496 100644 --- a/ceo/tui/members/AddUserView.py +++ b/ceo/tui/members/AddUserView.py @@ -1,6 +1,6 @@ from threading import Thread -from asciimatics.widgets import Layout, Text, Label +from asciimatics.widgets import Layout, Text from ...term_utils import get_terms_for_new_user from ...utils import http_get, http_post, defer, user_dict_kv, \ @@ -35,11 +35,7 @@ class AddUserView(CeoFrame): validator=lambda s: s.isdigit() and s[0] != '0') layout.add_widget(self._num_terms) - layout = Layout([100]) - self.add_layout(layout) - self._status_label = Label('') - layout.add_widget(self._status_label) - + self.add_flash_message_layout() self.add_buttons( back_btn=True, next_scene='Confirm', on_next=self._next) @@ -58,7 +54,7 @@ class AddUserView(CeoFrame): Thread(target=self._get_uwldap_info, args=[username]).start() def _get_uwldap_info(self, username): - self._status_label.text = 'Looking up user...' + self.flash_message('Looking up user...') try: resp = http_get('/api/uwldap/' + username) if resp.status_code != 200: @@ -70,7 +66,7 @@ class AddUserView(CeoFrame): if data.get('mail_local_addresses'): self._forwarding_address.value = data['mail_local_addresses'][0] finally: - self._status_label.text = '' + self.clear_flash_message() def _next(self): body = { diff --git a/ceo/tui/members/RenewUserView.py b/ceo/tui/members/RenewUserView.py index 70aa1f9..95c0775 100644 --- a/ceo/tui/members/RenewUserView.py +++ b/ceo/tui/members/RenewUserView.py @@ -22,11 +22,7 @@ class RenewUserView(CeoFrame): validator=lambda s: s.isdigit() and s[0] != '0') layout.add_widget(self._num_terms) - layout = Layout([100]) - self.add_layout(layout) - self._status_label = Label('') - layout.add_widget(self._status_label) - + self.add_flash_message_layout() self.add_buttons( back_btn=True, next_scene='Confirm', on_next=self._next) @@ -34,16 +30,16 @@ class RenewUserView(CeoFrame): def _next(self): uid = self._username.value - self._status_label.text = 'Looking up user...' - self._model.screen.force_update() - self._model.screen.draw_next_frame() - new_terms = get_terms_for_renewal( - uid, - int(self._num_terms.value), - self._model.is_club_rep, - self._model, - ) - self._status_label.text = '' + self.flash_message('Looking up user...', force_update=True) + try: + new_terms = get_terms_for_renewal( + uid, + int(self._num_terms.value), + self._model.is_club_rep, + self._model, + ) + finally: + self.clear_flash_message() body = {'uid': uid} if self._model.is_club_rep: From d3c98e418a7c28826f6ca5328ffd6ad7ff8ad0ec Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 02:29:53 +0000 Subject: [PATCH 07/17] implement GetUser in TUI --- ceo/tui/CeoFrame.py | 33 ++++++++++++++-- ceo/tui/ConfirmView.py | 3 +- ceo/tui/ErrorView.py | 1 + ceo/tui/Model.py | 12 ++++-- ceo/tui/ResultView.py | 59 +++++++++++++++++----------- ceo/tui/TransactionView.py | 1 + ceo/tui/members/GetUserResultView.py | 11 ++++++ ceo/tui/members/GetUserView.py | 29 ++++++++++++++ ceo/tui/members/RenewUserView.py | 2 +- ceo/tui/start.py | 11 ++++-- 10 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 ceo/tui/members/GetUserResultView.py create mode 100644 ceo/tui/members/GetUserView.py diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index 39f357d..797c25c 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -13,6 +13,7 @@ class CeoFrame(Frame): on_load=None, title=None, save_data=False, # whether to save widget state for resizing + has_dynamic_layouts=False, # whether layouts are created on load ): super().__init__( screen, @@ -28,6 +29,7 @@ class CeoFrame(Frame): self._model = model self._name = name self._loaded = False + self._has_dynamic_layouts = has_dynamic_layouts def _ceoframe_on_load(self): # We usually don't want _on_load() to be called multiple times @@ -44,14 +46,39 @@ class CeoFrame(Frame): if self._extra_on_load is not None: self._extra_on_load() - def _on_unload(self): + def _ceoframe_on_unload(self): + """ + This should be called just after the screen gets resized, + but before the new scenes are constructed. + The idea is to save the user's data in the input fields + so that we can restore them in the new scenes. + """ if not self._save_data: return - # save the input fields' values so that they don't disappear when - # the window gets resized self.save() self._model.viewdata[self._name] = self.data + def _ceoframe_on_reset(self): + """ + This needs to be called whenever we return to the home screen + after some kind of operation was completed. + Currently this is called from Model.reset(). + """ + # We want a fresh slate once we return to the home screen, so we + # want on_load() to be called for the scenes. + self._loaded = False + if self._has_dynamic_layouts: + # We don't want layouts to accumulate. + self.clear_layouts() + + def clear_layouts(self): + # OK so this a *really* bad thing to do, since we're reaching + # into the private variables of a third-party library. + # Unfortunately asciimatics doesn't allow us to clear the layouts + # of an existing frame, and we need this to be able to re-use + # frames which create layouts dynamically. + self._layouts.clear() + def add_buttons( self, back_btn=False, back_btn_text='Back', next_scene=None, next_btn_text='Next', on_next=None, diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py index 34646cd..4dcfec0 100644 --- a/ceo/tui/ConfirmView.py +++ b/ceo/tui/ConfirmView.py @@ -8,6 +8,7 @@ class ConfirmView(CeoFrame): super().__init__( screen, height, width, model, 'Confirm', on_load=self._confirmview_on_load, title='Confirmation', + has_dynamic_layouts=True, ) def _add_line(self, text: str = ''): @@ -48,7 +49,7 @@ class ConfirmView(CeoFrame): def _next(self): self.flash_message('Sending request...', force_update=True) try: - self._model.deferred_req_resp = self._model.deferred_req() + self._model.resp = self._model.deferred_req() finally: self.clear_flash_message() self.go_to_next_scene('Result') diff --git a/ceo/tui/ErrorView.py b/ceo/tui/ErrorView.py index 2615ed5..4fff146 100644 --- a/ceo/tui/ErrorView.py +++ b/ceo/tui/ErrorView.py @@ -9,6 +9,7 @@ class ErrorView(CeoFrame): super().__init__( screen, height, width, model, 'Error', on_load=self._errorview_on_load, title='Error', + has_dynamic_layouts=True, ) def _errorview_on_load(self): diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index aaf0bd3..b9b2a1b 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -6,6 +6,7 @@ class Model: def __init__(self): self.screen = None + self.views = [] self.title = None self.scene_stack = [] self.error_message = None @@ -28,7 +29,9 @@ class Model: 'labels': {}, 'status': 'not started', }, - 'Result': {}, + 'GetUser': { + 'uid': '', + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views @@ -36,7 +39,7 @@ class Model: self.confirm_lines = None self.operations = None self.deferred_req = None - self.deferred_req_resp = None + self.resp = None def reset(self): self.viewdata = deepcopy(self._initial_viewdata) @@ -44,7 +47,10 @@ class Model: self.confirm_lines = None self.operations = None self.deferred_req = None - self.deferred_req_resp = None + self.resp = None self.title = None self.error_message = None self.scene_stack.clear() + for view in self.views: + if hasattr(view, '_ceoframe_on_reset'): + view._ceoframe_on_reset() diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index 24bbaeb..e118a56 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -10,33 +10,46 @@ class ResultView(CeoFrame): super().__init__( screen, height, width, model, 'Result', on_load=self._resultview_on_load, title='Result', + has_dynamic_layouts=True, ) - self._summary_layout = Layout([1, 10], fill_frame=True) - self.add_layout(self._summary_layout) - self._show_msg() + # TODO: deduplicate this from ConfirmView + def _add_text(self, text: str = '\n', center: bool = False): + if center: + layout = Layout([100]) + align = '^' + col = 0 + else: + layout = Layout([1, 10]) + align = '<' + col = 1 + self.add_layout(layout) + for line in text.splitlines(): + layout.add_widget(Label(line, align=align), col) + + def _add_pair(self, key: str, val: str): + layout = Layout([10, 1, 10]) + self.add_layout(layout) + layout.add_widget(Label(key + ':', align='>'), 0) + layout.add_widget(Label(val, align='<'), 2) + + # override this method in child classes if desired + def show_result(self, resp: requests.Response): + self._add_text('The operation was successfully performed.', center=True) + + def _resultview_on_load(self): + self._add_text() + resp = self._model.resp + if resp.status_code != 200: + self._add_text('An error occurred:') + self._add_text(resp.text.rstrip()) + else: + self.show_result(resp) + # fill the rest of the space + self.add_layout(Layout([100], fill_frame=True)) self.add_buttons(on_next_excl=self._next) + self.fix() def _next(self): self._model.reset() raise NextScene('Welcome') - - def _show_msg(self, text: str = '\n', center=False): - for line in text.splitlines(): - align = '^' if center else '<' - self._summary_layout.add_widget(Label(line, align=align), 1) - - # override this method in child classes if desired - def show_result(self, resp: requests.Response): - self._show_msg('The operation was successfully performed.', center=True) - - def _resultview_on_load(self): - resp = self._model.deferred_req_resp - try: - if resp.status_code != 200: - self._show_msg('An error occurred:') - self._show_msg(resp.text.rstrip()) - return - self.show_result(resp) - finally: - self.fix() diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py index ed7809d..e8a959b 100644 --- a/ceo/tui/TransactionView.py +++ b/ceo/tui/TransactionView.py @@ -14,6 +14,7 @@ class TransactionView(CeoFrame): super().__init__( screen, height, width, model, 'Transaction', on_load=self._txnview_on_load, title='Running Transaction', + has_dynamic_layouts=True, ) self._model = model # map operation names to label widgets diff --git a/ceo/tui/members/GetUserResultView.py b/ceo/tui/members/GetUserResultView.py new file mode 100644 index 0000000..755e9fb --- /dev/null +++ b/ceo/tui/members/GetUserResultView.py @@ -0,0 +1,11 @@ +import requests + +from ...utils import user_dict_kv +from ..ResultView import ResultView + + +class GetUserResultView(ResultView): + def show_result(self, resp: requests.Response): + pairs = user_dict_kv(resp.json()) + for key, val in pairs: + self._add_pair(key, val) diff --git a/ceo/tui/members/GetUserView.py b/ceo/tui/members/GetUserView.py new file mode 100644 index 0000000..2b520dc --- /dev/null +++ b/ceo/tui/members/GetUserView.py @@ -0,0 +1,29 @@ +from asciimatics.widgets import Layout, Text + +from ...utils import http_get +from ..CeoFrame import CeoFrame + +class GetUserView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'GetUser', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text("Username:", "uid") + layout.add_widget(self._username) + + self.add_flash_message_layout() + self.add_buttons( + back_btn=True, + next_scene='GetUserResult', on_next=self._next) + self.fix() + + def _next(self): + uid = self._username.value + self.flash_message('Looking up user...', force_update=True) + try: + self._model.resp = http_get(f'/api/members/{uid}') + finally: + self.clear_flash_message() diff --git a/ceo/tui/members/RenewUserView.py b/ceo/tui/members/RenewUserView.py index 95c0775..86e722a 100644 --- a/ceo/tui/members/RenewUserView.py +++ b/ceo/tui/members/RenewUserView.py @@ -1,4 +1,4 @@ -from asciimatics.widgets import Layout, Text, Label +from asciimatics.widgets import Layout, Text from ...term_utils import get_terms_for_renewal from ...utils import http_post, defer diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 72b5029..170342d 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -12,6 +12,8 @@ from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView from .members.AddUserView import AddUserView +from .members.GetUserView import GetUserView +from .members.GetUserResultView import GetUserResultView from .members.RenewUserView import RenewUserView @@ -29,11 +31,10 @@ views = [] def screen_wrapper(screen, last_scene, model): global views - model.screen = screen # unload the old views for name, view in views: - if hasattr(view, '_on_unload'): - view._on_unload() + if hasattr(view, '_on_ceoframe_unload'): + view._on_ceoframe_unload() width = min(screen.width, 90) height = min(screen.height, 24) views = [ @@ -44,10 +45,14 @@ def screen_wrapper(screen, last_scene, model): ('Error', ErrorView(screen, width, height, model)), ('AddUser', AddUserView(screen, width, height, model)), ('RenewUser', RenewUserView(screen, width, height, model)), + ('GetUser', GetUserView(screen, width, height, model)), + ('GetUserResult', GetUserResultView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views ] + model.screen = screen + model.views = [view for name, view in views] screen.play( scenes, stop_on_resize=True, start_scene=last_scene, allow_int=True, unhandled_input=unhandled) From 1406899ea24ca686326c5c42a06362ca78fd5f91 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 03:03:30 +0000 Subject: [PATCH 08/17] implement ResetPasswordView --- ceo/tui/ConfirmView.py | 3 ++- ceo/tui/Model.py | 5 ++++ ceo/tui/members/GetUserView.py | 2 +- ceo/tui/members/ResetPasswordResultView.py | 12 +++++++++ ceo/tui/members/ResetPasswordView.py | 31 ++++++++++++++++++++++ ceo/tui/start.py | 4 +++ 6 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 ceo/tui/members/ResetPasswordResultView.py create mode 100644 ceo/tui/members/ResetPasswordView.py diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py index 4dcfec0..be313f5 100644 --- a/ceo/tui/ConfirmView.py +++ b/ceo/tui/ConfirmView.py @@ -52,4 +52,5 @@ class ConfirmView(CeoFrame): self._model.resp = self._model.deferred_req() finally: self.clear_flash_message() - self.go_to_next_scene('Result') + next_scene = self._model.result_view_name or 'Result' + self.go_to_next_scene(next_scene) diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index b9b2a1b..8eac187 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -9,6 +9,7 @@ class Model: self.views = [] self.title = None self.scene_stack = [] + self.result_view_name = None self.error_message = None # view-specific data, to be used when e.g. resizing the window self._initial_viewdata = { @@ -32,6 +33,9 @@ class Model: 'GetUser': { 'uid': '', }, + 'ResetPassword': { + 'uid': '', + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views @@ -51,6 +55,7 @@ class Model: self.title = None self.error_message = None self.scene_stack.clear() + self.result_view_name = None for view in self.views: if hasattr(view, '_ceoframe_on_reset'): view._ceoframe_on_reset() diff --git a/ceo/tui/members/GetUserView.py b/ceo/tui/members/GetUserView.py index 2b520dc..5884994 100644 --- a/ceo/tui/members/GetUserView.py +++ b/ceo/tui/members/GetUserView.py @@ -19,7 +19,7 @@ class GetUserView(CeoFrame): back_btn=True, next_scene='GetUserResult', on_next=self._next) self.fix() - + def _next(self): uid = self._username.value self.flash_message('Looking up user...', force_update=True) diff --git a/ceo/tui/members/ResetPasswordResultView.py b/ceo/tui/members/ResetPasswordResultView.py new file mode 100644 index 0000000..8cb1cbb --- /dev/null +++ b/ceo/tui/members/ResetPasswordResultView.py @@ -0,0 +1,12 @@ +from ..ResultView import ResultView + +import requests + + +class ResetPasswordResultView(ResultView): + def show_result(self, resp: requests.Response): + result = resp.json() + uid = self._model.viewdata['ResetPassword']['uid'] + self._add_text(f'The new password for {uid} is:', center=True) + self._add_text() + self._add_text(result['password'], center=True) diff --git a/ceo/tui/members/ResetPasswordView.py b/ceo/tui/members/ResetPasswordView.py new file mode 100644 index 0000000..c06d520 --- /dev/null +++ b/ceo/tui/members/ResetPasswordView.py @@ -0,0 +1,31 @@ +from asciimatics.widgets import Layout, Text, Label + +from ...utils import defer, http_post +from ..CeoFrame import CeoFrame + + +class ResetPasswordView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'ResetPassword', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + layout.add_widget(Label('Enter the username of the user whose password will be reset:')) + self._username = Text(None, "uid") + layout.add_widget(self._username) + + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + def _next(self): + uid = self._username.value + self._model.viewdata['ResetPassword']['uid'] = uid + self._model.confirm_lines= [ + f"Are you sure you want to reset {uid}'s password?", + ] + self._model.deferred_req = defer(http_post, f'/api/members/{uid}/pwreset') + self._model.result_view_name = 'ResetPasswordResult' diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 170342d..c5cd3a8 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -15,6 +15,8 @@ from .members.AddUserView import AddUserView from .members.GetUserView import GetUserView from .members.GetUserResultView import GetUserResultView from .members.RenewUserView import RenewUserView +from .members.ResetPasswordView import ResetPasswordView +from .members.ResetPasswordResultView import ResetPasswordResultView def unhandled(event): @@ -47,6 +49,8 @@ def screen_wrapper(screen, last_scene, model): ('RenewUser', RenewUserView(screen, width, height, model)), ('GetUser', GetUserView(screen, width, height, model)), ('GetUserResult', GetUserResultView(screen, width, height, model)), + ('ResetPassword', ResetPasswordView(screen, width, height, model)), + ('ResetPasswordResult', ResetPasswordResultView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views From a08fca4c601c6c9fc65412a1de2f0ed5704b7cb9 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 03:05:13 +0000 Subject: [PATCH 09/17] fix lint errors --- ceo/tui/CeoFrame.py | 2 +- ceo/tui/members/GetUserView.py | 3 ++- ceo/tui/members/ResetPasswordView.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index 797c25c..cd4086c 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -126,7 +126,7 @@ class CeoFrame(Frame): self.add_layout(layout) self._status_label = Label('') layout.add_widget(self._status_label) - + def flash_message(self, msg: str, force_update: bool = False): self._status_label.text = msg if force_update: diff --git a/ceo/tui/members/GetUserView.py b/ceo/tui/members/GetUserView.py index 5884994..cabbfe5 100644 --- a/ceo/tui/members/GetUserView.py +++ b/ceo/tui/members/GetUserView.py @@ -3,6 +3,7 @@ from asciimatics.widgets import Layout, Text from ...utils import http_get from ..CeoFrame import CeoFrame + class GetUserView(CeoFrame): def __init__(self, screen, width, height, model): super().__init__( @@ -13,7 +14,7 @@ class GetUserView(CeoFrame): self.add_layout(layout) self._username = Text("Username:", "uid") layout.add_widget(self._username) - + self.add_flash_message_layout() self.add_buttons( back_btn=True, diff --git a/ceo/tui/members/ResetPasswordView.py b/ceo/tui/members/ResetPasswordView.py index c06d520..b922799 100644 --- a/ceo/tui/members/ResetPasswordView.py +++ b/ceo/tui/members/ResetPasswordView.py @@ -15,16 +15,16 @@ class ResetPasswordView(CeoFrame): layout.add_widget(Label('Enter the username of the user whose password will be reset:')) self._username = Text(None, "uid") layout.add_widget(self._username) - + self.add_buttons( back_btn=True, next_scene='Confirm', on_next=self._next) self.fix() - + def _next(self): uid = self._username.value self._model.viewdata['ResetPassword']['uid'] = uid - self._model.confirm_lines= [ + self._model.confirm_lines = [ f"Are you sure you want to reset {uid}'s password?", ] self._model.deferred_req = defer(http_post, f'/api/members/{uid}/pwreset') From ebaeeaaf135c55cc4d3c3092e12687310a325089 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 04:16:29 +0000 Subject: [PATCH 10/17] implement ChanageLoginShellView and SetForwardingAddressesView --- ceo/tui/Model.py | 8 ++ ceo/tui/WelcomeView.py | 3 +- ceo/tui/members/ChangeLoginShellView.py | 71 +++++++++++++++++ ceo/tui/members/SetForwardingAddressesView.py | 76 +++++++++++++++++++ ceo/tui/start.py | 4 + 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 ceo/tui/members/ChangeLoginShellView.py create mode 100644 ceo/tui/members/SetForwardingAddressesView.py diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 8eac187..9265e4b 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -36,6 +36,14 @@ class Model: 'ResetPassword': { 'uid': '', }, + 'ChangeLoginShell': { + 'uid': '', + 'login_shell': '', + }, + 'SetForwardingAddresses': { + 'uid': '', + 'forwarding_addresses': [''], + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index ba57b1d..f2f1b72 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -20,7 +20,8 @@ class WelcomeView(Frame): ('Renew club rep', 'RenewUser'), ('Get user info', 'GetUser'), ('Reset password', 'ResetPassword'), - ('Modify user', 'ModifyUser'), + ('Change login shell', 'ChangeLoginShell'), + ('Set forwarding addresses', 'SetForwardingAddresses'), ] self._members_menu = ListBox( Widget.FILL_FRAME, diff --git a/ceo/tui/members/ChangeLoginShellView.py b/ceo/tui/members/ChangeLoginShellView.py new file mode 100644 index 0000000..ab3d722 --- /dev/null +++ b/ceo/tui/members/ChangeLoginShellView.py @@ -0,0 +1,71 @@ +from threading import Thread + +from asciimatics.widgets import Layout, Text + +from ...utils import defer, http_patch, http_get +from ..CeoFrame import CeoFrame + + +class ChangeLoginShellView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'ChangeLoginShell', + save_data=True, + ) + self._username_changed = False + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text( + "Username:", "uid", + on_change=self._on_username_change, + on_blur=self._on_username_blur, + ) + layout.add_widget(self._username) + self._login_shell = Text('Login shell:', 'login_shell') + layout.add_widget(self._login_shell) + + self.add_flash_message_layout() + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + # TODO: deduplicate this from AddUserView + def _on_username_change(self): + self._username_changed = True + + def _on_username_blur(self): + if not self._username_changed: + return + self._username_changed = False + username = self._username.value + if username == '': + return + Thread(target=self._get_user_info, args=[username]).start() + + def _get_user_info(self, username): + self.flash_message('Looking up user...') + try: + resp = http_get('/api/members/' + username) + if resp.status_code != 200: + return + data = resp.json() + self._login_shell.value = data['login_shell'] + finally: + self.clear_flash_message() + + def _next(self): + uid = self._username.value + login_shell = self._login_shell.value + body = {'login_shell': login_shell} + self._model.deferred_req = defer( + http_patch, f'/api/members/{uid}', json=body) + self._model.confirm_lines = [ + f"{uid}'s login shell will be changed to:", + '', + login_shell, + '', + 'Are you sure you want to continue?', + ] + self._model.operations = ['replace_login_shell'] diff --git a/ceo/tui/members/SetForwardingAddressesView.py b/ceo/tui/members/SetForwardingAddressesView.py new file mode 100644 index 0000000..1e622c7 --- /dev/null +++ b/ceo/tui/members/SetForwardingAddressesView.py @@ -0,0 +1,76 @@ +from threading import Thread + +from asciimatics.widgets import Layout, Label, Text, TextBox, Widget + +from ...utils import defer, http_patch, http_get +from ..CeoFrame import CeoFrame + + +class SetForwardingAddressesView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'SetForwardingAddresses', + save_data=True, + ) + self._username_changed = False + + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text( + "Username:", "uid", + on_change=self._on_username_change, + on_blur=self._on_username_blur, + ) + layout.add_widget(self._username) + self._forwarding_addresses = TextBox( + Widget.FILL_FRAME, 'Forwarding addresses:', 'forwarding_addresses', + line_wrap=True) + layout.add_widget(self._forwarding_addresses) + layout.add_widget(Label('Press to switch widgets')) + + self.add_flash_message_layout() + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + # TODO: deduplicate this from AddUserView + def _on_username_change(self): + self._username_changed = True + + def _on_username_blur(self): + if not self._username_changed: + return + self._username_changed = False + username = self._username.value + if username == '': + return + Thread(target=self._get_user_info, args=[username]).start() + + def _get_user_info(self, username): + self.flash_message('Looking up user...') + try: + resp = http_get('/api/members/' + username) + if resp.status_code != 200: + return + data = resp.json() + if 'forwarding_addresses' not in data: + return + self._forwarding_addresses.value = data['forwarding_addresses'] + finally: + self.clear_flash_message() + + def _next(self): + uid = self._username.value + forwarding_addresses = self._forwarding_addresses.value + body = {'forwarding_addresses': forwarding_addresses} + self._model.deferred_req = defer( + http_patch, f'/api/members/{uid}', json=body) + self._model.confirm_lines = [ + f"{uid}'s forwarding addresses will be set to:", + '', + *forwarding_addresses, + '', + 'Are you sure you want to continue?', + ] + self._model.operations = ['replace_forwarding_addresses'] diff --git a/ceo/tui/start.py b/ceo/tui/start.py index c5cd3a8..7009dab 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -12,11 +12,13 @@ from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView from .members.AddUserView import AddUserView +from .members.ChangeLoginShellView import ChangeLoginShellView from .members.GetUserView import GetUserView from .members.GetUserResultView import GetUserResultView from .members.RenewUserView import RenewUserView from .members.ResetPasswordView import ResetPasswordView from .members.ResetPasswordResultView import ResetPasswordResultView +from .members.SetForwardingAddressesView import SetForwardingAddressesView def unhandled(event): @@ -51,6 +53,8 @@ def screen_wrapper(screen, last_scene, model): ('GetUserResult', GetUserResultView(screen, width, height, model)), ('ResetPassword', ResetPasswordView(screen, width, height, model)), ('ResetPasswordResult', ResetPasswordResultView(screen, width, height, model)), + ('ChangeLoginShell', ChangeLoginShellView(screen, width, height, model)), + ('SetForwardingAddresses', SetForwardingAddressesView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views From 6b3ad28e8997414a6cf73c5c9c02145e3b4de274 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 05:02:34 +0000 Subject: [PATCH 11/17] implement AddGroupView --- ceo/tui/Model.py | 8 ++- ceo/tui/WelcomeView.py | 57 +++++++++++++++---- ceo/tui/groups/AddGroupView.py | 42 ++++++++++++++ ceo/tui/groups/__init__.py | 2 + ceo/tui/members/ChangeLoginShellView.py | 2 +- ceo/tui/members/SetForwardingAddressesView.py | 2 +- ceo/tui/start.py | 2 + 7 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 ceo/tui/groups/AddGroupView.py create mode 100644 ceo/tui/groups/__init__.py diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 9265e4b..29e081f 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -41,8 +41,12 @@ class Model: 'login_shell': '', }, 'SetForwardingAddresses': { - 'uid': '', - 'forwarding_addresses': [''], + 'uid': '', + 'forwarding_addresses': [''], + }, + 'AddGroup': { + 'cn': '', + 'description': '', }, } self.viewdata = deepcopy(self._initial_viewdata) diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index f2f1b72..498d3ae 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -1,5 +1,6 @@ -from asciimatics.widgets import Frame, ListBox, Layout, Divider, \ - Button, Widget +import functools + +from asciimatics.widgets import Frame, ListBox, Layout, Divider, Button from asciimatics.exceptions import NextScene, StopApplication @@ -9,11 +10,10 @@ class WelcomeView(Frame): screen, height, width, - can_scroll=False, title='CSC Electronic Office', ) self._model = model - self._members_menu_items = [ + members_menu_items = [ ('Add member', 'AddUser'), ('Add club rep', 'AddUser'), ('Renew member', 'RenewUser'), @@ -23,19 +23,43 @@ class WelcomeView(Frame): ('Change login shell', 'ChangeLoginShell'), ('Set forwarding addresses', 'SetForwardingAddresses'), ] - self._members_menu = ListBox( - Widget.FILL_FRAME, + members_menu = ListBox( + len(members_menu_items), [ (desc, i) for i, (desc, view) in - enumerate(self._members_menu_items) + enumerate(members_menu_items) ], name='members', label='Members', on_select=self._members_menu_select, ) + groups_menu_items = [ + ('Add group', 'AddGroup'), + ('Get group members', 'GetGroup'), + ('Add member to group', 'AddMemberToGroup'), + ('Remove member from group', 'RemoveMemberFromGroup'), + ] + groups_menu = ListBox( + len(groups_menu_items), + [ + (desc, i) for i, (desc, view) in + enumerate(groups_menu_items) + ], + name='groups', + label='Groups', + on_select=functools.partial(self._generic_menu_select, 'groups'), + ) + self._menu_groups = { + 'members': members_menu_items, + 'groups': groups_menu_items, + } layout = Layout([100], fill_frame=True) self.add_layout(layout) - layout.add_widget(self._members_menu) + layout.add_widget(members_menu) + layout.add_widget(groups_menu) + + layout = Layout([100]) + self.add_layout(layout) layout.add_widget(Divider()) layout = Layout([1, 1, 1]) @@ -43,12 +67,23 @@ class WelcomeView(Frame): layout.add_widget(Button("Quit", self._quit), 2) self.fix() - def _members_menu_select(self): + def _get_menu_item_desc_view(self, menu_name: str): self.save() - item_id = self.data['members'] - desc, view = self._members_menu_items[item_id] + item_id = self.data[menu_name] + menu_items = self._menu_groups[menu_name] + return menu_items[item_id] + + def _members_menu_select(self): + desc, view = self._get_menu_item_desc_view('members') if desc.endswith('club rep'): self._model.is_club_rep = True + self._welcomeview_go_to_next_scene(desc, view) + + def _generic_menu_select(self, menu_name): + desc, view = self._get_menu_item_desc_view('groups') + self._welcomeview_go_to_next_scene(desc, view) + + def _welcomeview_go_to_next_scene(self, desc, view): self._model.title = desc self._model.scene_stack.append('Welcome') raise NextScene(view) diff --git a/ceo/tui/groups/AddGroupView.py b/ceo/tui/groups/AddGroupView.py new file mode 100644 index 0000000..4a56f13 --- /dev/null +++ b/ceo/tui/groups/AddGroupView.py @@ -0,0 +1,42 @@ +from asciimatics.widgets import Layout, Text + +from ...utils import defer, http_post +from ..CeoFrame import CeoFrame +from ceod.transactions.groups import AddGroupTransaction + + +class AddGroupView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'AddGroup', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._cn = Text('Name:', 'cn') + layout.add_widget(self._cn) + self._description = Text('Description:', 'description') + layout.add_widget(self._description) + + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + def _next(self): + cn = self._cn.value + description = self._description.value + body = { + 'cn': cn, + 'description': description, + } + self._model.confirm_lines = [ + 'The following group will be created:', + '', + ('cn', cn), + ('description', description), + '', + 'Are you sure you want to continue?', + ] + self._model.deferred_req = defer(http_post, '/api/groups', json=body) + self._model.operations = AddGroupTransaction.operations diff --git a/ceo/tui/groups/__init__.py b/ceo/tui/groups/__init__.py new file mode 100644 index 0000000..633f866 --- /dev/null +++ b/ceo/tui/groups/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- + diff --git a/ceo/tui/members/ChangeLoginShellView.py b/ceo/tui/members/ChangeLoginShellView.py index ab3d722..00f3187 100644 --- a/ceo/tui/members/ChangeLoginShellView.py +++ b/ceo/tui/members/ChangeLoginShellView.py @@ -24,7 +24,7 @@ class ChangeLoginShellView(CeoFrame): layout.add_widget(self._username) self._login_shell = Text('Login shell:', 'login_shell') layout.add_widget(self._login_shell) - + self.add_flash_message_layout() self.add_buttons( back_btn=True, diff --git a/ceo/tui/members/SetForwardingAddressesView.py b/ceo/tui/members/SetForwardingAddressesView.py index 1e622c7..aff8c85 100644 --- a/ceo/tui/members/SetForwardingAddressesView.py +++ b/ceo/tui/members/SetForwardingAddressesView.py @@ -27,7 +27,7 @@ class SetForwardingAddressesView(CeoFrame): line_wrap=True) layout.add_widget(self._forwarding_addresses) layout.add_widget(Label('Press to switch widgets')) - + self.add_flash_message_layout() self.add_buttons( back_btn=True, diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 7009dab..2bbfd6f 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -11,6 +11,7 @@ from .Model import Model from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView +from .groups.AddGroupView import AddGroupView from .members.AddUserView import AddUserView from .members.ChangeLoginShellView import ChangeLoginShellView from .members.GetUserView import GetUserView @@ -55,6 +56,7 @@ def screen_wrapper(screen, last_scene, model): ('ResetPasswordResult', ResetPasswordResultView(screen, width, height, model)), ('ChangeLoginShell', ChangeLoginShellView(screen, width, height, model)), ('SetForwardingAddresses', SetForwardingAddressesView(screen, width, height, model)), + ('AddGroup', AddGroupView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views From beb16b1740af5ae8c1db10ab7cd09d2d8d3ef918 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Tue, 7 Sep 2021 05:22:20 +0000 Subject: [PATCH 12/17] implement GetGroupView --- ceo/tui/Model.py | 3 +++ ceo/tui/ResultView.py | 5 ++++- ceo/tui/groups/GetGroupResultView.py | 18 ++++++++++++++++ ceo/tui/groups/GetGroupView.py | 31 ++++++++++++++++++++++++++++ ceo/tui/start.py | 4 ++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 ceo/tui/groups/GetGroupResultView.py create mode 100644 ceo/tui/groups/GetGroupView.py diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 29e081f..741e179 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -48,6 +48,9 @@ class Model: 'cn': '', 'description': '', }, + 'GetGroup': { + 'cn': '', + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index e118a56..f9ac12f 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -30,7 +30,10 @@ class ResultView(CeoFrame): def _add_pair(self, key: str, val: str): layout = Layout([10, 1, 10]) self.add_layout(layout) - layout.add_widget(Label(key + ':', align='>'), 0) + if key: + layout.add_widget(Label(key + ':', align='>'), 0) + else: + layout.add_widget(Label(''), 0) layout.add_widget(Label(val, align='<'), 2) # override this method in child classes if desired diff --git a/ceo/tui/groups/GetGroupResultView.py b/ceo/tui/groups/GetGroupResultView.py new file mode 100644 index 0000000..b35333f --- /dev/null +++ b/ceo/tui/groups/GetGroupResultView.py @@ -0,0 +1,18 @@ +import requests + +from ..ResultView import ResultView + + +class GetGroupResultView(ResultView): + def show_result(self, resp: requests.Response): + d = resp.json() + if 'description' in d: + desc = d['description'] + ' (' + d['cn'] + ')' + else: + desc = d['cn'] + self._add_text('Members of ' + desc, center=True) + self._add_text() + for member in d['members']: + self._add_text( + member['cn'] + ' (' + member['uid'] + ')', + center=True) diff --git a/ceo/tui/groups/GetGroupView.py b/ceo/tui/groups/GetGroupView.py new file mode 100644 index 0000000..f7e0af4 --- /dev/null +++ b/ceo/tui/groups/GetGroupView.py @@ -0,0 +1,31 @@ +from asciimatics.widgets import Layout, Text + +from ...utils import http_get +from ..CeoFrame import CeoFrame + + +class GetGroupView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'GetGroup', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._cn = Text("Group name", "cn") + layout.add_widget(self._cn) + + self.add_flash_message_layout() + self.add_buttons( + back_btn=True, + next_scene='GetGroupResult', on_next=self._next) + self.fix() + + def _next(self): + cn = self._cn.value + self._model.viewdata['GetGroup']['cn'] = cn + self.flash_message('Looking up group...', force_update=True) + try: + self._model.resp = http_get(f'/api/groups/{cn}') + finally: + self.clear_flash_message() diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 2bbfd6f..f3b3d33 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -12,6 +12,8 @@ from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView from .groups.AddGroupView import AddGroupView +from .groups.GetGroupView import GetGroupView +from .groups.GetGroupResultView import GetGroupResultView from .members.AddUserView import AddUserView from .members.ChangeLoginShellView import ChangeLoginShellView from .members.GetUserView import GetUserView @@ -57,6 +59,8 @@ def screen_wrapper(screen, last_scene, model): ('ChangeLoginShell', ChangeLoginShellView(screen, width, height, model)), ('SetForwardingAddresses', SetForwardingAddressesView(screen, width, height, model)), ('AddGroup', AddGroupView(screen, width, height, model)), + ('GetGroup', GetGroupView(screen, width, height, model)), + ('GetGroupResult', GetGroupResultView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views From 21173d1b8cdf47bde70372b53f9866e5b9af00ca Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 02:59:56 +0000 Subject: [PATCH 13/17] implement AddMemberToGroupView --- ceo/tui/Model.py | 4 ++ ceo/tui/ResultView.py | 6 ++- ceo/tui/TUIStreamResponseHandler.py | 11 +++-- ceo/tui/WelcomeView.py | 3 +- ceo/tui/groups/AddMemberToGroupView.py | 43 +++++++++++++++++++ ceo/tui/groups/__init__.py | 2 - ceo/tui/start.py | 2 + .../groups/AddMemberToGroupTransaction.py | 4 +- 8 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 ceo/tui/groups/AddMemberToGroupView.py diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index 741e179..ee8d5cc 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -51,6 +51,10 @@ class Model: 'GetGroup': { 'cn': '', }, + 'AddMemberToGroup': { + 'cn': '', + 'uid': '', + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index f9ac12f..dcbf163 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -45,7 +45,11 @@ class ResultView(CeoFrame): resp = self._model.resp if resp.status_code != 200: self._add_text('An error occurred:') - self._add_text(resp.text.rstrip()) + if resp.headers.get('content-type') == 'application/json': + err_msg = resp.json()['error'] + else: + err_msg = resp.text.rstrip() + self._add_text(err_msg) else: self.show_result(resp) # fill the rest of the space diff --git a/ceo/tui/TUIStreamResponseHandler.py b/ceo/tui/TUIStreamResponseHandler.py index 1c83f21..0c1bae4 100644 --- a/ceo/tui/TUIStreamResponseHandler.py +++ b/ceo/tui/TUIStreamResponseHandler.py @@ -31,7 +31,7 @@ class TUIStreamResponseHandler(StreamResponseHandler): self.txn_view.fix() self.screen.force_update() - def _show_msg(self, msg: str = ''): + def _show_msg(self, msg: str = '\n'): for line in msg.splitlines(): self.msg_layout.add_widget(Label(line, align='^')) @@ -43,7 +43,11 @@ class TUIStreamResponseHandler(StreamResponseHandler): def handle_non_200(self, resp: requests.Response): self._abort() self._show_msg('An error occurred:') - self._show_msg(resp.text) + if resp.headers.get('content-type') == 'application/json': + err_msg = resp.json()['error'] + else: + err_msg = resp.text + self._show_msg(err_msg) self._update() def begin(self): @@ -52,8 +56,9 @@ class TUIStreamResponseHandler(StreamResponseHandler): def handle_aborted(self, err_msg: str): self._abort() self._show_msg('The transaction was rolled back.') - self._show_msg('The error was:') + self._show_msg('The error was:\n') self._show_msg(err_msg) + self._show_msg() self._show_msg('Please check the ceod logs.') self._update() diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 498d3ae..131f444 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -1,6 +1,6 @@ import functools -from asciimatics.widgets import Frame, ListBox, Layout, Divider, Button +from asciimatics.widgets import Frame, ListBox, Layout, Divider, Button, Label from asciimatics.exceptions import NextScene, StopApplication @@ -60,6 +60,7 @@ class WelcomeView(Frame): layout = Layout([100]) self.add_layout(layout) + layout.add_widget(Label('Press to switch widgets')) layout.add_widget(Divider()) layout = Layout([1, 1, 1]) diff --git a/ceo/tui/groups/AddMemberToGroupView.py b/ceo/tui/groups/AddMemberToGroupView.py new file mode 100644 index 0000000..9d1c404 --- /dev/null +++ b/ceo/tui/groups/AddMemberToGroupView.py @@ -0,0 +1,43 @@ +from asciimatics.widgets import Layout, Text, CheckBox, Label + +from ...utils import defer, http_post +from ..CeoFrame import CeoFrame +from ceod.transactions.groups import AddMemberToGroupTransaction + + +class AddMemberToGroupView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'AddMemberToGroup', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._cn = Text('Group name:', 'cn') + layout.add_widget(self._cn) + self._username = Text('Username:', 'uid') + layout.add_widget(self._username) + layout.add_widget(Label('')) + self._checkbox = CheckBox('subscribe to auxiliary mailing lists') + self._checkbox.value = True + layout.add_widget(self._checkbox) + + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + def _next(self): + cn = self._cn.value + uid = self._username.value + self._model.confirm_lines = [ + f'Are you sure you want to add {uid} to {cn}?', + ] + operations = AddMemberToGroupTransaction.operations + url = f'/api/groups/{cn}/members/{uid}' + # TODO: deduplicate this logic from the CLI + if not self._checkbox.value: + url += '?subscribe_to_lists=false' + operations.remove('subscribe_user_to_auxiliary_mailing_lists') + self._model.deferred_req = defer(http_post, url) + self._model.operations = operations diff --git a/ceo/tui/groups/__init__.py b/ceo/tui/groups/__init__.py index 633f866..e69de29 100644 --- a/ceo/tui/groups/__init__.py +++ b/ceo/tui/groups/__init__.py @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- - diff --git a/ceo/tui/start.py b/ceo/tui/start.py index f3b3d33..a3e6820 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -12,6 +12,7 @@ from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView from .groups.AddGroupView import AddGroupView +from .groups.AddMemberToGroupView import AddMemberToGroupView from .groups.GetGroupView import GetGroupView from .groups.GetGroupResultView import GetGroupResultView from .members.AddUserView import AddUserView @@ -61,6 +62,7 @@ def screen_wrapper(screen, last_scene, model): ('AddGroup', AddGroupView(screen, width, height, model)), ('GetGroup', GetGroupView(screen, width, height, model)), ('GetGroupResult', GetGroupResultView(screen, width, height, model)), + ('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views diff --git a/ceod/transactions/groups/AddMemberToGroupTransaction.py b/ceod/transactions/groups/AddMemberToGroupTransaction.py index d8a739b..1a310c8 100644 --- a/ceod/transactions/groups/AddMemberToGroupTransaction.py +++ b/ceod/transactions/groups/AddMemberToGroupTransaction.py @@ -69,9 +69,9 @@ class AddMemberToGroupTransaction(AbstractTransaction): yield 'subscribe_user_to_auxiliary_mailing_lists' except KeyError: pass - except Exception: + except Exception as err: logger.error(traceback.format_exc()) - yield 'failed_to_subscribe_user_to_auxiliary_mailing_lists' + yield 'failed_to_subscribe_user_to_auxiliary_mailing_lists: ' + str(err) result = { 'added_to_groups': [self.group_name] + [ From df7148940a38f313985eb344d206a81b110adbf5 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 03:20:51 +0000 Subject: [PATCH 14/17] implement RemoveMemberFromGroupView --- ceo/tui/Model.py | 6 +++ ceo/tui/groups/AddMemberToGroupView.py | 3 +- ceo/tui/groups/RemoveMemberFromGroupView.py | 44 +++++++++++++++++++ ceo/tui/start.py | 2 + .../RemoveMemberFromGroupTransaction.py | 4 +- 5 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 ceo/tui/groups/RemoveMemberFromGroupView.py diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index ee8d5cc..e662273 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -54,6 +54,12 @@ class Model: 'AddMemberToGroup': { 'cn': '', 'uid': '', + 'subscribe': True, + }, + 'RemoveMemberFromGroup': { + 'cn': '', + 'uid': '', + 'unsubscribe': True, }, } self.viewdata = deepcopy(self._initial_viewdata) diff --git a/ceo/tui/groups/AddMemberToGroupView.py b/ceo/tui/groups/AddMemberToGroupView.py index 9d1c404..2a0ba0f 100644 --- a/ceo/tui/groups/AddMemberToGroupView.py +++ b/ceo/tui/groups/AddMemberToGroupView.py @@ -18,7 +18,8 @@ class AddMemberToGroupView(CeoFrame): self._username = Text('Username:', 'uid') layout.add_widget(self._username) layout.add_widget(Label('')) - self._checkbox = CheckBox('subscribe to auxiliary mailing lists') + self._checkbox = CheckBox( + 'subscribe to auxiliary mailing lists', name='subscribe') self._checkbox.value = True layout.add_widget(self._checkbox) diff --git a/ceo/tui/groups/RemoveMemberFromGroupView.py b/ceo/tui/groups/RemoveMemberFromGroupView.py new file mode 100644 index 0000000..3704948 --- /dev/null +++ b/ceo/tui/groups/RemoveMemberFromGroupView.py @@ -0,0 +1,44 @@ +from asciimatics.widgets import Layout, Text, CheckBox, Label + +from ...utils import defer, http_delete +from ..CeoFrame import CeoFrame +from ceod.transactions.groups import RemoveMemberFromGroupTransaction + + +class RemoveMemberFromGroupView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'RemoveMemberFromGroup', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._cn = Text('Group name:', 'cn') + layout.add_widget(self._cn) + self._username = Text('Username:', 'uid') + layout.add_widget(self._username) + layout.add_widget(Label('')) + self._checkbox = CheckBox( + 'unsubscribe from auxiliary mailing lists', name='unsubscribe') + self._checkbox.value = True + layout.add_widget(self._checkbox) + + self.add_buttons( + back_btn=True, + next_scene='Confirm', on_next=self._next) + self.fix() + + def _next(self): + cn = self._cn.value + uid = self._username.value + self._model.confirm_lines = [ + f'Are you sure you want to remove {uid} from {cn}?', + ] + operations = RemoveMemberFromGroupTransaction.operations + url = f'/api/groups/{cn}/members/{uid}' + # TODO: deduplicate this logic from the CLI + if not self._checkbox.value: + url += '?unsubscribe_from_lists=false' + operations.remove('unsubscribe_user_from_auxiliary_mailing_lists') + self._model.deferred_req = defer(http_delete, url) + self._model.operations = operations diff --git a/ceo/tui/start.py b/ceo/tui/start.py index a3e6820..1183087 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -15,6 +15,7 @@ from .groups.AddGroupView import AddGroupView from .groups.AddMemberToGroupView import AddMemberToGroupView from .groups.GetGroupView import GetGroupView from .groups.GetGroupResultView import GetGroupResultView +from .groups.RemoveMemberFromGroupView import RemoveMemberFromGroupView from .members.AddUserView import AddUserView from .members.ChangeLoginShellView import ChangeLoginShellView from .members.GetUserView import GetUserView @@ -63,6 +64,7 @@ def screen_wrapper(screen, last_scene, model): ('GetGroup', GetGroupView(screen, width, height, model)), ('GetGroupResult', GetGroupResultView(screen, width, height, model)), ('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)), + ('RemoveMemberFromGroup', RemoveMemberFromGroupView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views diff --git a/ceod/transactions/groups/RemoveMemberFromGroupTransaction.py b/ceod/transactions/groups/RemoveMemberFromGroupTransaction.py index d5e2a77..c1ef0b6 100644 --- a/ceod/transactions/groups/RemoveMemberFromGroupTransaction.py +++ b/ceod/transactions/groups/RemoveMemberFromGroupTransaction.py @@ -69,9 +69,9 @@ class RemoveMemberFromGroupTransaction(AbstractTransaction): yield 'unsubscribe_user_from_auxiliary_mailing_lists' except KeyError: pass - except Exception: + except Exception as err: logger.error(traceback.format_exc()) - yield 'failed_to_unsubscribe_user_from_auxiliary_mailing_lists' + yield 'failed_to_unsubscribe_user_from_auxiliary_mailing_lists: ' + str(err) result = { 'removed_from_groups': [self.group_name] + [ From 4aaf10b6877e006292a5f53af718a1153ff010c6 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 03:38:12 +0000 Subject: [PATCH 15/17] add Databases and Positions menus --- ceo/tui/CeoFrame.py | 3 ++ ceo/tui/WelcomeView.py | 63 +++++++++++++++++++++++++++--------------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index cd4086c..8084c62 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -30,6 +30,9 @@ class CeoFrame(Frame): self._name = name self._loaded = False self._has_dynamic_layouts = has_dynamic_layouts + # sanity check + if save_data: + assert name in model.viewdata def _ceoframe_on_load(self): # We usually don't want _on_load() to be called multiple times diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 131f444..b4ff240 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -23,40 +23,40 @@ class WelcomeView(Frame): ('Change login shell', 'ChangeLoginShell'), ('Set forwarding addresses', 'SetForwardingAddresses'), ] - members_menu = ListBox( - len(members_menu_items), - [ - (desc, i) for i, (desc, view) in - enumerate(members_menu_items) - ], - name='members', - label='Members', - on_select=self._members_menu_select, - ) + members_menu = self._create_menu( + members_menu_items, 'members', self._members_menu_select) groups_menu_items = [ ('Add group', 'AddGroup'), ('Get group members', 'GetGroup'), ('Add member to group', 'AddMemberToGroup'), ('Remove member from group', 'RemoveMemberFromGroup'), ] - groups_menu = ListBox( - len(groups_menu_items), - [ - (desc, i) for i, (desc, view) in - enumerate(groups_menu_items) - ], - name='groups', - label='Groups', - on_select=functools.partial(self._generic_menu_select, 'groups'), - ) + groups_menu = self._create_menu(groups_menu_items, 'groups') + db_menu_items = [ + ('Create MySQL database', 'CreateMySQL'), + ('Reset MySQL password', 'ResetMySQLPassword'), + ('Create PostgreSQL database', 'CreatePostgreSQL'), + ('Reset PostgreSQL password', 'ResetPostgreSQLPassword'), + ] + db_menu = self._create_menu( + db_menu_items, 'databases', self._db_menu_select) + positions_menu_items = [ + ('Get positions', 'GetPositions'), + ('Set positions', 'SetPositions'), + ] + positions_menu = self._create_menu(positions_menu_items, 'positions') self._menu_groups = { 'members': members_menu_items, 'groups': groups_menu_items, + 'databases': db_menu_items, + 'positions': positions_menu_items, } - layout = Layout([100], fill_frame=True) + layout = Layout([1, 4, 1], fill_frame=True) self.add_layout(layout) - layout.add_widget(members_menu) - layout.add_widget(groups_menu) + layout.add_widget(members_menu, 1) + layout.add_widget(groups_menu, 1) + layout.add_widget(db_menu, 1) + layout.add_widget(positions_menu, 1) layout = Layout([100]) self.add_layout(layout) @@ -68,6 +68,20 @@ class WelcomeView(Frame): layout.add_widget(Button("Quit", self._quit), 2) self.fix() + def _create_menu(self, menu_items, name, on_select=None): + if on_select is None: + on_select = functools.partial(self._generic_menu_select, name) + return ListBox( + len(menu_items), + [ + (desc, i) for i, (desc, view) in + enumerate(menu_items) + ], + name=name, + label=name.capitalize(), + on_select=on_select, + ) + def _get_menu_item_desc_view(self, menu_name: str): self.save() item_id = self.data[menu_name] @@ -80,6 +94,9 @@ class WelcomeView(Frame): self._model.is_club_rep = True self._welcomeview_go_to_next_scene(desc, view) + def _db_menu_select(self): + pass + def _generic_menu_select(self, menu_name): desc, view = self._get_menu_item_desc_view('groups') self._welcomeview_go_to_next_scene(desc, view) From 0bf24230a00ebaeb87071e3d059a465207f8f245 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 04:10:21 +0000 Subject: [PATCH 16/17] add global quit button --- ceo/tui/CeoFrame.py | 32 ++++++++++++++++++++++++++++++-- ceo/tui/ConfirmView.py | 1 + ceo/tui/ResultView.py | 1 + ceo/tui/WelcomeView.py | 12 ++++++------ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index 8084c62..c18e0c4 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -1,5 +1,8 @@ -from asciimatics.exceptions import NextScene -from asciimatics.widgets import Frame, Layout, Divider, Button, Label +from asciimatics.event import KeyboardEvent +from asciimatics.exceptions import NextScene, StopApplication +from asciimatics.screen import Screen +from asciimatics.widgets import Frame, Layout, Divider, Button, Label, \ + PopUpDialog class CeoFrame(Frame): @@ -14,6 +17,7 @@ class CeoFrame(Frame): title=None, save_data=False, # whether to save widget state for resizing has_dynamic_layouts=False, # whether layouts are created on load + escape_on_q=False, # whether to quit when 'q' is pressed ): super().__init__( screen, @@ -30,6 +34,9 @@ class CeoFrame(Frame): self._name = name self._loaded = False self._has_dynamic_layouts = has_dynamic_layouts + self._quit_keys = [Screen.KEY_ESCAPE] + if escape_on_q: + self._quit_keys.append(ord('q')) # sanity check if save_data: assert name in model.viewdata @@ -138,3 +145,24 @@ class CeoFrame(Frame): def clear_flash_message(self): self.flash_message('') + + def process_event(self, event): + if not isinstance(event, KeyboardEvent): + return super().process_event(event) + c = event.key_code + # Stop on 'q' or 'Esc' + if c in self._quit_keys: + self._scene.add_effect(PopUpDialog( + self.screen, + 'Are you sure you want to quit?', + ['Yes', 'No'], + has_shadow=True, + on_close=self._quit_on_yes, + )) + return super().process_event(event) + + @staticmethod + def _quit_on_yes(selected): + # Yes is the first button + if selected == 0: + raise StopApplication("User terminated app") diff --git a/ceo/tui/ConfirmView.py b/ceo/tui/ConfirmView.py index be313f5..d3af5a9 100644 --- a/ceo/tui/ConfirmView.py +++ b/ceo/tui/ConfirmView.py @@ -9,6 +9,7 @@ class ConfirmView(CeoFrame): screen, height, width, model, 'Confirm', on_load=self._confirmview_on_load, title='Confirmation', has_dynamic_layouts=True, + escape_on_q=True, ) def _add_line(self, text: str = ''): diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index dcbf163..0a414bb 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -11,6 +11,7 @@ class ResultView(CeoFrame): screen, height, width, model, 'Result', on_load=self._resultview_on_load, title='Result', has_dynamic_layouts=True, + escape_on_q=True, ) # TODO: deduplicate this from ConfirmView diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index b4ff240..5271199 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -1,18 +1,18 @@ import functools -from asciimatics.widgets import Frame, ListBox, Layout, Divider, Button, Label +from asciimatics.widgets import ListBox, Layout, Divider, Button, Label from asciimatics.exceptions import NextScene, StopApplication +from .CeoFrame import CeoFrame -class WelcomeView(Frame): + +class WelcomeView(CeoFrame): def __init__(self, screen, width, height, model): super().__init__( - screen, - height, - width, + screen, height, width, model, 'Welcome', title='CSC Electronic Office', + escape_on_q=True, ) - self._model = model members_menu_items = [ ('Add member', 'AddUser'), ('Add club rep', 'AddUser'), From 82b7b2c015d35663c30341de3a62c68c4866aa01 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 04:11:03 +0000 Subject: [PATCH 17/17] fix lint errors --- ceo/tui/WelcomeView.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 5271199..df800a7 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -11,7 +11,7 @@ class WelcomeView(CeoFrame): super().__init__( screen, height, width, model, 'Welcome', title='CSC Electronic Office', - escape_on_q=True, + escape_on_q=True, ) members_menu_items = [ ('Add member', 'AddUser'),