From cce920d6baeb35855d8b1c019f61c736bd0c6d1d Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Sun, 5 Sep 2021 22:48:20 +0000 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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'), From 651988bb086c37c24c24b501e28fc6b2945dac02 Mon Sep 17 00:00:00 2001 From: Rio Date: Wed, 8 Sep 2021 09:32:34 -0400 Subject: [PATCH 16/18] Positions CLI (#11) Closes #9 Co-authored-by: Rio6 Co-authored-by: Rio Liu Co-authored-by: Max Erenberg Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/11 Co-authored-by: Rio Co-committed-by: Rio --- ceo/__main__.py | 7 +++-- ceo/cli/entrypoint.py | 2 ++ ceo/cli/positions.py | 44 ++++++++++++++++++++++++++ ceo/operation_strings.py | 3 ++ ceo/utils.py | 2 ++ ceod/api/positions.py | 6 ++++ tests/ceo/cli/test_positions.py | 55 +++++++++++++++++++++++++++++++++ tests/ceo_dev.ini | 5 +++ tests/conftest.py | 20 ++++++------ 9 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 ceo/cli/positions.py create mode 100644 tests/ceo/cli/test_positions.py diff --git a/ceo/__main__.py b/ceo/__main__.py index af29d1c..fbc4473 100644 --- a/ceo/__main__.py +++ b/ceo/__main__.py @@ -13,6 +13,9 @@ from ceo_common.model import Config, HTTPClient def register_services(): + # Using base component directly so events get triggered + baseComponent = component.getGlobalSiteManager() + # Config # This is a hack to determine if we're in the dev env or not if socket.getfqdn().endswith('.csclub.internal'): @@ -21,11 +24,11 @@ def register_services(): else: config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') cfg = Config(config_file) - component.provideUtility(cfg, IConfig) + baseComponent.registerUtility(cfg, IConfig) # HTTPService http_client = HTTPClient() - component.provideUtility(http_client, IHTTPClient) + baseComponent.registerUtility(http_client, IHTTPClient) def main(): diff --git a/ceo/cli/entrypoint.py b/ceo/cli/entrypoint.py index 1221144..e686133 100644 --- a/ceo/cli/entrypoint.py +++ b/ceo/cli/entrypoint.py @@ -2,6 +2,7 @@ import click from .members import members from .groups import groups +from .positions import positions from .updateprograms import updateprograms @@ -12,4 +13,5 @@ def cli(): cli.add_command(members) cli.add_command(groups) +cli.add_command(positions) cli.add_command(updateprograms) diff --git a/ceo/cli/positions.py b/ceo/cli/positions.py new file mode 100644 index 0000000..733e8c0 --- /dev/null +++ b/ceo/cli/positions.py @@ -0,0 +1,44 @@ +import click +from zope import component + +from ..utils import http_get, http_post +from .utils import handle_sync_response, handle_stream_response, print_colon_kv +from ceo_common.interfaces import IConfig +from ceod.transactions.members import UpdateMemberPositionsTransaction + + +@click.group(short_help='List or change exec positions') +def positions(): + update_commands() + + +@positions.command(short_help='Get current positions') +def get(): + resp = http_get('/api/positions') + result = handle_sync_response(resp) + print_colon_kv(result.items()) + + +@positions.command(short_help='Update positions') +def set(**kwargs): + body = {k.replace('_', '-'): v for k, v in kwargs.items()} + print_body = {k: v or '' for k, v in body.items()} + click.echo('The positions will be updated:') + print_colon_kv(print_body.items()) + click.confirm('Do you want to continue?', abort=True) + + resp = http_post('/api/positions', json=body) + handle_stream_response(resp, UpdateMemberPositionsTransaction.operations) + + +# Provides dynamic parameters for `set' command using config file +def update_commands(): + global set + + cfg = component.getUtility(IConfig) + avail = cfg.get('positions_available') + required = cfg.get('positions_required') + + for pos in avail: + r = pos in required + set = click.option(f'--{pos}', metavar='USERNAME', required=r, prompt=r)(set) diff --git a/ceo/operation_strings.py b/ceo/operation_strings.py index 8ff44f7..2d874e4 100644 --- a/ceo/operation_strings.py +++ b/ceo/operation_strings.py @@ -24,4 +24,7 @@ descriptions = { 'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups', 'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists', 'remove_sudo_role': 'Remove sudo role from LDAP', + 'update_positions_ldap': 'Update positions in LDAP', + 'update_exec_group_ldap': 'Update executive group in LDAP', + 'subscribe_to_mailing_lists': 'Subscribe to mailing lists', } diff --git a/ceo/utils.py b/ceo/utils.py index b23604f..28e4a13 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -69,6 +69,8 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]: key1000: val3 val4 """ + if not pairs: + return [] lines = [] maxlen = max(len(key) for key, val in pairs) for key, val in pairs: diff --git a/ceod/api/positions.py b/ceod/api/positions.py index 9d9f4c4..a194565 100644 --- a/ceod/api/positions.py +++ b/ceod/api/positions.py @@ -29,6 +29,12 @@ def update_positions(): required = cfg.get('positions_required') available = cfg.get('positions_available') + # remove falsy values + body = { + positions: username for positions, username in body.items() + if username + } + for position in body.keys(): if position not in available: return { diff --git a/tests/ceo/cli/test_positions.py b/tests/ceo/cli/test_positions.py new file mode 100644 index 0000000..c7ae111 --- /dev/null +++ b/tests/ceo/cli/test_positions.py @@ -0,0 +1,55 @@ +from click.testing import CliRunner +from ceo.cli import cli + + +def test_positions(cli_setup): + runner = CliRunner() + + # Setup test data + for i in range(5): + runner.invoke(cli, ['members', 'add', f'test_{i}', '--cn', f'Test {i}', '--program', 'Math', '--terms', '1'], input='y\n') + runner.invoke(cli, ['groups', 'add', 'exec', '--description', 'Test Group'], input='y\n') + + result = runner.invoke(cli, [ + 'positions', 'set', + '--president', 'test_0', + '--vice-president', 'test_1', + '--sysadmin', 'test_2', + '--secretary', 'test_3', + '--webmaster', 'test_4', + ], input='y\n') + + assert result.exit_code == 0 + assert result.output == ''' +The positions will be updated: +president: test_0 +vice-president: test_1 +sysadmin: test_2 +secretary: test_3 +webmaster: test_4 +treasurer: +cro: +librarian: +imapd: +offsck: +Do you want to continue? [y/N]: y +Update positions in LDAP... Done +Update executive group in LDAP... Done +Subscribe to mailing lists... Done +Transaction successfully completed. +'''[1:] # noqa: W291 + + result = runner.invoke(cli, ['positions', 'get']) + assert result.exit_code == 0 + assert result.output == ''' +president: test_0 +secretary: test_3 +sysadmin: test_2 +vice-president: test_1 +webmaster: test_4 +'''[1:] + + # Cleanup test data + for i in range(5): + runner.invoke(cli, ['members', 'delete', f'test_{i}'], input='y\n') + runner.invoke(cli, ['groups', 'delete', 'exec'], input='y\n') diff --git a/tests/ceo_dev.ini b/tests/ceo_dev.ini index e74895e..978f6f5 100644 --- a/tests/ceo_dev.ini +++ b/tests/ceo_dev.ini @@ -7,3 +7,8 @@ uw_domain = uwaterloo.internal admin_host = phosphoric-acid use_https = false port = 9987 + +[positions] +required = president,vice-president,sysadmin +available = president,vice-president,treasurer,secretary, + sysadmin,cro,librarian,imapd,webmaster,offsck diff --git a/tests/conftest.py b/tests/conftest.py index 61547b6..7d4ce62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def cfg(_drone_hostname_mock): with importlib.resources.path('tests', 'ceod_test_local.ini') as p: config_file = p.__fspath__() _cfg = Config(config_file) - component.provideUtility(_cfg, IConfig) + component.getGlobalSiteManager().registerUtility(_cfg, IConfig) return _cfg @@ -75,7 +75,7 @@ def krb_srv(cfg): else: principal = 'ceod/' + socket.getfqdn() krb = KerberosService(principal) - component.provideUtility(krb, IKerberosService) + component.getGlobalSiteManager().registerUtility(krb, IKerberosService) delete_test_princs(krb) yield krb @@ -160,7 +160,7 @@ def ldap_srv_session(cfg, krb_srv, ldap_conn): conn.add(base_dn, 'organizationalUnit') _ldap_srv = LDAPService() - component.provideUtility(_ldap_srv, ILDAPService) + component.getGlobalSiteManager().registerUtility(_ldap_srv, ILDAPService) yield _ldap_srv @@ -180,7 +180,7 @@ def ldap_srv(ldap_srv_session, g_admin_ctx): @pytest.fixture(scope='session') def file_srv(cfg): _file_srv = FileService() - component.provideUtility(_file_srv, IFileService) + component.getGlobalSiteManager().registerUtility(_file_srv, IFileService) members_home = cfg.get('members_home') clubs_home = cfg.get('clubs_home') @@ -194,7 +194,7 @@ def file_srv(cfg): @pytest.fixture(scope='session') def http_client(cfg): _client = HTTPClient() - component.provideUtility(_client, IHTTPClient) + component.getGlobalSiteManager().registerUtility(_client, IHTTPClient) return _client @@ -210,7 +210,7 @@ def mock_mailman_server(): def mailman_srv(mock_mailman_server, cfg, http_client): # TODO: test the RemoteMailmanService as well mailman = MailmanService() - component.provideUtility(mailman, IMailmanService) + component.getGlobalSiteManager().registerUtility(mailman, IMailmanService) return mailman @@ -223,7 +223,7 @@ def uwldap_srv(cfg, ldap_conn): conn.add(base_dn, 'organizationalUnit') _uwldap_srv = UWLDAPService() - component.provideUtility(_uwldap_srv, IUWLDAPService) + component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService) yield _uwldap_srv delete_subtree(conn, base_dn) @@ -240,21 +240,21 @@ def mock_mail_server(): @pytest.fixture(scope='session') def mail_srv(cfg, mock_mail_server): _mail_srv = MailService() - component.provideUtility(_mail_srv, IMailService) + component.getGlobalSiteManager().registerUtility(_mail_srv, IMailService) return _mail_srv @pytest.fixture(scope='session') def mysql_srv(cfg): mysql_srv = MySQLService() - component.provideUtility(mysql_srv, IDatabaseService, 'mysql') + component.getGlobalSiteManager().registerUtility(mysql_srv, IDatabaseService, 'mysql') return mysql_srv @pytest.fixture(scope='session') def postgresql_srv(cfg): psql_srv = PostgreSQLService() - component.provideUtility(psql_srv, IDatabaseService, 'postgresql') + component.getGlobalSiteManager().registerUtility(psql_srv, IDatabaseService, 'postgresql') return psql_srv From 6e2b9dee24e1689545b9465babf3bac1b63642f5 Mon Sep 17 00:00:00 2001 From: n3parikh Date: Wed, 8 Sep 2021 18:23:29 -0400 Subject: [PATCH 17/18] Update discord link (#13) Co-authored-by: n3parikh Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/13 Co-authored-by: n3parikh Co-committed-by: n3parikh --- ceod/model/templates/welcome_message.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ceod/model/templates/welcome_message.j2 b/ceod/model/templates/welcome_message.j2 index 27b7273..f4577d3 100644 --- a/ceod/model/templates/welcome_message.j2 +++ b/ceod/model/templates/welcome_message.j2 @@ -11,7 +11,7 @@ You can hear about upcoming events in a number of ways: * Check our website from time to time: http://csclub.uwaterloo.ca/ * Subscribe to our events calendar feed: http://csclub.uwaterloo.ca/events.ics * Like the CSC on Facebook: https://www.facebook.com/uw.computerscienceclub -* Join the CSC Discord server: https://discord.gg/uwcsclub +* Join the CSC Discord server: https://discord.gg/pHfYBCg * Read your email: announcements are sent via the csc-general mailing list * Keep an eye out in the MC: posters for upcoming events appear in stairwells and hallways From cb6243c3e25898b31769b343324a62bfe4eb213c Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Wed, 8 Sep 2021 22:28:29 +0000 Subject: [PATCH 18/18] remove unused handler --- ceo/tui/start.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 1183087..2bf1825 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -1,7 +1,6 @@ import sys -from asciimatics.event import KeyboardEvent -from asciimatics.exceptions import ResizeScreenError, StopApplication +from asciimatics.exceptions import ResizeScreenError from asciimatics.scene import Scene from asciimatics.screen import Screen @@ -26,14 +25,6 @@ from .members.ResetPasswordResultView import ResetPasswordResultView from .members.SetForwardingAddressesView import SetForwardingAddressesView -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") - - # tuples of (name, view) views = [] @@ -73,7 +64,7 @@ def screen_wrapper(screen, last_scene, model): 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) + ) def main():