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()