diff --git a/.drone/coffee-setup.sh b/.drone/coffee-setup.sh index 98d861a..0773aa3 100755 --- a/.drone/coffee-setup.sh +++ b/.drone/coffee-setup.sh @@ -28,13 +28,16 @@ POSTGRES_DIR=/etc/postgresql/11/main cat < $POSTGRES_DIR/pg_hba.conf # TYPE DATABASE USER ADDRESS METHOD local all postgres peer +host all postgres localhost md5 host all postgres 0.0.0.0/0 md5 +host all postgres ::/0 md5 local all all peer host all all localhost md5 local sameuser all peer host sameuser all 0.0.0.0/0 md5 +host sameuser all ::/0 md5 EOF grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \ echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf diff --git a/ceo/term_utils.py b/ceo/term_utils.py index 5460b33..56b635a 100644 --- a/ceo/term_utils.py +++ b/ceo/term_utils.py @@ -15,13 +15,13 @@ def get_terms_for_new_user(num_terms: int) -> List[str]: def get_terms_for_renewal( - username: str, num_terms: int, clubrep: bool, tui_model=None, + username: str, num_terms: int, clubrep: bool, tui_controller=None, ) -> List[str]: resp = http_get('/api/members/' + username) - if tui_model is None: + if tui_controller is None: result = cli_utils.handle_sync_response(resp) else: - result = tui_utils.handle_sync_response(resp, tui_model) + result = tui_utils.handle_sync_response(resp, tui_controller) max_term = None current_term = Term.current() if clubrep and 'non_member_terms' in result: diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py deleted file mode 100644 index 3a26dc8..0000000 --- a/ceo/tui/CeoFrame.py +++ /dev/null @@ -1,160 +0,0 @@ -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): - def __init__( - self, - screen, - height, - width, - model, - name, - on_load=None, - title=None, - escape_on_q=False, # whether to quit when 'q' is pressed - ): - super().__init__( - screen, - height, - width, - name=name, - can_scroll=False, - title=title, - on_load=self._ceoframe_on_load, - ) - self._custom_on_load = on_load - self._model = model - self._name = name - # If a view has a custom on_load function, all layouts should - # be created on load (*not* in the constructor) - self._has_dynamic_layouts = on_load is not None - self._quit_keys = [Screen.KEY_ESCAPE] - if escape_on_q: - self._quit_keys.append(ord('q')) - # child classes may override this as a last resort - self.skip_reload = False - - def _ceoframe_on_load(self): - if self.skip_reload: - self.skip_reload = False - return - if self._model.title is not None: - self.title = self._model.title - self._model.title = None - if self._has_dynamic_layouts and self._model.nav_direction == 'forward': - # We arrive here after a user pressed 'Back' then 'Next', - # or after we returned to the Welcome screen. - # The data may have changed, so we need to redraw everything, - # via self._custom_on_load(). - self.clear_layouts() - self._custom_on_load() - - # may be overridden by child classes - def _ceoframe_on_reset(self): - """ - This is called whenever we return to the home screen - after some kind of operation was completed. - This is called from Model.reset(). - """ - pass - - def clear_layouts(self): - self._layouts.clear() - - def force_update(self): - """ - This should be called by background threads after they make changes - to the UI. - """ - # 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.fix() - self._model.screen.force_update() - - def add_buttons( - self, back_btn=False, back_btn_text='Back', - next_scene=None, next_btn_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(): - self._model.nav_direction = 'backward' - last_scene = self._model.scene_stack.pop() - if last_scene == 'Welcome': - self._model.reset() - raise NextScene(last_scene) - - def _next(): - self._model.nav_direction = 'forward' - if on_next_excl is not None: - on_next_excl() - return - if on_next is not None: - on_next() - 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_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, force_update: bool = False): - self.flash_message('', force_update) - - 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 deleted file mode 100644 index 2914519..0000000 --- a/ceo/tui/ConfirmView.py +++ /dev/null @@ -1,61 +0,0 @@ -from asciimatics.widgets import Layout, Label - -from .CeoFrame import CeoFrame - - -class ConfirmView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'Confirm', - on_load=self._confirmview_on_load, title='Confirmation', - escape_on_q=True, - ) - - def _add_line(self, text: str = ''): - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Label(text, align='^')) - - def _add_pair(self, key: str, val: str): - layout = Layout([10, 1, 10]) - self.add_layout(layout) - layout.add_widget(Label(key + ':', align='>'), 0) - layout.add_widget(Label(val, align='<'), 2) - - def _confirmview_on_load(self): - for _ in range(2): - self._add_line() - for line in self._model.confirm_lines: - if isinstance(line, str): - self._add_line(line) - else: - # assume tuple - key, val = line - self._add_pair(key, val) - # fill the rest of the space - self.add_layout(Layout([100], fill_frame=True)) - - kwargs = { - 'back_btn': True, 'back_btn_text': 'No', 'next_btn_text': 'Yes', - } - if self._model.operations is not None: - kwargs['next_scene'] = self._model.txn_view_name or 'Transaction' - else: - self.add_flash_message_layout() - kwargs['on_next_excl'] = self._next - self.add_buttons(**kwargs) - self.fix() - # OK so there's some weird bug somewhere which causes the buttons to be unselectable - # if we add a new user, return to the Welcome screen, then try to renew a user. - # This is a workaround for that. - self.skip_reload = True - self.reset() - - def _next(self): - self.flash_message('Sending request...', force_update=True) - try: - self._model.resp = self._model.deferred_req() - finally: - self.clear_flash_message() - next_scene = self._model.result_view_name or 'Result' - self.go_to_next_scene(next_scene) diff --git a/ceo/tui/ErrorView.py b/ceo/tui/ErrorView.py deleted file mode 100644 index 2615ed5..0000000 --- a/ceo/tui/ErrorView.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index a6ea571..0000000 --- a/ceo/tui/Model.py +++ /dev/null @@ -1,56 +0,0 @@ -from copy import deepcopy - -from zope import component - -from ceo_common.interfaces import IConfig - - -class Model: - """A convenient place to store View data persistently.""" - - def __init__(self): - cfg = component.getUtility(IConfig) - - self.screen = None - self.views = [] - self.title = None - self.scene_stack = [] - self.result_view_name = None - self.txn_view_name = None - self.error_message = None - self.nav_direction = 'forward' - # View-specific data - self._initial_viewdata = { - 'ResetPassword': { - 'uid': '', - }, - } - for pos in cfg.get('positions_available'): - self._initial_viewdata[pos] = '' - self.viewdata = deepcopy(self._initial_viewdata) - # data which is shared between multiple views - self.is_club_rep = False - self.confirm_lines = None - self.operations = None - self.deferred_req = None - self.resp = None - self.db_type = None - self.user_dict = None - - 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.resp = None - self.db_type = None - self.user_dict = None - self.title = None - self.error_message = None - self.scene_stack.clear() - self.result_view_name = None - self.txn_view_name = None - 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 deleted file mode 100644 index f08890b..0000000 --- a/ceo/tui/ResultView.py +++ /dev/null @@ -1,62 +0,0 @@ -from asciimatics.exceptions import NextScene -from asciimatics.widgets import Layout, Label -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', - escape_on_q=True, - ) - - # 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) - 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 - 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 not resp.ok: - self._add_text('An error occurred:') - 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 - 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') diff --git a/ceo/tui/TUIStreamResponseHandler.py b/ceo/tui/TUIStreamResponseHandler.py deleted file mode 100644 index 4645dd2..0000000 --- a/ceo/tui/TUIStreamResponseHandler.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Dict, Union - -from asciimatics.widgets import Label, Layout -import requests - -from .Model import Model -from ..StreamResponseHandler import StreamResponseHandler - - -class TUIStreamResponseHandler(StreamResponseHandler): - def __init__( - self, - model: Model, - labels: Dict[str, Label], - msg_layout: Layout, - txn_view, # TransactionView - ): - super().__init__() - self.screen = model.screen - self.operations = model.operations - self.idx = 0 - self.labels = labels - self.msg_layout = msg_layout - self.txn_view = txn_view - self.error_messages = [] - - def _update(self): - self.txn_view.force_update() - - def _show_msg(self, msg: str = '\n'): - for line in msg.splitlines(): - self.msg_layout.add_widget(Label(line, align='^')) - - def _abort(self): - for operation in self.operations[self.idx:]: - self.labels[operation].text = 'ABORTED' - self.txn_view.enable_next_btn() - - def handle_non_200(self, resp: requests.Response): - self._abort() - self._show_msg('An error occurred:') - 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): - pass - - def handle_aborted(self, err_msg: str): - self._abort() - self._show_msg('The transaction was rolled back.') - 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() - - def handle_completed(self): - self._show_msg('Transaction successfully completed.') - if len(self.error_messages) > 0: - 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): - operation = self.operations[self.idx] - self.labels[operation].text = 'Done' - self.idx += 1 - self._update() - - def handle_failed_operation(self, err_msg: Union[str, None]): - operation = self.operations[self.idx] - self.labels[operation].text = 'Failed' - if err_msg is not None: - self.error_messages.append(err_msg) - self.idx += 1 - self._update() - - def handle_skipped_operation(self): - operation = self.operations[self.idx] - self.labels[operation].text = 'Skipped' - self.idx += 1 - self._update() - - def handle_unrecognized_operation(self, operation: str): - self.error_messages.append('Unrecognized operation: ' + operation) - self.idx += 1 - self._update() diff --git a/ceo/tui/TransactionView.py b/ceo/tui/TransactionView.py deleted file mode 100644 index 0cd2f61..0000000 --- a/ceo/tui/TransactionView.py +++ /dev/null @@ -1,85 +0,0 @@ -from threading import Thread -from typing import List, Dict - -from asciimatics.exceptions import NextScene -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(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'Transaction', - on_load=self._txnview_on_load, title='Running Transaction', - ) - # map operation names to label widgets - self._labels = {} - - 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) - layout.add_widget(self._next_btn, 1) - - def _add_blank_line(self): - self._op_layout.add_widget(Label(''), 0) - self._op_layout.add_widget(Label(''), 2) - - def _txnview_on_load(self): - 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 - 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) - # 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() - - def _do_txn(self): - resp = self._model.deferred_req() - handler = TUIStreamResponseHandler( - model=self._model, - labels=self._labels, - msg_layout=self._msg_layout, - txn_view=self, - ) - data = generic_handle_stream_response(resp, self._model.operations, handler) - self.write_extra_txn_info(data) - - # to be overridden in child classes if desired - def write_extra_txn_info(self, data: List[Dict]): - pass - - 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. - # We don't want to reload, though (which reset() will trigger). - self.skip_reload = True - self.reset() - - def _next(self): - self._model.reset() - raise NextScene('Welcome') diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py deleted file mode 100644 index 8c30d63..0000000 --- a/ceo/tui/WelcomeView.py +++ /dev/null @@ -1,107 +0,0 @@ -from asciimatics.widgets import ListBox, Layout, Divider, Button, Label -from asciimatics.exceptions import NextScene, StopApplication - -from .CeoFrame import CeoFrame - - -class WelcomeView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'Welcome', - title='CSC Electronic Office', - escape_on_q=True, - ) - members_menu_items = [ - ('Add member', 'AddUser'), - ('Add club rep', 'AddUser'), - ('Renew member', 'RenewUser'), - ('Renew club rep', 'RenewUser'), - ('Get user info', 'GetUser'), - ('Reset password', 'ResetPassword'), - ('Change login shell', 'ChangeLoginShell'), - ('Set forwarding addresses', 'SetForwardingAddresses'), - ] - groups_menu_items = [ - ('Add group', 'AddGroup'), - ('Get group members', 'GetGroup'), - ('Add member to group', 'AddMemberToGroup'), - ('Remove member from group', 'RemoveMemberFromGroup'), - ] - db_menu_items = [ - ('Create MySQL database', 'CreateDatabase'), - ('Reset MySQL password', 'ResetDatabasePassword'), - ('Create PostgreSQL database', 'CreateDatabase'), - ('Reset PostgreSQL password', 'ResetDatabasePassword'), - ] - positions_menu_items = [ - ('Get positions', 'GetPositions'), - ('Set positions', 'SetPositions'), - ] - self.menu_items = [ - ('members', members_menu_items), - ('groups', groups_menu_items), - ('databases', db_menu_items), - ('positions', positions_menu_items), - ] - self.menu_items_dict = dict(self.menu_items) - flat_menu_items = [item for name, items in self.menu_items for item in items] - menu = ListBox( - len(flat_menu_items), - [ - (desc, i) for i, (desc, view) in - enumerate(flat_menu_items) - ], - name='menu', - on_select=self._menu_select, - ) - labels = [] - for name, items in self.menu_items: - labels.append(Label(name.capitalize(), align='>')) - for _ in range(len(items) - 1): - labels.append(Label('')) - - layout = Layout([5, 1, 8], fill_frame=True) - self.add_layout(layout) - layout.add_widget(menu, 2) - for label in labels: - layout.add_widget(label, 0) - - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Label('Press to switch widgets')) - layout.add_widget(Divider()) - - layout = Layout([1, 1, 1]) - self.add_layout(layout) - layout.add_widget(Button("Quit", self._quit), 2) - self.fix() - - def _menu_select(self): - self.save() - item_id = self.data['menu'] - # find which submenu the item belongs to - counter = 0 - for name, items in self.menu_items: - if item_id < counter + len(items): - break - counter += len(items) - submenu_idx = item_id - counter - desc, view = items[submenu_idx] - if name == 'members': - if desc.endswith('club rep'): - self._model.is_club_rep = True - elif name == 'databases': - if 'MySQL' in desc: - self._model.db_type = 'mysql' - else: - self._model.db_type = 'postgresql' - 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) - - @staticmethod - def _quit(): - raise StopApplication("User pressed quit") diff --git a/ceo/tui/app.py b/ceo/tui/app.py new file mode 100644 index 0000000..9d896e8 --- /dev/null +++ b/ceo/tui/app.py @@ -0,0 +1,30 @@ +import os +from queue import SimpleQueue + + +class App: + REL_WIDTH_PCT = 60 + REL_HEIGHT_PCT = 70 + # On a full-screen (1366x768) gnome-terminal window, + # I had 168 cols and 36 rows + WIDTH = int(0.6 * 168) + HEIGHT = int(0.7 * 36) + + def __init__(self, loop, main_widget): + self.loop = loop + self.main_widget = main_widget + self.history = [] + self.queued_pipe_callbacks = SimpleQueue() + self.pipefd = loop.watch_pipe(self._pipe_callback) + + def run_in_main_loop(self, func): + self.queued_pipe_callbacks.put(func) + os.write(self.pipefd, b'\x00') + + def _pipe_callback(self, data): + # We need to clear the whole queue because select() + # will only send one "notification" if there are two + # consecutive writes + while not self.queued_pipe_callbacks.empty(): + self.queued_pipe_callbacks.get()() + return True diff --git a/ceo/tui/controllers/AddGroupController.py b/ceo/tui/controllers/AddGroupController.py new file mode 100644 index 0000000..0ef7dea --- /dev/null +++ b/ceo/tui/controllers/AddGroupController.py @@ -0,0 +1,36 @@ +from .Controller import Controller +from .TransactionController import TransactionController +from ceo.tui.models import TransactionModel +from ceo.tui.views import AddGroupConfirmationView, TransactionView +from ceod.transactions.groups import AddGroupTransaction + + +class AddGroupController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + + def on_next_button_pressed(self, button): + try: + self.model.name = self.get_group_name_from_view() + self.model.description = self.view.description_edit.edit_text + if not self.model.description: + self.view.popup('Description must not be empty') + raise Controller.InvalidInput() + except Controller.InvalidInput: + return + view = AddGroupConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def on_confirmation_button_pressed(self, button): + body = { + 'cn': self.model.name, + 'description': self.model.description, + } + model = TransactionModel( + AddGroupTransaction.operations, + 'POST', '/api/groups', json=body + ) + controller = TransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) diff --git a/ceo/tui/controllers/AddMemberToGroupController.py b/ceo/tui/controllers/AddMemberToGroupController.py new file mode 100644 index 0000000..6141d8c --- /dev/null +++ b/ceo/tui/controllers/AddMemberToGroupController.py @@ -0,0 +1,37 @@ +from .Controller import Controller +from ceod.transactions.groups import AddMemberToGroupTransaction +from .TransactionController import TransactionController +from ceo.tui.models import TransactionModel +from ceo.tui.views import AddMemberToGroupConfirmationView, TransactionView + + +class AddMemberToGroupController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + + def on_list_subscribe_checkbox_change(self, checkbox, new_state): + self.model.subscribe_to_lists = new_state + + def on_next_button_pressed(self, button): + try: + self.model.name = self.get_group_name_from_view() + self.model.username = self.get_username_from_view() + except Controller.InvalidInput: + return + view = AddMemberToGroupConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def on_confirmation_button_pressed(self, button): + cn = self.model.name + uid = self.model.username + url = f'/api/groups/{cn}/members/{uid}' + if not self.model.subscribe_to_lists: + url += '?subscribe_to_lists=false' + model = TransactionModel( + AddMemberToGroupTransaction.operations, + 'POST', url + ) + controller = TransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) diff --git a/ceo/tui/controllers/AddUserController.py b/ceo/tui/controllers/AddUserController.py new file mode 100644 index 0000000..b0b7604 --- /dev/null +++ b/ceo/tui/controllers/AddUserController.py @@ -0,0 +1,110 @@ +from threading import Thread + +from ...utils import http_get +from .Controller import Controller +from .AddUserTransactionController import AddUserTransactionController +import ceo.term_utils as term_utils +from ceo.tui.models import TransactionModel +from ceo.tui.views import AddUserConfirmationView, TransactionView +from ceod.transactions.members import AddMemberTransaction + + +class AddUserController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + self.right_col_idx = 0 + self.prev_searched_username = None + + def on_confirmation_button_pressed(self, button): + body = { + 'uid': self.model.username, + 'cn': self.model.full_name, + 'given_name': self.model.first_name, + 'sn': self.model.last_name, + } + if self.model.program: + body['program'] = self.model.program + if self.model.forwarding_address: + body['forwarding_addresses'] = [self.model.forwarding_address] + new_terms = term_utils.get_terms_for_new_user(self.model.num_terms) + if self.model.membership_type == 'club_rep': + body['non_member_terms'] = new_terms + else: + body['terms'] = new_terms + + model = TransactionModel( + AddMemberTransaction.operations, + 'POST', '/api/members', + json=body + ) + controller = AddUserTransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) + + def on_next_button_pressed(self, button): + try: + username = self.get_username_from_view() + num_terms = self.get_num_terms_from_view() + except Controller.InvalidInput: + return + full_name = self.view.full_name_edit.edit_text + # TODO: share validation logic between CLI and TUI + if not full_name: + self.view.popup('Full name must not be empty') + return + self.model.username = username + self.model.full_name = full_name + self.model.first_name = self.view.first_name_edit.edit_text + self.model.last_name = self.view.last_name_edit.edit_text + self.model.program = self.view.program_edit.edit_text + self.model.forwarding_address = self.view.forwarding_address_edit.edit_text + self.model.num_terms = num_terms + view = AddUserConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def on_membership_type_changed(self, radio_button, new_state, selected_type): + if new_state: + self.model.membership_type = selected_type + + def on_row_focus_changed(self): + _, idx = self.view.listwalker.get_focus() + old_idx = self.right_col_idx + self.right_col_idx = idx + # The username field is the third row, so when + # idx changes from 2 to 3, this means the user + # moved from the username field to the next field + if old_idx == 2 and idx == 3: + Thread( + target=self._lookup_user, + args=(self.view.username_edit.edit_text,) + ).start() + + def _set_flash_text(self, *args): + self.view.flash_text.set_text('Looking up user...') + + def _clear_flash_text(self): + self.view.flash_text.set_text('') + + def _on_lookup_user_success(self): + self._clear_flash_text() + self.view.update_fields() + + def _lookup_user(self, username): + if not username: + return + if username == self.prev_searched_username: + return + self.prev_searched_username = username + self.app.run_in_main_loop(self._set_flash_text) + resp = http_get('/api/uwldap/' + username) + if not resp.ok: + self.app.run_in_main_loop(self._clear_flash_text) + return + data = resp.json() + self.model.full_name = data.get('cn', '') + self.model.first_name = data.get('given_name', '') + self.model.last_name = data.get('sn', '') + self.model.program = data.get('program', '') + self.model.forwarding_address = data.get('mail_local_addresses', [''])[0] + self.app.run_in_main_loop(self._on_lookup_user_success) diff --git a/ceo/tui/controllers/AddUserTransactionController.py b/ceo/tui/controllers/AddUserTransactionController.py new file mode 100644 index 0000000..b3fa36d --- /dev/null +++ b/ceo/tui/controllers/AddUserTransactionController.py @@ -0,0 +1,38 @@ +from typing import Dict, List + +from ...utils import get_failed_operations +from .TransactionController import TransactionController + + +class AddUserTransactionController(TransactionController): + def __init__(self, model, app): + super().__init__(model, app) + + def handle_completed(self): + # We don't want to write to the message_text yet, but + # we still need to enable the Next button. + self.app.run_in_main_loop(self.view.enable_next_button) + + def write_extra_txn_info(self, data: List[Dict]): + if data[-1]['status'] != 'completed': + return + result = data[-1]['result'] + failed_operations = get_failed_operations(data) + lines = [] + if failed_operations: + lines.append('Transaction successfully completed with some errors.') + else: + lines.append('Transaction successfully completed.') + lines.append('') + lines.append('User password is: ' + result['password']) + if 'send_welcome_message' in failed_operations: + lines.extend([ + '', + 'Since the welcome message was not sent, ' + 'you need to email this password to the user.' + ]) + + def target(): + self._show_lines(lines) + + self.app.run_in_main_loop(target) diff --git a/ceo/tui/controllers/ChangeLoginShellController.py b/ceo/tui/controllers/ChangeLoginShellController.py new file mode 100644 index 0000000..4b57ff3 --- /dev/null +++ b/ceo/tui/controllers/ChangeLoginShellController.py @@ -0,0 +1,79 @@ +from threading import Thread + +from ...utils import http_get +from .Controller import Controller +from .TransactionController import TransactionController +from ceo.tui.models import TransactionModel +from ceo.tui.views import ChangeLoginShellConfirmationView, TransactionView + + +class ChangeLoginShellController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + self.right_col_idx = 0 + self.prev_searched_username = None + + def on_next_button_pressed(self, button): + try: + self.model.username = self.get_username_from_view() + self.model.login_shell = self.view.login_shell_edit.edit_text + if not self.model.login_shell: + self.view.popup('Login shell must not be empty') + raise Controller.InvalidInput() + except Controller.InvalidInput: + return + view = ChangeLoginShellConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def on_confirmation_button_pressed(self, button): + body = {'login_shell': self.model.login_shell} + model = TransactionModel( + ['replace_login_shell'], + 'PATCH', f'/api/members/{self.model.username}', + json=body + ) + controller = TransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) + + # TODO: reduce code duplication with AddUserController + + def on_row_focus_changed(self): + _, idx = self.view.listwalker.get_focus() + old_idx = self.right_col_idx + self.right_col_idx = idx + # The username field is the first row, so when + # idx changes from 0 to 1, this means the user + # moved from the username field to the next field + if old_idx == 0 and idx == 1: + Thread( + target=self._lookup_user, + args=(self.view.username_edit.edit_text,) + ).start() + + def _set_flash_text(self, *args): + self.view.flash_text.set_text('Looking up user...') + + def _clear_flash_text(self): + self.view.flash_text.set_text('') + + def _on_lookup_user_success(self): + self._clear_flash_text() + self.view.update_fields() + + def _lookup_user(self, username): + if not username: + return + if username == self.prev_searched_username: + return + self.prev_searched_username = username + self.app.run_in_main_loop(self._set_flash_text) + resp = http_get('/api/members/' + username) + if not resp.ok: + self.app.run_in_main_loop(self._clear_flash_text) + return + data = resp.json() + self.model.login_shell = data.get('login_shell', '') + + self.app.run_in_main_loop(self._on_lookup_user_success) diff --git a/ceo/tui/controllers/Controller.py b/ceo/tui/controllers/Controller.py new file mode 100644 index 0000000..f47f6ee --- /dev/null +++ b/ceo/tui/controllers/Controller.py @@ -0,0 +1,78 @@ +from abc import ABC + +import ceo.tui.utils as utils + + +# NOTE: one controller can control multiple views, +# but each view must have exactly one controller +class Controller(ABC): + class InvalidInput(Exception): + pass + + class RequestFailed(Exception): + pass + + def __init__(self, model, app): + super().__init__() + self.model = model + self.app = app + # Since the view and the controller both have a reference to each + # other, this needs to be initialized in a separate step + self.view = None + + def _push_history(self, old_view, new_view): + if new_view.model.name == 'Welcome': + self.app.history.clear() + else: + self.app.history.append(old_view) + + def switch_to_view(self, new_view): + self._push_history(self.view, new_view) + self.view = new_view + new_view.activate() + + def go_to_next_menu(self, next_menu_name): + _, new_view, _ = utils.get_mvc(self.app, next_menu_name) + self._push_history(self.view, new_view) + new_view.activate() + + def prev_menu_callback(self, button): + prev_view = self.app.history.pop() + prev_view.controller.view = prev_view + prev_view.activate() + + def next_menu_callback(self, button, next_menu_name): + self.go_to_next_menu(next_menu_name) + + def get_next_menu_callback(self, next_menu_name): + def callback(button): + self.next_menu_callback(button, next_menu_name) + return callback + + def get_username_from_view(self): + username = self.view.username_edit.edit_text + # TODO: share validation logic between CLI and TUI + if not username: + self.view.popup('Username must not be empty') + raise Controller.InvalidInput() + return username + + def get_group_name_from_view(self): + name = self.view.name_edit.edit_text + # TODO: share validation logic between CLI and TUI + if not name: + self.view.popup('Name must not be empty') + raise Controller.InvalidInput() + return name + + def get_num_terms_from_view(self): + num_terms_str = self.view.num_terms_edit.edit_text + if num_terms_str: + num_terms = int(num_terms_str) + else: + num_terms = 0 + # TODO: share validation logic between CLI and TUI + if num_terms <= 0: + self.view.popup('Number of terms must be a positive integer') + raise Controller.InvalidInput() + return num_terms diff --git a/ceo/tui/controllers/CreateDatabaseController.py b/ceo/tui/controllers/CreateDatabaseController.py new file mode 100644 index 0000000..07d3f0d --- /dev/null +++ b/ceo/tui/controllers/CreateDatabaseController.py @@ -0,0 +1,50 @@ +import os + +from zope import component + +from ...utils import http_get, http_post, write_db_creds +from .SyncRequestController import SyncRequestController +import ceo.krb_check as krb +from ceo.tui.views import CreateDatabaseConfirmationView, CreateDatabaseResponseView +from ceo_common.interfaces import IConfig + + +class CreateDatabaseController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def on_db_type_changed(self, radio_button, new_state, selected_type): + if new_state: + self.model.db_type = selected_type + + def on_next_button_pressed(self, button): + view = CreateDatabaseConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def get_resp(self): + db_type = self.model.db_type + username = krb.get_username() + resp = http_get(f'/api/members/{username}') + if not resp.ok: + return resp + self.model.user_dict = resp.json() + return http_post(f'/api/db/{db_type}/{username}') + + def get_response_view(self): + return CreateDatabaseResponseView(self.model, self, self.app) + + def write_db_creds_to_file(self): + password = self.model.resp_json['password'] + db_type = self.model.db_type + cfg = component.getUtility(IConfig) + db_host = cfg.get(f'{db_type}_host') + homedir = self.model.user_dict['home_directory'] + filename = os.path.join(homedir, f"ceo-{db_type}-info") + wrote_to_file = write_db_creds( + filename, self.model.user_dict, password, db_type, db_host + ) + + self.model.password = password + self.model.db_host = db_host + self.model.filename = filename + self.model.wrote_to_file = wrote_to_file diff --git a/ceo/tui/controllers/GetGroupController.py b/ceo/tui/controllers/GetGroupController.py new file mode 100644 index 0000000..2f6d6f3 --- /dev/null +++ b/ceo/tui/controllers/GetGroupController.py @@ -0,0 +1,22 @@ +from ...utils import http_get +from .Controller import Controller +from .SyncRequestController import SyncRequestController +from ceo.tui.views import GetGroupResponseView + + +class GetGroupController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def get_resp(self): + return http_get(f'/api/groups/{self.model.name}') + + def get_response_view(self): + return GetGroupResponseView(self.model, self, self.app) + + def on_next_button_pressed(self, button): + try: + self.model.name = self.get_group_name_from_view() + except Controller.InvalidInput: + return + self.on_confirmation_button_pressed(button) diff --git a/ceo/tui/controllers/GetPositionsController.py b/ceo/tui/controllers/GetPositionsController.py new file mode 100644 index 0000000..3c40ec1 --- /dev/null +++ b/ceo/tui/controllers/GetPositionsController.py @@ -0,0 +1,29 @@ +from threading import Thread + +from ...utils import http_get +from .Controller import Controller +import ceo.tui.utils as tui_utils + + +class GetPositionsController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + + def lookup_positions_async(self): + self.view.flash_text.set_text('Looking up positions...') + Thread(target=self.lookup_positions_sync).start() + + def lookup_positions_sync(self): + resp = http_get('/api/positions') + try: + positions = tui_utils.handle_sync_response(resp, self) + except Controller.RequestFailed: + return + for pos, username in positions.items(): + self.model.positions[pos] = username + + def target(): + self.view.flash_text.set_text('') + self.view.update_fields() + + self.app.run_in_main_loop(target) diff --git a/ceo/tui/controllers/GetUserController.py b/ceo/tui/controllers/GetUserController.py new file mode 100644 index 0000000..f7975d3 --- /dev/null +++ b/ceo/tui/controllers/GetUserController.py @@ -0,0 +1,22 @@ +from ...utils import http_get +from .Controller import Controller +from .SyncRequestController import SyncRequestController +from ceo.tui.views import GetUserResponseView + + +class GetUserController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def get_resp(self): + return http_get(f'/api/members/{self.model.username}') + + def get_response_view(self): + return GetUserResponseView(self.model, self, self.app) + + def on_next_button_pressed(self, button): + try: + self.model.username = self.get_username_from_view() + except Controller.InvalidInput: + return + self.on_confirmation_button_pressed(button) diff --git a/ceo/tui/controllers/RemoveMemberFromGroupController.py b/ceo/tui/controllers/RemoveMemberFromGroupController.py new file mode 100644 index 0000000..b99125e --- /dev/null +++ b/ceo/tui/controllers/RemoveMemberFromGroupController.py @@ -0,0 +1,37 @@ +from .Controller import Controller +from ceod.transactions.groups import RemoveMemberFromGroupTransaction +from .TransactionController import TransactionController +from ceo.tui.models import TransactionModel +from ceo.tui.views import RemoveMemberFromGroupConfirmationView, TransactionView + + +class RemoveMemberFromGroupController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + + def on_list_unsubscribe_checkbox_change(self, checkbox, new_state): + self.model.unsubscribe_from_lists = new_state + + def on_next_button_pressed(self, button): + try: + self.model.name = self.get_group_name_from_view() + self.model.username = self.get_username_from_view() + except Controller.InvalidInput: + return + view = RemoveMemberFromGroupConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def on_confirmation_button_pressed(self, button): + cn = self.model.name + uid = self.model.username + url = f'/api/groups/{cn}/members/{uid}' + if not self.model.unsubscribe_from_lists: + url += '?unsubscribe_from_lists=false' + model = TransactionModel( + RemoveMemberFromGroupTransaction.operations, + 'DELETE', url + ) + controller = TransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) diff --git a/ceo/tui/controllers/RenewUserController.py b/ceo/tui/controllers/RenewUserController.py new file mode 100644 index 0000000..f836f9f --- /dev/null +++ b/ceo/tui/controllers/RenewUserController.py @@ -0,0 +1,54 @@ +from threading import Thread + +from ...utils import http_post +from .Controller import Controller +from .SyncRequestController import SyncRequestController +import ceo.term_utils as term_utils +from ceo.tui.views import RenewUserConfirmationView + + +class RenewUserController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def on_membership_type_changed(self, radio_button, new_state, selected_type): + if new_state: + self.model.membership_type = selected_type + + def on_next_button_pressed(self, button): + try: + username = self.get_username_from_view() + num_terms = self.get_num_terms_from_view() + except Controller.InvalidInput: + return + self.model.username = username + self.model.num_terms = num_terms + self.view.flash_text.set_text('Looking up user...') + Thread(target=self._get_next_terms).start() + + def _get_next_terms(self): + try: + self.model.new_terms = term_utils.get_terms_for_renewal( + self.model.username, + self.model.num_terms, + self.model.membership_type == 'club_rep', + self + ) + except Controller.RequestFailed: + return + + def target(): + self.view.flash_text.set_text('') + view = RenewUserConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + self.app.run_in_main_loop(target) + + def get_resp(self): + uid = self.model.username + body = {'uid': uid} + if self.model.membership_type == 'club_rep': + body['non_member_terms'] = self.model.new_terms + else: + body['terms'] = self.model.new_terms + return http_post(f'/api/members/{uid}/renew', json=body) diff --git a/ceo/tui/controllers/ResetDatabasePasswordController.py b/ceo/tui/controllers/ResetDatabasePasswordController.py new file mode 100644 index 0000000..a078a2b --- /dev/null +++ b/ceo/tui/controllers/ResetDatabasePasswordController.py @@ -0,0 +1,49 @@ +import os + +from zope import component + +from ...utils import http_get, http_post, write_db_creds +from .SyncRequestController import SyncRequestController +import ceo.krb_check as krb +from ceo.tui.views import ResetDatabasePasswordConfirmationView, ResetDatabasePasswordResponseView +from ceo_common.interfaces import IConfig + + +class ResetDatabasePasswordController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def on_db_type_changed(self, radio_button, new_state, selected_type): + if new_state: + self.model.db_type = selected_type + + def on_next_button_pressed(self, button): + view = ResetDatabasePasswordConfirmationView(self.model, self, self.app) + self.switch_to_view(view) + + def get_resp(self): + db_type = self.model.db_type + username = krb.get_username() + resp = http_get(f'/api/members/{username}') + if not resp.ok: + return resp + self.model.user_dict = resp.json() + return http_post(f'/api/db/{db_type}/{username}/pwreset') + + def get_response_view(self): + return ResetDatabasePasswordResponseView(self.model, self, self.app) + + def write_db_creds_to_file(self): + password = self.model.resp_json['password'] + db_type = self.model.db_type + cfg = component.getUtility(IConfig) + db_host = cfg.get(f'{db_type}_host') + homedir = self.model.user_dict['home_directory'] + filename = os.path.join(homedir, f"ceo-{db_type}-info") + wrote_to_file = write_db_creds( + filename, self.model.user_dict, password, db_type, db_host + ) + + self.model.password = password + self.model.filename = filename + self.model.wrote_to_file = wrote_to_file diff --git a/ceo/tui/controllers/ResetPasswordController.py b/ceo/tui/controllers/ResetPasswordController.py new file mode 100644 index 0000000..52313b1 --- /dev/null +++ b/ceo/tui/controllers/ResetPasswordController.py @@ -0,0 +1,27 @@ +from ...utils import http_post +from .Controller import Controller +from .SyncRequestController import SyncRequestController +import ceo.krb_check as krb +from ceo.tui.views import ResetPasswordUsePasswdView, ResetPasswordConfirmationView, ResetPasswordResponseView + + +class ResetPasswordController(SyncRequestController): + def __init__(self, model, app): + super().__init__(model, app) + + def get_resp(self): + return http_post(f'/api/members/{self.model.username}/pwreset') + + def get_response_view(self): + return ResetPasswordResponseView(self.model, self, self.app) + + def on_next_button_pressed(self, button): + try: + self.model.username = self.get_username_from_view() + except Controller.InvalidInput: + return + if self.model.username == krb.get_username(): + view = ResetPasswordUsePasswdView(self.model, self, self.app) + else: + view = ResetPasswordConfirmationView(self.model, self, self.app) + self.switch_to_view(view) diff --git a/ceo/tui/controllers/SetPositionsController.py b/ceo/tui/controllers/SetPositionsController.py new file mode 100644 index 0000000..17f8000 --- /dev/null +++ b/ceo/tui/controllers/SetPositionsController.py @@ -0,0 +1,47 @@ +from threading import Thread + +from ...utils import http_get +from .Controller import Controller +from .TransactionController import TransactionController +from ceo.tui.models import TransactionModel +import ceo.tui.utils as tui_utils +from ceo.tui.views import TransactionView +from ceod.transactions.members import UpdateMemberPositionsTransaction + + +class SetPositionsController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + + def on_next_button_pressed(self, button): + body = {} + for pos, field in self.view.position_fields.items(): + if field.edit_text != '': + body[pos] = field.edit_text + model = TransactionModel( + UpdateMemberPositionsTransaction.operations, + 'POST', '/api/positions', json=body + ) + controller = TransactionController(model, self.app) + view = TransactionView(model, controller, self.app) + controller.view = view + self.switch_to_view(view) + + def lookup_positions_async(self): + self.view.flash_text.set_text('Looking up positions...') + Thread(target=self.lookup_positions_sync).start() + + def lookup_positions_sync(self): + resp = http_get('/api/positions') + try: + positions = tui_utils.handle_sync_response(resp, self) + except Controller.RequestFailed: + return + for pos, username in positions.items(): + self.model.positions[pos] = username + + def target(): + self.view.flash_text.set_text('') + self.view.update_fields() + + self.app.run_in_main_loop(target) diff --git a/ceo/tui/controllers/SyncRequestController.py b/ceo/tui/controllers/SyncRequestController.py new file mode 100644 index 0000000..d3a1de6 --- /dev/null +++ b/ceo/tui/controllers/SyncRequestController.py @@ -0,0 +1,39 @@ +from threading import Thread + +from .Controller import Controller +import ceo.tui.utils as tui_utils +from ceo.tui.views import SyncResponseView + + +class SyncRequestController(Controller): + def __init__(self, model, app): + super().__init__(model, app) + self.request_in_progress = False + + def get_resp(self): + # To be implemented by child classes + raise NotImplementedError() + + def get_response_view(self): + return SyncResponseView(self.model, self, self.app) + + def on_confirmation_button_pressed(self, button): + if self.request_in_progress: + return + self.request_in_progress = True + self.view.flash_text.set_text('Sending request...') + + def main_loop_target(): + self.view.flash_text.set_text('') + view = self.get_response_view() + self.switch_to_view(view) + + def thread_target(): + resp = self.get_resp() + try: + self.model.resp_json = tui_utils.handle_sync_response(resp, self) + except Controller.RequestFailed: + return + self.app.run_in_main_loop(main_loop_target) + + Thread(target=thread_target).start() diff --git a/ceo/tui/controllers/TransactionController.py b/ceo/tui/controllers/TransactionController.py new file mode 100644 index 0000000..62e799d --- /dev/null +++ b/ceo/tui/controllers/TransactionController.py @@ -0,0 +1,110 @@ +from threading import Thread +from typing import Dict, List + +from ...StreamResponseHandler import StreamResponseHandler +from ...utils import http_request, generic_handle_stream_response +from .Controller import Controller + + +class TransactionController(Controller, StreamResponseHandler): + def __init__(self, model, app): + super().__init__(model, app) + self.op_idx = 0 + self.error_messages = [] + + def start(self): + Thread(target=self._start_txn).start() + + def _start_txn(self): + resp = http_request( + self.model.http_verb, + self.model.req_path, + **self.model.req_kwargs + ) + data = generic_handle_stream_response(resp, self.model.operations, self) + self.write_extra_txn_info(data) + + # to be overridden in child classes if desired + def write_extra_txn_info(self, data: List[Dict]): + pass + + def _show_lines(self, lines): + num_lines = len(lines) + # Since the message_text is at the bottom of the window, + # we want to add sufficient padding to the bottom of the text + lines += [''] * max(4 - num_lines, 0) + for i, line in enumerate(lines): + if type(line) is str: + lines[i] = line + '\n' + else: # tuple (attr, text) + lines[i] = (line[0], line[1] + '\n') + self.view.message_text.set_text(lines) + + def _abort(self): + for elem in self.view.right_col_elems[self.op_idx:]: + elem.set_text(('red', 'ABORTED')) + self.view.enable_next_button() + + def begin(self): + pass + + def handle_non_200(self, resp): + def target(): + self._abort() + lines = ['An error occurred:'] + if resp.headers.get('content-type') == 'application/json': + err_msg = resp.json()['error'] + else: + err_msg = resp.text + lines.extend(err_msg.split('\n')) + self._show_lines(lines) + self.app.run_in_main_loop(target) + + def handle_aborted(self, err_msg): + def target(): + self._abort() + lines = [ + 'The transaction was rolled back.', + 'The error was:', + '', + *err_msg.split('\n'), + ] + self._show_lines(lines) + self.app.run_in_main_loop(target) + + def handle_completed(self): + def target(): + lines = ['Transaction successfully completed.'] + if len(self.error_messages) > 0: + lines.append('There were some errors:') + for msg in self.error_messages: + lines.extend(msg.split('\n')) + self._show_lines(lines) + self.view.enable_next_button() + self.app.run_in_main_loop(target) + + def handle_successful_operation(self): + def target(): + self.view.right_col_elems[self.op_idx].set_text(('green', 'Done')) + self.op_idx += 1 + self.app.run_in_main_loop(target) + + def handle_failed_operation(self, err_msg): + def target(): + self.view.right_col_elems[self.op_idx].set_text(('red', 'Failed')) + self.op_idx += 1 + if err_msg is not None: + self.error_messages.append(err_msg) + self.app.run_in_main_loop(target) + + def handle_skipped_operation(self): + def target(): + self.view.right_col_elems[self.op_idx].set_text('Skipped') + self.op_idx += 1 + self.app.run_in_main_loop(target) + + def handle_unrecognized_operation(self, operation): + def target(): + self.error_messages.append('Unrecognized operation: ' + operation) + self.op_idx += 1 + self.app.run_in_main_loop(target) diff --git a/ceo/tui/controllers/WelcomeController.py b/ceo/tui/controllers/WelcomeController.py new file mode 100644 index 0000000..fcfd6a4 --- /dev/null +++ b/ceo/tui/controllers/WelcomeController.py @@ -0,0 +1,6 @@ +from .Controller import Controller + + +class WelcomeController(Controller): + def __init__(self, model, app): + super().__init__(model, app) diff --git a/ceo/tui/controllers/__init__.py b/ceo/tui/controllers/__init__.py new file mode 100644 index 0000000..7b28a64 --- /dev/null +++ b/ceo/tui/controllers/__init__.py @@ -0,0 +1,18 @@ +from .Controller import Controller +from .WelcomeController import WelcomeController +from .AddUserController import AddUserController +from .AddUserTransactionController import AddUserTransactionController +from .RenewUserController import RenewUserController +from .GetUserController import GetUserController +from .ResetPasswordController import ResetPasswordController +from .ChangeLoginShellController import ChangeLoginShellController +from .AddGroupController import AddGroupController +from .GetGroupController import GetGroupController +from .AddMemberToGroupController import AddMemberToGroupController +from .RemoveMemberFromGroupController import RemoveMemberFromGroupController +from .CreateDatabaseController import CreateDatabaseController +from .ResetDatabasePasswordController import ResetDatabasePasswordController +from .GetPositionsController import GetPositionsController +from .SetPositionsController import SetPositionsController +from .TransactionController import TransactionController +from .SyncRequestController import SyncRequestController diff --git a/ceo/tui/databases/CreateDatabaseResultView.py b/ceo/tui/databases/CreateDatabaseResultView.py deleted file mode 100644 index 1b8ffda..0000000 --- a/ceo/tui/databases/CreateDatabaseResultView.py +++ /dev/null @@ -1,34 +0,0 @@ -import os - -import requests -from zope import component - -from ...utils import write_db_creds -from ..ResultView import ResultView -from ceo_common.interfaces import IConfig - - -class CreateDatabaseResultView(ResultView): - def show_result(self, resp: requests.Response): - password = resp.json()['password'] - db_type = self._model.db_type - db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' - db_host = component.getUtility(IConfig).get(f'{db_type}_host') - user_dict = self._model.user_dict - username = user_dict['uid'] - filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") - wrote_to_file = write_db_creds( - filename, user_dict, password, db_type, db_host) - self._add_text(f'{db_type_name} database created.', center=True) - self._add_text() - self._add_text((f'''Connection Information: - -Database: {username} -Username: {username} -Password: {password} -Host: {db_host}''')) - self._add_text() - if wrote_to_file: - self._add_text(f"These settings have been written to {filename}.") - else: - self._add_text(f"We were unable to write these settings to {filename}.") diff --git a/ceo/tui/databases/CreateDatabaseView.py b/ceo/tui/databases/CreateDatabaseView.py deleted file mode 100644 index f2e0ac0..0000000 --- a/ceo/tui/databases/CreateDatabaseView.py +++ /dev/null @@ -1,47 +0,0 @@ -from asciimatics.widgets import Layout, Text - -from ...utils import http_post, http_get, defer -from ..CeoFrame import CeoFrame - - -class CreateDatabaseView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'CreateDatabase', - ) - layout = Layout([100], fill_frame=True) - self.add_layout(layout) - self._username = Text("Username:", "uid") - layout.add_widget(self._username) - self.add_buttons( - back_btn=True, next_scene='Confirm', - on_next=self._next) - self.fix() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - - def _target(self): - username = self._username.value - db_type = self._model.db_type - resp = http_get(f'/api/members/{username}') - if not resp.ok: - return resp - user_dict = resp.json() - self._model.user_dict = user_dict - return http_post(f'/api/db/{db_type}/{username}') - - def _next(self): - username = self._username.value - if not username: - return - if self._model.db_type == 'mysql': - db_type_name = 'MySQL' - else: - db_type_name = 'PostgreSQL' - self._model.confirm_lines = [ - f'Are you sure you want to create a {db_type_name} database for {username}?', - ] - self._model.deferred_req = defer(self._target) - self._model.result_view_name = 'CreateDatabaseResult' diff --git a/ceo/tui/databases/ResetDatabasePasswordResultView.py b/ceo/tui/databases/ResetDatabasePasswordResultView.py deleted file mode 100644 index 12b45fb..0000000 --- a/ceo/tui/databases/ResetDatabasePasswordResultView.py +++ /dev/null @@ -1,29 +0,0 @@ -import os - -import requests -from zope import component - -from ...utils import write_db_creds -from ..ResultView import ResultView -from ceo_common.interfaces import IConfig - - -class ResetDatabasePasswordResultView(ResultView): - def show_result(self, resp: requests.Response): - password = resp.json()['password'] - db_type = self._model.db_type - db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' - db_host = component.getUtility(IConfig).get(f'{db_type}_host') - user_dict = self._model.user_dict - username = user_dict['uid'] - filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") - wrote_to_file = write_db_creds( - filename, user_dict, password, db_type, db_host) - self._add_text(f'The new {db_type_name} password for {username} is:') - self._add_text() - self._add_text(password) - self._add_text() - if wrote_to_file: - self._add_text(f"The settings in {filename} have been updated.") - else: - self._add_text(f"We were unable to update the settings in {filename}.") diff --git a/ceo/tui/databases/ResetDatabasePasswordView.py b/ceo/tui/databases/ResetDatabasePasswordView.py deleted file mode 100644 index 6e31db0..0000000 --- a/ceo/tui/databases/ResetDatabasePasswordView.py +++ /dev/null @@ -1,47 +0,0 @@ -from asciimatics.widgets import Layout, Text - -from ...utils import http_post, http_get, defer -from ..CeoFrame import CeoFrame - - -class ResetDatabasePasswordView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'ResetDatabasePassword', - ) - layout = Layout([100], fill_frame=True) - self.add_layout(layout) - self._username = Text("Username:", "uid") - layout.add_widget(self._username) - self.add_buttons( - back_btn=True, next_scene='Confirm', - on_next=self._next) - self.fix() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - - def _target(self): - username = self._username.value - db_type = self._model.db_type - resp = http_get(f'/api/members/{username}') - if not resp.ok: - return resp - user_dict = resp.json() - self._model.user_dict = user_dict - return http_post(f'/api/db/{db_type}/{username}/pwreset') - - def _next(self): - username = self._username.value - if not username: - return - if self._model.db_type == 'mysql': - db_type_name = 'MySQL' - else: - db_type_name = 'PostgreSQL' - self._model.confirm_lines = [ - f'Are you sure you want to reset the {db_type_name} password for {username}?', - ] - self._model.deferred_req = defer(self._target) - self._model.result_view_name = 'ResetDatabasePasswordResult' diff --git a/ceo/tui/databases/__init__.py b/ceo/tui/databases/__init__.py deleted file mode 100644 index 8d1c8b6..0000000 --- a/ceo/tui/databases/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ceo/tui/groups/AddGroupView.py b/ceo/tui/groups/AddGroupView.py deleted file mode 100644 index de10ace..0000000 --- a/ceo/tui/groups/AddGroupView.py +++ /dev/null @@ -1,46 +0,0 @@ -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', - ) - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._cn.value = None - self._description.value = None - - 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/AddMemberToGroupView.py b/ceo/tui/groups/AddMemberToGroupView.py deleted file mode 100644 index 1e38197..0000000 --- a/ceo/tui/groups/AddMemberToGroupView.py +++ /dev/null @@ -1,49 +0,0 @@ -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', - ) - 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', name='subscribe') - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._cn.value = None - self._username.value = None - self._checkbox.value = True - - 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/GetGroupResultView.py b/ceo/tui/groups/GetGroupResultView.py deleted file mode 100644 index b35333f..0000000 --- a/ceo/tui/groups/GetGroupResultView.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index f7501af..0000000 --- a/ceo/tui/groups/GetGroupView.py +++ /dev/null @@ -1,33 +0,0 @@ -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', - ) - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._cn.value = None - - def _next(self): - cn = self._cn.value - 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/groups/RemoveMemberFromGroupView.py b/ceo/tui/groups/RemoveMemberFromGroupView.py deleted file mode 100644 index 1c7c2f7..0000000 --- a/ceo/tui/groups/RemoveMemberFromGroupView.py +++ /dev/null @@ -1,48 +0,0 @@ -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', - ) - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._cn.value = None - self._username.value = None - - 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/groups/__init__.py b/ceo/tui/groups/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ceo/tui/members/AddUserTransactionView.py b/ceo/tui/members/AddUserTransactionView.py deleted file mode 100644 index f3b9cda..0000000 --- a/ceo/tui/members/AddUserTransactionView.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List, Dict - -from asciimatics.widgets import Label - -from ...utils import get_failed_operations -from ..TransactionView import TransactionView - - -class AddUserTransactionView(TransactionView): - def _show_msg(self, msg: str = '\n'): - for line in msg.splitlines(): - self._msg_layout.add_widget(Label(line, align='^')) - - def write_extra_txn_info(self, data: List[Dict]): - if data[-1]['status'] != 'completed': - return - result = data[-1]['result'] - failed_operations = get_failed_operations(data) - self._show_msg() - self._show_msg('User password is: ' + result['password']) - if 'send_welcome_message' in failed_operations: - self._show_msg() - self._show_msg('Since the welcome message was not sent,') - self._show_msg('you need to email this password to the user.') - self.force_update() diff --git a/ceo/tui/members/AddUserView.py b/ceo/tui/members/AddUserView.py deleted file mode 100644 index 0ba7090..0000000 --- a/ceo/tui/members/AddUserView.py +++ /dev/null @@ -1,116 +0,0 @@ -from threading import Thread - -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, \ - get_adduser_operations -from ..CeoFrame import CeoFrame - - -class AddUserView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'AddUser', - ) - self._username_changed = False - - layout = Layout([100], fill_frame=True) - self.add_layout(layout) - self._username = Text( - "Username:", "uid", - on_change=self._on_username_change, - on_blur=self._on_username_blur, - ) - layout.add_widget(self._username) - self._full_name = Text("Full name:", "cn") - layout.add_widget(self._full_name) - self._first_name = Text("First name:", "given_name") - layout.add_widget(self._first_name) - self._last_name = Text("Last name:", "sn") - layout.add_widget(self._last_name) - self._program = Text("Program:", "program") - layout.add_widget(self._program) - self._forwarding_address = Text("Forwarding address:", "forwarding_address") - layout.add_widget(self._forwarding_address) - self._num_terms = Text( - "Number of terms:", "num_terms", - validator=lambda s: s.isdigit() and s[0] != '0') - self._num_terms.value = '1' - layout.add_widget(self._num_terms) - - self.add_flash_message_layout() - self.add_buttons( - back_btn=True, - next_scene='Confirm', on_next=self._next) - self.fix() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - self._full_name.value = None - self._program.value = None - self._forwarding_address.value = None - self._num_terms.value = '1' - - 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_uwldap_info, args=[username]).start() - - def _get_uwldap_info(self, username): - self.flash_message('Looking up user...') - try: - resp = http_get('/api/uwldap/' + username) - if resp.status_code != 200: - return - data = resp.json() - self._status_label.text = '' - if data.get('cn'): - self._full_name.value = data['cn'] - if data.get('given_name'): - self._first_name.value = data['given_name'] - if data.get('sn'): - self._last_name.value = data['sn'] - if data.get('program'): - self._program.value = data.get('program', '') - if data.get('mail_local_addresses'): - self._forwarding_address.value = data['mail_local_addresses'][0] - finally: - self.clear_flash_message() - - def _next(self): - body = { - 'uid': self._username.value, - 'cn': self._full_name.value, - 'given_name': self._first_name.value, - 'sn': self._last_name.value, - } - if self._program.value: - body['program'] = self._program.value - if self._forwarding_address.value: - body['forwarding_addresses'] = [self._forwarding_address.value] - new_terms = get_terms_for_new_user(int(self._num_terms.value)) - if self._model.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:', - '', - ] + pairs + [ - '', - 'Are you sure you want to continue?', - ] - - self._model.deferred_req = defer(http_post, '/api/members', json=body) - self._model.operations = get_adduser_operations(body) - self._model.txn_view_name = 'AddUserTransaction' diff --git a/ceo/tui/members/ChangeLoginShellView.py b/ceo/tui/members/ChangeLoginShellView.py deleted file mode 100644 index 44e242a..0000000 --- a/ceo/tui/members/ChangeLoginShellView.py +++ /dev/null @@ -1,75 +0,0 @@ -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', - ) - 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() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - self._login_shell.value = None - - # 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/GetUserResultView.py b/ceo/tui/members/GetUserResultView.py deleted file mode 100644 index 755e9fb..0000000 --- a/ceo/tui/members/GetUserResultView.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 3ae8bd1..0000000 --- a/ceo/tui/members/GetUserView.py +++ /dev/null @@ -1,33 +0,0 @@ -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', - ) - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - - 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 deleted file mode 100644 index fdd8d8c..0000000 --- a/ceo/tui/members/RenewUserView.py +++ /dev/null @@ -1,65 +0,0 @@ -from asciimatics.widgets import Layout, Text - -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', - ) - 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') - self._num_terms.value = '1' - layout.add_widget(self._num_terms) - - self.add_flash_message_layout() - self.add_buttons( - back_btn=True, - next_scene='Confirm', on_next=self._next) - self.fix() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - self._num_terms.value = '1' - - def _next(self): - uid = self._username.value - 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: - 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/members/ResetPasswordResultView.py b/ceo/tui/members/ResetPasswordResultView.py deleted file mode 100644 index 8cb1cbb..0000000 --- a/ceo/tui/members/ResetPasswordResultView.py +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 19e060b..0000000 --- a/ceo/tui/members/ResetPasswordView.py +++ /dev/null @@ -1,34 +0,0 @@ -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', - ) - 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 _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - - 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/members/SetForwardingAddressesView.py b/ceo/tui/members/SetForwardingAddressesView.py deleted file mode 100644 index ae92b14..0000000 --- a/ceo/tui/members/SetForwardingAddressesView.py +++ /dev/null @@ -1,80 +0,0 @@ -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', - ) - 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() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self._username.value = None - self._forwarding_addresses.value = None - - # 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/members/__init__.py b/ceo/tui/members/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ceo/tui/models/AddGroupModel.py b/ceo/tui/models/AddGroupModel.py new file mode 100644 index 0000000..42d7287 --- /dev/null +++ b/ceo/tui/models/AddGroupModel.py @@ -0,0 +1,7 @@ +class AddGroupModel: + name = 'AddGroup' + title = 'Add group' + + def __init__(self): + self.name = '' + self.description = '' diff --git a/ceo/tui/models/AddMemberToGroupModel.py b/ceo/tui/models/AddMemberToGroupModel.py new file mode 100644 index 0000000..64ef343 --- /dev/null +++ b/ceo/tui/models/AddMemberToGroupModel.py @@ -0,0 +1,8 @@ +class AddMemberToGroupModel: + name = 'AddMemberToGroup' + title = 'Add member to group' + + def __init__(self): + self.name = '' + self.username = '' + self.subscribe_to_lists = True diff --git a/ceo/tui/models/AddUserModel.py b/ceo/tui/models/AddUserModel.py new file mode 100644 index 0000000..ee373f2 --- /dev/null +++ b/ceo/tui/models/AddUserModel.py @@ -0,0 +1,13 @@ +class AddUserModel: + name = 'AddUser' + title = 'Add user' + + def __init__(self): + self.membership_type = 'general_member' + self.username = '' + self.full_name = '' + self.first_name = '' + self.last_name = '' + self.program = '' + self.forwarding_address = '' + self.num_terms = 1 diff --git a/ceo/tui/models/ChangeLoginShellModel.py b/ceo/tui/models/ChangeLoginShellModel.py new file mode 100644 index 0000000..c842115 --- /dev/null +++ b/ceo/tui/models/ChangeLoginShellModel.py @@ -0,0 +1,8 @@ +class ChangeLoginShellModel: + name = 'ChangeLoginShell' + title = 'Change login shell' + + def __init__(self): + self.username = '' + self.login_shell = '' + self.resp_json = None diff --git a/ceo/tui/models/CreateDatabaseModel.py b/ceo/tui/models/CreateDatabaseModel.py new file mode 100644 index 0000000..25aa85b --- /dev/null +++ b/ceo/tui/models/CreateDatabaseModel.py @@ -0,0 +1,12 @@ +class CreateDatabaseModel: + name = 'CreateDatabase' + title = 'Create database' + + def __init__(self): + self.db_type = 'mysql' + self.user_dict = None + self.resp_json = None + self.password = None + self.db_host = None + self.filename = None + self.wrote_to_file = False diff --git a/ceo/tui/models/GetGroupModel.py b/ceo/tui/models/GetGroupModel.py new file mode 100644 index 0000000..d982eff --- /dev/null +++ b/ceo/tui/models/GetGroupModel.py @@ -0,0 +1,7 @@ +class GetGroupModel: + name = 'GetGroup' + title = 'Get group members' + + def __init__(self): + self.name = '' + self.resp_json = None diff --git a/ceo/tui/models/GetPositionsModel.py b/ceo/tui/models/GetPositionsModel.py new file mode 100644 index 0000000..a353e5d --- /dev/null +++ b/ceo/tui/models/GetPositionsModel.py @@ -0,0 +1,4 @@ +class GetPositionsModel: + name = 'GetPositions' + title = 'Get positions' + positions = {} diff --git a/ceo/tui/models/GetUserModel.py b/ceo/tui/models/GetUserModel.py new file mode 100644 index 0000000..b4344ae --- /dev/null +++ b/ceo/tui/models/GetUserModel.py @@ -0,0 +1,7 @@ +class GetUserModel: + name = 'GetUser' + title = 'Get user info' + + def __init__(self): + self.username = '' + self.resp_json = None diff --git a/ceo/tui/models/RemoveMemberFromGroupModel.py b/ceo/tui/models/RemoveMemberFromGroupModel.py new file mode 100644 index 0000000..f197e3b --- /dev/null +++ b/ceo/tui/models/RemoveMemberFromGroupModel.py @@ -0,0 +1,8 @@ +class RemoveMemberFromGroupModel: + name = 'RemoveMemberFromGroup' + title = 'Remove member from group' + + def __init__(self): + self.name = '' + self.username = '' + self.unsubscribe_from_lists = True diff --git a/ceo/tui/models/RenewUserModel.py b/ceo/tui/models/RenewUserModel.py new file mode 100644 index 0000000..1aabe64 --- /dev/null +++ b/ceo/tui/models/RenewUserModel.py @@ -0,0 +1,10 @@ +class RenewUserModel: + name = 'RenewUser' + title = 'Renew user' + + def __init__(self): + self.membership_type = 'general_member' + self.username = '' + self.num_terms = 1 + self.new_terms = None + self.resp_json = None diff --git a/ceo/tui/models/ResetDatabasePasswordModel.py b/ceo/tui/models/ResetDatabasePasswordModel.py new file mode 100644 index 0000000..39d30af --- /dev/null +++ b/ceo/tui/models/ResetDatabasePasswordModel.py @@ -0,0 +1,11 @@ +class ResetDatabasePasswordModel: + name = 'ResetDatabasePassword' + title = 'Reset database password' + + def __init__(self): + self.db_type = 'mysql' + self.user_dict = None + self.resp_json = None + self.password = None + self.filename = None + self.wrote_to_file = False diff --git a/ceo/tui/models/ResetPasswordModel.py b/ceo/tui/models/ResetPasswordModel.py new file mode 100644 index 0000000..64d4132 --- /dev/null +++ b/ceo/tui/models/ResetPasswordModel.py @@ -0,0 +1,7 @@ +class ResetPasswordModel: + name = 'ResetPassword' + title = 'Reset password' + + def __init__(self): + self.username = '' + self.resp_json = None diff --git a/ceo/tui/models/SetPositionsModel.py b/ceo/tui/models/SetPositionsModel.py new file mode 100644 index 0000000..51f1209 --- /dev/null +++ b/ceo/tui/models/SetPositionsModel.py @@ -0,0 +1,4 @@ +class SetPositionsModel: + name = 'SetPositions' + title = 'Set positions' + positions = {} diff --git a/ceo/tui/models/TransactionModel.py b/ceo/tui/models/TransactionModel.py new file mode 100644 index 0000000..f5f7796 --- /dev/null +++ b/ceo/tui/models/TransactionModel.py @@ -0,0 +1,9 @@ +class TransactionModel: + name = 'Transaction' + title = 'Running transaction' + + def __init__(self, operations, http_verb, req_path, **req_kwargs): + self.operations = operations + self.http_verb = http_verb + self.req_path = req_path + self.req_kwargs = req_kwargs diff --git a/ceo/tui/models/WelcomeModel.py b/ceo/tui/models/WelcomeModel.py new file mode 100644 index 0000000..ac76086 --- /dev/null +++ b/ceo/tui/models/WelcomeModel.py @@ -0,0 +1,43 @@ +from .AddUserModel import AddUserModel +from .RenewUserModel import RenewUserModel +from .GetUserModel import GetUserModel +from .ResetPasswordModel import ResetPasswordModel +from .ChangeLoginShellModel import ChangeLoginShellModel +from .AddGroupModel import AddGroupModel +from .GetGroupModel import GetGroupModel +from .AddMemberToGroupModel import AddMemberToGroupModel +from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel +from .CreateDatabaseModel import CreateDatabaseModel +from .ResetDatabasePasswordModel import ResetDatabasePasswordModel +from .GetPositionsModel import GetPositionsModel +from .SetPositionsModel import SetPositionsModel + + +class WelcomeModel: + name = 'Welcome' + title = 'CSC Electronic Office' + + def __init__(self): + self.categories = { + 'Members': [ + AddUserModel, + RenewUserModel, + GetUserModel, + ResetPasswordModel, + ChangeLoginShellModel, + ], + 'Groups': [ + AddGroupModel, + GetGroupModel, + AddMemberToGroupModel, + RemoveMemberFromGroupModel, + ], + 'Databases': [ + CreateDatabaseModel, + ResetDatabasePasswordModel, + ], + 'Positions': [ + GetPositionsModel, + SetPositionsModel, + ], + } diff --git a/ceo/tui/models/__init__.py b/ceo/tui/models/__init__.py new file mode 100644 index 0000000..64013d0 --- /dev/null +++ b/ceo/tui/models/__init__.py @@ -0,0 +1,15 @@ +from .WelcomeModel import WelcomeModel +from .AddUserModel import AddUserModel +from .RenewUserModel import RenewUserModel +from .GetUserModel import GetUserModel +from .ResetPasswordModel import ResetPasswordModel +from .ChangeLoginShellModel import ChangeLoginShellModel +from .AddGroupModel import AddGroupModel +from .GetGroupModel import GetGroupModel +from .AddMemberToGroupModel import AddMemberToGroupModel +from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel +from .CreateDatabaseModel import CreateDatabaseModel +from .ResetDatabasePasswordModel import ResetDatabasePasswordModel +from .GetPositionsModel import GetPositionsModel +from .SetPositionsModel import SetPositionsModel +from .TransactionModel import TransactionModel diff --git a/ceo/tui/positions/GetPositionsView.py b/ceo/tui/positions/GetPositionsView.py deleted file mode 100644 index 5d5383d..0000000 --- a/ceo/tui/positions/GetPositionsView.py +++ /dev/null @@ -1,80 +0,0 @@ -from threading import Thread - -from asciimatics.widgets import Layout, Label -from zope import component - -from ...utils import http_get -from ..CeoFrame import CeoFrame -from ceo_common.interfaces import IConfig - - -position_names = { - 'president': "President", - 'vice-president': "Vice President", - 'treasurer': "Treasurer", - 'secretary': "Secretary", - 'sysadmin': "Sysadmin", - 'cro': "Chief Returning Officer", - 'librarian': "Librarian", - 'imapd': "IMAPD", - 'webmaster': "Web Master", - 'offsck': "Office Manager", -} - - -class GetPositionsView(CeoFrame): - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'GetPositions', - escape_on_q=True, - on_load=self._on_load - ) - self._position_widgets = {} - - def _on_load(self): - cfg = component.getUtility(IConfig) - avail = cfg.get('positions_available') - - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Label('')) - - self._main_layout = Layout([10, 1, 10], fill_frame=True) - self.add_layout(self._main_layout) - - for pos in avail: - self._position_widgets[pos] = self._add_pair(position_names[pos], '') - - self.add_flash_message_layout() - self.add_buttons(back_btn=True) - self.fix() - - def target(): - self.flash_message('Looking up positions...') - try: - resp = http_get('/api/positions') - if not resp.ok: - return - positions = resp.json() - for pos, username in positions.items(): - self._position_widgets[pos].text = username - finally: - self.clear_flash_message(force_update=True) - Thread(target=target).start() - - def _add_blank_line(self): - self._main_layout.add_widget(Label(' ', 0)) - self._main_layout.add_widget(Label(' ', 2)) - - def _add_pair(self, key: str, val: str): - key_widget = Label(key + ':', align='>') - value_widget = Label(val, align='<') - self._main_layout.add_widget(key_widget, 0) - self._main_layout.add_widget(value_widget, 2) - return value_widget - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - # clear the labels - for widget in self._position_widgets.values(): - widget.text = '' diff --git a/ceo/tui/positions/SetPositionsView.py b/ceo/tui/positions/SetPositionsView.py deleted file mode 100644 index b7481de..0000000 --- a/ceo/tui/positions/SetPositionsView.py +++ /dev/null @@ -1,76 +0,0 @@ -from threading import Thread - -from asciimatics.widgets import Layout, Label, Text -from zope import component - -from ...utils import defer, http_post, http_get -from ..CeoFrame import CeoFrame -from .GetPositionsView import position_names -from ceo_common.interfaces import IConfig -from ceod.transactions.members.UpdateMemberPositionsTransaction import UpdateMemberPositionsTransaction - - -class SetPositionsView(CeoFrame): - - """ - Reset the positions to the currently set positions - """ - def reset_positions(self): - def target(): - self.flash_message('Looking up positions...') - try: - resp = http_get('/api/positions') - if not resp.ok: - return - positions = resp.json() - for pos, username in positions.items(): - self._widgets[pos].value = username - finally: - self.clear_flash_message(force_update=True) - Thread(target=target).start() - - def __init__(self, screen, width, height, model): - super().__init__( - screen, height, width, model, 'SetPositions', - ) - cfg = component.getUtility(IConfig) - avail = cfg.get('positions_available') - required = cfg.get('positions_required') - - layout = Layout([100], fill_frame=True) - self.add_layout(layout) - self._widgets = {} - for pos in avail: - suffix = ' (*)' if pos in required else '' - widget = Text(position_names[pos] + suffix, pos) - self._widgets[pos] = widget - layout.add_widget(widget) - - layout = Layout([100]) - self.add_layout(layout) - layout.add_widget(Label('(*) Required')) - - self.add_flash_message_layout() - self.add_buttons( - back_btn=True, - next_scene='Confirm', on_next=self._next) - self.reset_positions() - self.fix() - - def _ceoframe_on_reset(self): - super()._ceoframe_on_reset() - self.reset_positions() - - def _next(self): - self.save() - body = {pos: username for pos, username in self.data.items() if username} - - self._model.deferred_req = defer(http_post, '/api/positions', json=body) - self._model.operations = UpdateMemberPositionsTransaction.operations - self._model.confirm_lines = [ - "The positions will be updated as follows:", - '', - *self.data.items(), - '', - 'Are you sure you want to continue?', - ] diff --git a/ceo/tui/positions/__init__.py b/ceo/tui/positions/__init__.py deleted file mode 100644 index 55fa7d1..0000000 --- a/ceo/tui/positions/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .GetPositionsView import GetPositionsView -from .SetPositionsView import SetPositionsView diff --git a/ceo/tui/start.py b/ceo/tui/start.py index a5aaea5..d8c167e 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -1,89 +1,42 @@ -import os -import sys +import urwid -from asciimatics.exceptions import ResizeScreenError -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 .databases.CreateDatabaseView import CreateDatabaseView -from .databases.CreateDatabaseResultView import CreateDatabaseResultView -from .databases.ResetDatabasePasswordView import ResetDatabasePasswordView -from .databases.ResetDatabasePasswordResultView import ResetDatabasePasswordResultView -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.AddUserTransactionView import AddUserTransactionView -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 -from .positions import GetPositionsView, SetPositionsView +from .app import App +from .utils import get_mvc -# tuples of (name, view) -views = [] - - -def screen_wrapper(screen, last_scene, model): - global views - width = min(screen.width, 90) - height = min(screen.height, 24) - views = [ - ('Welcome', WelcomeView(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)), - ('AddUserTransaction', AddUserTransactionView(screen, width, height, 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)), - ('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)), - ('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)), - ('RemoveMemberFromGroup', RemoveMemberFromGroupView(screen, width, height, model)), - ('CreateDatabase', CreateDatabaseView(screen, width, height, model)), - ('CreateDatabaseResult', CreateDatabaseResultView(screen, width, height, model)), - ('ResetDatabasePassword', ResetDatabasePasswordView(screen, width, height, model)), - ('ResetDatabasePasswordResult', ResetDatabasePasswordResultView(screen, width, height, model)), - ('GetPositions', GetPositionsView(screen, width, height, model)), - ('SetPositions', SetPositionsView(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, - ) +def exit_on_special_chars(key): + if key in ('q', 'Q', 'esc'): + raise urwid.ExitMainLoop() def main(): - last_scene = None - model = Model() - try: - Screen.wrapper(screen_wrapper, arguments=[last_scene, model]) - sys.exit(0) - except ResizeScreenError: - os.system('reset') - print('Unfortunately, ceo does not currently support dynamic resizing.') - sys.exit(1) + # Just put some empty placeholder in the main widget for now + # (will be replaced by the WelcomeView) + main_widget = urwid.Padding(urwid.Text(''), left=2, right=2) + top = urwid.Overlay( + main_widget, + urwid.AttrMap(urwid.SolidFill(' '), 'background'), + align='center', + width=('relative', App.REL_WIDTH_PCT), + valign='middle', + height=('relative', App.REL_HEIGHT_PCT), + min_width=App.WIDTH, + min_height=App.HEIGHT, + ) + loop = urwid.MainLoop( + top, + palette=[ + ('reversed', 'standout', ''), + ('bold', 'bold', ''), + ('green', 'light green', ''), + ('red', 'light red', ''), + ('background', 'standout,light cyan', ''), + ], + # Disable the mouse (makes it hard to copy text from the screen) + handle_mouse=False, + unhandled_input=exit_on_special_chars + ) + app = App(loop, main_widget) + _, view, _ = get_mvc(app, 'Welcome') + view.activate() + loop.run() diff --git a/ceo/tui/utils.py b/ceo/tui/utils.py index caa1ab0..fa225d8 100644 --- a/ceo/tui/utils.py +++ b/ceo/tui/utils.py @@ -1,10 +1,88 @@ -from asciimatics.exceptions import NextScene +import json -import requests +from ceo.tui.controllers import * +from ceo.tui.models import * +from ceo.tui.views import * -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() +def handle_sync_response(resp, controller): + if resp.ok: + if resp.headers.get('content-type') == 'application/json': + return resp.json() + # streaming response + return [json.loads(line) for line in resp.text.splitlines()] + + def target(): + view = ErrorView(controller.model, controller, controller.app) + controller.switch_to_view(view) + + if resp.headers.get('content-type') == 'application/json': + err_msg = resp.json()['error'] + else: + err_msg = resp.text.rstrip() + controller.model.error_message = err_msg + controller.app.run_in_main_loop(target) + raise Controller.RequestFailed() + + +def get_mvc(app, name): + if name == WelcomeModel.name: + model = WelcomeModel() + controller = WelcomeController(model, app) + view = WelcomeView(model, controller, app) + elif name == AddUserModel.name: + model = AddUserModel() + controller = AddUserController(model, app) + view = AddUserView(model, controller, app) + elif name == RenewUserModel.name: + model = RenewUserModel() + controller = RenewUserController(model, app) + view = RenewUserView(model, controller, app) + elif name == GetUserModel.name: + model = GetUserModel() + controller = GetUserController(model, app) + view = GetUserView(model, controller, app) + elif name == ResetPasswordModel.name: + model = ResetPasswordModel() + controller = ResetPasswordController(model, app) + view = ResetPasswordView(model, controller, app) + elif name == ChangeLoginShellModel.name: + model = ChangeLoginShellModel() + controller = ChangeLoginShellController(model, app) + view = ChangeLoginShellView(model, controller, app) + elif name == AddGroupModel.name: + model = AddGroupModel() + controller = AddGroupController(model, app) + view = AddGroupView(model, controller, app) + elif name == GetGroupModel.name: + model = GetGroupModel() + controller = GetGroupController(model, app) + view = GetGroupView(model, controller, app) + elif name == AddMemberToGroupModel.name: + model = AddMemberToGroupModel() + controller = AddMemberToGroupController(model, app) + view = AddMemberToGroupView(model, controller, app) + elif name == RemoveMemberFromGroupModel.name: + model = RemoveMemberFromGroupModel() + controller = RemoveMemberFromGroupController(model, app) + view = RemoveMemberFromGroupView(model, controller, app) + elif name == CreateDatabaseModel.name: + model = CreateDatabaseModel() + controller = CreateDatabaseController(model, app) + view = CreateDatabaseView(model, controller, app) + elif name == ResetDatabasePasswordModel.name: + model = ResetDatabasePasswordModel() + controller = ResetDatabasePasswordController(model, app) + view = ResetDatabasePasswordView(model, controller, app) + elif name == GetPositionsModel.name: + model = GetPositionsModel() + controller = GetPositionsController(model, app) + view = GetPositionsView(model, controller, app) + elif name == SetPositionsModel.name: + model = SetPositionsModel() + controller = SetPositionsController(model, app) + view = SetPositionsView(model, controller, app) + else: + raise NotImplementedError() + controller.view = view + return model, view, controller diff --git a/ceo/tui/views/AddGroupConfirmationView.py b/ceo/tui/views/AddGroupConfirmationView.py new file mode 100644 index 0000000..528cecd --- /dev/null +++ b/ceo/tui/views/AddGroupConfirmationView.py @@ -0,0 +1,10 @@ +from .ConfirmationView import ConfirmationView + + +class AddGroupConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"A new group '{self.model.name}' will be created." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/AddGroupView.py b/ceo/tui/views/AddGroupView.py new file mode 100644 index 0000000..f58fe6e --- /dev/null +++ b/ceo/tui/views/AddGroupView.py @@ -0,0 +1,21 @@ +import urwid + +from .ColumnView import ColumnView + + +class AddGroupView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.name_edit = urwid.Edit() + self.description_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Name:', align='right'), + self.name_edit + ), + ( + urwid.Text('Description:', align='right'), + self.description_edit + ) + ] + self.set_rows(rows) diff --git a/ceo/tui/views/AddMemberToGroupConfirmationView.py b/ceo/tui/views/AddMemberToGroupConfirmationView.py new file mode 100644 index 0000000..8624de0 --- /dev/null +++ b/ceo/tui/views/AddMemberToGroupConfirmationView.py @@ -0,0 +1,10 @@ +from .ConfirmationView import ConfirmationView + + +class AddMemberToGroupConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"User '{self.model.username}' will be added to the group '{self.model.name}'." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/AddMemberToGroupView.py b/ceo/tui/views/AddMemberToGroupView.py new file mode 100644 index 0000000..093aa8f --- /dev/null +++ b/ceo/tui/views/AddMemberToGroupView.py @@ -0,0 +1,33 @@ +import urwid + +from .ColumnView import ColumnView + + +class AddMemberToGroupView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.name_edit = urwid.Edit() + self.username_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Group name:', align='right'), + self.name_edit + ), + ( + urwid.Text('New group member:', align='right'), + self.username_edit + ) + ] + checkbox = urwid.CheckBox( + 'Subscribe to auxiliary mailing lists', + state=True, + on_state_change=self.controller.on_list_subscribe_checkbox_change + ) + # This is necessary to place the checkbox in the center of the page + # (urwid.Padding doesn't seem to have an effect on it) + checkbox = urwid.Columns([ + ('weight', 1, urwid.Text('')), + ('weight', 3, checkbox) + ]) + extra_widgets = [urwid.Divider(), checkbox] + self.set_rows(rows, extra_widgets=extra_widgets) diff --git a/ceo/tui/views/AddUserConfirmationView.py b/ceo/tui/views/AddUserConfirmationView.py new file mode 100644 index 0000000..d66fae9 --- /dev/null +++ b/ceo/tui/views/AddUserConfirmationView.py @@ -0,0 +1,12 @@ +from .ConfirmationView import ConfirmationView + + +class AddUserConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = ['Please make sure that:', ''] + if self.model.membership_type == 'general_member': + lines.append(f'\N{BULLET} The new member has paid ${self.model.num_terms * 2} in club fees') + lines.append("\N{BULLET} You have verified the name on the new member's WatCard") + lines.append("\N{BULLET} The new member has signed the machine usage agreement") + self.set_lines(lines, align='left') diff --git a/ceo/tui/views/AddUserView.py b/ceo/tui/views/AddUserView.py new file mode 100644 index 0000000..8a06ba4 --- /dev/null +++ b/ceo/tui/views/AddUserView.py @@ -0,0 +1,74 @@ +import urwid + +from .ColumnView import ColumnView + + +class AddUserView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + membership_types_group = [] + self.username_edit = urwid.Edit() + self.full_name_edit = urwid.Edit() + self.first_name_edit = urwid.Edit() + self.last_name_edit = urwid.Edit() + self.program_edit = urwid.Edit() + self.forwarding_address_edit = urwid.Edit() + self.num_terms_edit = urwid.IntEdit(default=1) + rows = [ + ( + urwid.Text('Membership type:', align='right'), + urwid.RadioButton( + membership_types_group, + 'General membership ($2)', + on_state_change=self.controller.on_membership_type_changed, + user_data='general_member' + ) + ), + ( + urwid.Divider(), + urwid.RadioButton( + membership_types_group, + 'Club rep (free)', + on_state_change=self.controller.on_membership_type_changed, + user_data='club_rep' + ) + ), + ( + urwid.Text('Username:', align='right'), + self.username_edit + ), + ( + urwid.Text('Full name:', align='right'), + self.full_name_edit + ), + ( + urwid.Text('First name:', align='right'), + self.first_name_edit + ), + ( + urwid.Text('Last name:', align='right'), + self.last_name_edit + ), + ( + urwid.Text('Program:', align='right'), + self.program_edit + ), + ( + urwid.Text('Number of terms:', align='right'), + self.num_terms_edit + ), + ] + self.set_rows( + rows, + # We want to know when the username field loses focus + notify_when_focus_changes=True, + right_col_weight=2 + ) + + def update_fields(self): + self.full_name_edit.edit_text = self.model.full_name + self.first_name_edit.edit_text = self.model.first_name + self.last_name_edit.edit_text = self.model.last_name + self.program_edit.edit_text = self.model.program + self.forwarding_address_edit.edit_text = self.model.forwarding_address + self.num_terms_edit.edit_text = str(self.model.num_terms) diff --git a/ceo/tui/views/ChangeLoginShellConfirmationView.py b/ceo/tui/views/ChangeLoginShellConfirmationView.py new file mode 100644 index 0000000..c14fac9 --- /dev/null +++ b/ceo/tui/views/ChangeLoginShellConfirmationView.py @@ -0,0 +1,10 @@ +from .ConfirmationView import ConfirmationView + + +class ChangeLoginShellConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"{self.model.username}'s login shell will be set to {self.model.login_shell}." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/ChangeLoginShellView.py b/ceo/tui/views/ChangeLoginShellView.py new file mode 100644 index 0000000..b2ed52c --- /dev/null +++ b/ceo/tui/views/ChangeLoginShellView.py @@ -0,0 +1,27 @@ +import urwid + +from .ColumnView import ColumnView + + +class ChangeLoginShellView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.username_edit = urwid.Edit() + self.login_shell_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Username:', align='right'), + self.username_edit + ), + ( + urwid.Text('Login shell:', align='right'), + self.login_shell_edit + ) + ] + self.set_rows( + rows, + notify_when_focus_changes=True + ) + + def update_fields(self): + self.login_shell_edit.edit_text = self.model.login_shell diff --git a/ceo/tui/views/ColumnResponseView.py b/ceo/tui/views/ColumnResponseView.py new file mode 100644 index 0000000..5893742 --- /dev/null +++ b/ceo/tui/views/ColumnResponseView.py @@ -0,0 +1,32 @@ +import urwid + +from .ColumnView import ColumnView + + +class ColumnResponseView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + + def set_pairs(self, pairs, right_col_weight=1): + for i, (left, right) in enumerate(pairs): + if type(right) is list: + pairs[i] = (left, ','.join(map(str, right))) + else: + pairs[i] = (left, str(right)) + rows = [ + ( + urwid.Text( + left + ':' if left != '' else '', + align='right' + ), + urwid.Text(right) + ) + for left, right in pairs + ] + self.set_rows( + rows, + right_col_weight=right_col_weight, + disable_cols=True, + no_back_button=True, + on_next=self.controller.get_next_menu_callback('Welcome') + ) diff --git a/ceo/tui/views/ColumnView.py b/ceo/tui/views/ColumnView.py new file mode 100644 index 0000000..28f51d2 --- /dev/null +++ b/ceo/tui/views/ColumnView.py @@ -0,0 +1,59 @@ +import urwid + +from .View import View +from .utils import wrap_in_frame + + +class ColumnView(View): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + + def set_rows( + self, + rows, + right_col_weight=1, + notify_when_focus_changes=False, + disable_cols=False, + extra_widgets=None, + no_back_button=False, + on_next=None, + no_next_button=False, + ): + # Each item in the list is two columns + columns_list = [ + urwid.Columns( + [('weight', 1, left), ('weight', right_col_weight, right)], + dividechars=3, + focus_column=1 + ) + for left, right in rows + ] + if extra_widgets is not None: + columns_list.extend(extra_widgets) + listwalker = urwid.SimpleFocusListWalker(columns_list) + if notify_when_focus_changes: + # See https://stackoverflow.com/a/43125172 + urwid.connect_signal( + listwalker, 'modified', + self.controller.on_row_focus_changed + ) + # Keep a reference for the controller + self.listwalker = listwalker + cols = urwid.ListBox(listwalker) + if disable_cols: + cols = urwid.WidgetDisable(cols) + self.flash_text = urwid.Text('') + if no_back_button: + on_back = None + else: + on_back = self.controller.prev_menu_callback + if on_next is None and not no_next_button: + on_next = self.controller.on_next_button_pressed + body = cols + self.original_widget = wrap_in_frame( + body, + self.model.title, + on_back=on_back, + on_next=on_next, + flash_text=self.flash_text, + ) diff --git a/ceo/tui/views/ConfirmationView.py b/ceo/tui/views/ConfirmationView.py new file mode 100644 index 0000000..049c367 --- /dev/null +++ b/ceo/tui/views/ConfirmationView.py @@ -0,0 +1,18 @@ +import urwid + +from .PlainTextView import PlainTextView + + +class ConfirmationView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.flash_text = urwid.Text('') + + def set_lines(self, lines, align='center'): + super().set_lines( + lines, + align=align, + on_back=self.controller.prev_menu_callback, + on_next=self.controller.on_confirmation_button_pressed, + flash_text=self.flash_text, + ) diff --git a/ceo/tui/views/CreateDatabaseConfirmationView.py b/ceo/tui/views/CreateDatabaseConfirmationView.py new file mode 100644 index 0000000..8f756f7 --- /dev/null +++ b/ceo/tui/views/CreateDatabaseConfirmationView.py @@ -0,0 +1,14 @@ +from .ConfirmationView import ConfirmationView +import ceo.krb_check as krb + + +class CreateDatabaseConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + db_type = self.model.db_type + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + username = krb.get_username() + lines = [ + f"A new {db_type_name} database will be created for {username}." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/CreateDatabaseResponseView.py b/ceo/tui/views/CreateDatabaseResponseView.py new file mode 100644 index 0000000..8f9ca58 --- /dev/null +++ b/ceo/tui/views/CreateDatabaseResponseView.py @@ -0,0 +1,33 @@ +from .PlainTextView import PlainTextView + + +class CreateDatabaseResponseView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + + def activate(self): + self.controller.write_db_creds_to_file() + username = self.model.user_dict['uid'] + password = self.model.password + db_host = self.model.db_host + filename = self.model.filename + wrote_to_file = self.model.wrote_to_file + lines = [ + 'Connection information:', + '', + f'Database: {username}', + f'Username: {username}', + f'Password: {password}', + f'Host: {db_host}', + '' + ] + if wrote_to_file: + lines.append(f"These settings have been written to {filename}.") + else: + lines.append(f"We were unable to write these settings to {filename}.") + self.set_lines( + lines, + align='left', + on_next=self.controller.get_next_menu_callback('Welcome'), + ) + super().activate() diff --git a/ceo/tui/views/CreateDatabaseView.py b/ceo/tui/views/CreateDatabaseView.py new file mode 100644 index 0000000..0e8aab2 --- /dev/null +++ b/ceo/tui/views/CreateDatabaseView.py @@ -0,0 +1,30 @@ +import urwid + +from .ColumnView import ColumnView + + +class CreateDatabaseView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + db_types_group = [] + rows = [ + ( + urwid.Text('Database type:', align='right'), + urwid.RadioButton( + db_types_group, + 'MySQL', + on_state_change=self.controller.on_db_type_changed, + user_data='mysql' + ) + ), + ( + urwid.Divider(), + urwid.RadioButton( + db_types_group, + 'PostgreSQL', + on_state_change=self.controller.on_db_type_changed, + user_data='postgresql' + ) + ), + ] + self.set_rows(rows) diff --git a/ceo/tui/views/ErrorView.py b/ceo/tui/views/ErrorView.py new file mode 100644 index 0000000..7e39fb8 --- /dev/null +++ b/ceo/tui/views/ErrorView.py @@ -0,0 +1,15 @@ +from .PlainTextView import PlainTextView + + +class ErrorView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + 'An error occurred:', + '', + *model.error_message.split('\n') + ] + self.set_lines( + lines, + on_next=self.controller.get_next_menu_callback('Welcome'), + ) diff --git a/ceo/tui/views/GetGroupResponseView.py b/ceo/tui/views/GetGroupResponseView.py new file mode 100644 index 0000000..480af37 --- /dev/null +++ b/ceo/tui/views/GetGroupResponseView.py @@ -0,0 +1,24 @@ +from .PlainTextView import PlainTextView + + +class GetGroupResponseView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + d = self.model.resp_json + if 'description' in d: + desc = d['description'] + ' (' + d['cn'] + ')' + else: + desc = d['cn'] + lines = [ + 'Members of ' + desc + ':', + '' + ] + lines.extend([ + member['cn'] + ' (' + member['uid'] + ')' + for member in self.model.resp_json['members'] + ]) + self.set_lines( + lines, + scrollable=True, + on_next=self.controller.get_next_menu_callback('Welcome'), + ) diff --git a/ceo/tui/views/GetGroupView.py b/ceo/tui/views/GetGroupView.py new file mode 100644 index 0000000..178d04d --- /dev/null +++ b/ceo/tui/views/GetGroupView.py @@ -0,0 +1,16 @@ +import urwid + +from .ColumnView import ColumnView + + +class GetGroupView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.name_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Group name:', align='right'), + self.name_edit + ) + ] + self.set_rows(rows) diff --git a/ceo/tui/views/GetPositionsView.py b/ceo/tui/views/GetPositionsView.py new file mode 100644 index 0000000..e346fa1 --- /dev/null +++ b/ceo/tui/views/GetPositionsView.py @@ -0,0 +1,30 @@ +from zope import component +import urwid + +from .ColumnView import ColumnView +from .position_names import position_names +from ceo_common.interfaces import IConfig + + +class GetPositionsView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.position_fields = {} + cfg = component.getUtility(IConfig) + avail = cfg.get('positions_available') + rows = [] + for pos in avail: + name = position_names[pos] + field = urwid.Text('...') + self.position_fields[pos] = field + self.model.positions[pos] = '' + rows.append((urwid.Text(name, align='right'), field)) + self.set_rows(rows, disable_cols=True, no_next_button=True) + + def activate(self): + self.controller.lookup_positions_async() + super().activate() + + def update_fields(self): + for pos, field in self.position_fields.items(): + field.set_text(self.model.positions[pos]) diff --git a/ceo/tui/views/GetUserResponseView.py b/ceo/tui/views/GetUserResponseView.py new file mode 100644 index 0000000..5f2d89d --- /dev/null +++ b/ceo/tui/views/GetUserResponseView.py @@ -0,0 +1,31 @@ +from ...utils import user_dict_kv +from .ColumnResponseView import ColumnResponseView + + +class GetUserResponseView(ColumnResponseView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + d = self.model.resp_json.copy() + # We don't have a lot of vertical space, so it's best to + # omit unnecessary fields + cols_to_omit = [ + 'given_name', + 'sn', + 'is_club', + 'home_directory', + ] + for key in cols_to_omit: + del d[key] + pairs = user_dict_kv(d) + + num_terms = max(map(len, [ + d.get('terms', []), + d.get('non_member_terms', []) + ])) + if num_terms < 6: + right_col_weight = 1 + elif num_terms < 12: + right_col_weight = 2 + else: + right_col_weight = 3 + self.set_pairs(pairs, right_col_weight=right_col_weight) diff --git a/ceo/tui/views/GetUserView.py b/ceo/tui/views/GetUserView.py new file mode 100644 index 0000000..378e40a --- /dev/null +++ b/ceo/tui/views/GetUserView.py @@ -0,0 +1,16 @@ +import urwid + +from .ColumnView import ColumnView + + +class GetUserView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.username_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Username:', align='right'), + self.username_edit + ) + ] + self.set_rows(rows) diff --git a/ceo/tui/views/PlainTextView.py b/ceo/tui/views/PlainTextView.py new file mode 100644 index 0000000..821f2be --- /dev/null +++ b/ceo/tui/views/PlainTextView.py @@ -0,0 +1,47 @@ +import urwid + +from .View import View +from .utils import wrap_in_frame +from ceo.tui.app import App + + +class PlainTextView(View): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + + def set_lines( + self, + lines, + align='center', + scrollable=False, + min_width=None, + on_back=None, + on_next=None, + flash_text=None, + ): + if min_width is None: + if align == 'center': + min_width = App.WIDTH + else: + min_width = max(map(len, lines)) + if scrollable: + body = urwid.ListBox(urwid.SimpleListWalker([ + urwid.Text(line, align=align) + for line in lines + ])) + else: + body = urwid.Filler( + urwid.Text('\n'.join(lines), align=align) + ) + self.original_widget = wrap_in_frame( + urwid.Padding( + body, + align='center', + width=('relative', App.REL_WIDTH_PCT), + min_width=min_width, + ), + self.model.title, + on_back=on_back, + on_next=on_next, + flash_text=flash_text, + ) diff --git a/ceo/tui/views/RemoveMemberFromGroupConfirmationView.py b/ceo/tui/views/RemoveMemberFromGroupConfirmationView.py new file mode 100644 index 0000000..d9a2af3 --- /dev/null +++ b/ceo/tui/views/RemoveMemberFromGroupConfirmationView.py @@ -0,0 +1,10 @@ +from .ConfirmationView import ConfirmationView + + +class RemoveMemberFromGroupConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"User '{self.model.username}' will be removed from the group '{self.model.name}'." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/RemoveMemberFromGroupView.py b/ceo/tui/views/RemoveMemberFromGroupView.py new file mode 100644 index 0000000..9fa4ae7 --- /dev/null +++ b/ceo/tui/views/RemoveMemberFromGroupView.py @@ -0,0 +1,33 @@ +import urwid + +from .ColumnView import ColumnView + + +class RemoveMemberFromGroupView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.name_edit = urwid.Edit() + self.username_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Group name:', align='right'), + self.name_edit + ), + ( + urwid.Text('Member to remove:', align='right'), + self.username_edit + ) + ] + checkbox = urwid.CheckBox( + 'Unsubscribe from auxiliary mailing lists', + state=True, + on_state_change=self.controller.on_list_unsubscribe_checkbox_change + ) + # This is necessary to place the checkbox in the center of the page + # (urwid.Padding doesn't seem to have an effect on it) + checkbox = urwid.Columns([ + ('weight', 1, urwid.Text('')), + ('weight', 3, checkbox) + ]) + extra_widgets = [urwid.Divider(), checkbox] + self.set_rows(rows, extra_widgets=extra_widgets) diff --git a/ceo/tui/views/RenewUserConfirmationView.py b/ceo/tui/views/RenewUserConfirmationView.py new file mode 100644 index 0000000..1070ccd --- /dev/null +++ b/ceo/tui/views/RenewUserConfirmationView.py @@ -0,0 +1,18 @@ +from .ConfirmationView import ConfirmationView + + +class RenewUserConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + membership_str = 'member' + if model.membership_type == 'club_rep': + membership_str = 'non-member' + lines = [ + f"{model.username} will be renewed for the following {membership_str} terms:", + '', + ', '.join(self.model.new_terms) + ] + if model.membership_type == 'general_member': + lines.append('') + lines.append(f'Please make sure they have paid ${model.num_terms * 2} in club fees.') + self.set_lines(lines) diff --git a/ceo/tui/views/RenewUserView.py b/ceo/tui/views/RenewUserView.py new file mode 100644 index 0000000..b813ea7 --- /dev/null +++ b/ceo/tui/views/RenewUserView.py @@ -0,0 +1,40 @@ +import urwid + +from .ColumnView import ColumnView + + +class RenewUserView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + membership_types_group = [] + self.username_edit = urwid.Edit() + self.num_terms_edit = urwid.IntEdit(default=1) + rows = [ + ( + urwid.Text('Membership type:', align='right'), + urwid.RadioButton( + membership_types_group, + 'General membership ($2)', + on_state_change=self.controller.on_membership_type_changed, + user_data='general_member' + ) + ), + ( + urwid.Divider(), + urwid.RadioButton( + membership_types_group, + 'Club rep (free)', + on_state_change=self.controller.on_membership_type_changed, + user_data='club_rep' + ) + ), + ( + urwid.Text('Username:', align='right'), + self.username_edit + ), + ( + urwid.Text('Number of terms:', align='right'), + self.num_terms_edit + ), + ] + self.set_rows(rows, right_col_weight=2) diff --git a/ceo/tui/views/ResetDatabasePasswordConfirmationView.py b/ceo/tui/views/ResetDatabasePasswordConfirmationView.py new file mode 100644 index 0000000..a93bf91 --- /dev/null +++ b/ceo/tui/views/ResetDatabasePasswordConfirmationView.py @@ -0,0 +1,14 @@ +from .ConfirmationView import ConfirmationView +import ceo.krb_check as krb + + +class ResetDatabasePasswordConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + db_type = self.model.db_type + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + username = krb.get_username() + lines = [ + f"The {db_type_name} password for {username} will be reset." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/ResetDatabasePasswordResponseView.py b/ceo/tui/views/ResetDatabasePasswordResponseView.py new file mode 100644 index 0000000..91164ca --- /dev/null +++ b/ceo/tui/views/ResetDatabasePasswordResponseView.py @@ -0,0 +1,31 @@ +from .PlainTextView import PlainTextView + + +class ResetDatabasePasswordResponseView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + + def activate(self): + self.controller.write_db_creds_to_file() + db_type = self.model.db_type + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + username = self.model.user_dict['uid'] + password = self.model.password + filename = self.model.filename + wrote_to_file = self.model.wrote_to_file + lines = [ + f'The new {db_type_name} password for {username} is:' + '', + password, + '' + ] + if wrote_to_file: + lines.append(f"The settings in {filename} have been updated.") + else: + lines.append(f"We were unable to update the settings in {filename}.") + self.set_lines( + lines, + align='left', + on_next=self.controller.get_next_menu_callback('Welcome'), + ) + super().activate() diff --git a/ceo/tui/views/ResetDatabasePasswordView.py b/ceo/tui/views/ResetDatabasePasswordView.py new file mode 100644 index 0000000..c891be3 --- /dev/null +++ b/ceo/tui/views/ResetDatabasePasswordView.py @@ -0,0 +1,30 @@ +import urwid + +from .ColumnView import ColumnView + + +class ResetDatabasePasswordView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + db_types_group = [] + rows = [ + ( + urwid.Text('Database type:', align='right'), + urwid.RadioButton( + db_types_group, + 'MySQL', + on_state_change=self.controller.on_db_type_changed, + user_data='mysql' + ) + ), + ( + urwid.Divider(), + urwid.RadioButton( + db_types_group, + 'PostgreSQL', + on_state_change=self.controller.on_db_type_changed, + user_data='postgresql' + ) + ), + ] + self.set_rows(rows) diff --git a/ceo/tui/views/ResetPasswordConfirmationView.py b/ceo/tui/views/ResetPasswordConfirmationView.py new file mode 100644 index 0000000..4e7475e --- /dev/null +++ b/ceo/tui/views/ResetPasswordConfirmationView.py @@ -0,0 +1,10 @@ +from .ConfirmationView import ConfirmationView + + +class ResetPasswordConfirmationView(ConfirmationView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"{self.model.username}'s password will be set to a random value." + ] + self.set_lines(lines) diff --git a/ceo/tui/views/ResetPasswordResponseView.py b/ceo/tui/views/ResetPasswordResponseView.py new file mode 100644 index 0000000..38d3a09 --- /dev/null +++ b/ceo/tui/views/ResetPasswordResponseView.py @@ -0,0 +1,17 @@ +from .PlainTextView import PlainTextView + + +class ResetPasswordResponseView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + f"The new password for {self.model.username} is:", + '', + self.model.resp_json['password'], + '', + 'YOU MUST NOW EMAIL THIS PASSWORD TO THE USER.' + ] + self.set_lines( + lines, + on_next=self.controller.get_next_menu_callback('Welcome'), + ) diff --git a/ceo/tui/views/ResetPasswordUsePasswdView.py b/ceo/tui/views/ResetPasswordUsePasswdView.py new file mode 100644 index 0000000..6a0765d --- /dev/null +++ b/ceo/tui/views/ResetPasswordUsePasswdView.py @@ -0,0 +1,15 @@ +from .PlainTextView import PlainTextView + + +class ResetPasswordUsePasswdView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = [ + "This is a utility for syscom members to reset other members' passwords.", + '', + 'To reset your own password, just run `passwd`.' + ] + super().set_lines( + lines, + on_next=self.controller.get_next_menu_callback('Welcome'), + ) diff --git a/ceo/tui/views/ResetPasswordView.py b/ceo/tui/views/ResetPasswordView.py new file mode 100644 index 0000000..a82967b --- /dev/null +++ b/ceo/tui/views/ResetPasswordView.py @@ -0,0 +1,16 @@ +import urwid + +from .ColumnView import ColumnView + + +class ResetPasswordView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.username_edit = urwid.Edit() + rows = [ + ( + urwid.Text('Username:', align='right'), + self.username_edit + ) + ] + self.set_rows(rows) diff --git a/ceo/tui/views/SetPositionsView.py b/ceo/tui/views/SetPositionsView.py new file mode 100644 index 0000000..648d108 --- /dev/null +++ b/ceo/tui/views/SetPositionsView.py @@ -0,0 +1,41 @@ +from zope import component + +import urwid + +from .ColumnView import ColumnView +from .position_names import position_names +from ceo_common.interfaces import IConfig + + +class SetPositionsView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + self.position_fields = {} + cfg = component.getUtility(IConfig) + avail = cfg.get('positions_available') + required = cfg.get('positions_required') + rows = [] + for pos in avail: + name = position_names[pos] + if pos in required: + name += ' (*)' + else: + name += ' ' + field = urwid.Edit() + self.position_fields[pos] = field + self.model.positions[pos] = '' + rows.append((urwid.Text(name, align='right'), field)) + extra_widgets = [ + urwid.Divider(), + # Note that this appears all the way on the left + urwid.Text('(*) Required') + ] + self.set_rows(rows, extra_widgets=extra_widgets) + + def activate(self): + self.controller.lookup_positions_async() + super().activate() + + def update_fields(self): + for pos, field in self.position_fields.items(): + field.edit_text = self.model.positions[pos] diff --git a/ceo/tui/views/SyncResponseView.py b/ceo/tui/views/SyncResponseView.py new file mode 100644 index 0000000..88767cc --- /dev/null +++ b/ceo/tui/views/SyncResponseView.py @@ -0,0 +1,11 @@ +from .PlainTextView import PlainTextView + + +class SyncResponseView(PlainTextView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + lines = ['Request successfully completed.'] + self.set_lines( + lines, + on_next=self.controller.get_next_menu_callback('Welcome'), + ) diff --git a/ceo/tui/views/TransactionView.py b/ceo/tui/views/TransactionView.py new file mode 100644 index 0000000..4f8a639 --- /dev/null +++ b/ceo/tui/views/TransactionView.py @@ -0,0 +1,54 @@ +import urwid + +from ...operation_strings import descriptions +from .View import View +from .utils import wrap_in_frame, CenterButton, decorate_button + + +class TransactionView(View): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + left_col_elems = [] + right_col_elems = [] + for op in model.operations: + left_col_elems.append(urwid.Text(descriptions[op] + '...', align='right')) + right_col_elems.append(urwid.Text('')) + left_col = urwid.ListBox(urwid.SimpleFocusListWalker(left_col_elems)) + left_col = urwid.WidgetDisable(left_col) + right_col = urwid.ListBox(urwid.SimpleFocusListWalker(right_col_elems)) + right_col = urwid.WidgetDisable(right_col) + cols = urwid.Columns( + [left_col, right_col], + dividechars=2 + ) + self.next_button = decorate_button( + CenterButton( + 'Next', + on_press=self.on_next_button_pressed, + ) + ) + # It doesn't seem to be possible to move focus to a button which + # was intially disabled, so we're going to use a flag variable + self._next_button_enabled = False + # The controller uses this to show status messages + self.message_text = urwid.Text('', align='center') + self.original_widget = wrap_in_frame( + cols, + model.title, + next_btn=self.next_button, + message_text=self.message_text, + ) + # Keep a reference for the controller + self.right_col_elems = right_col_elems + + def enable_next_button(self): + self._next_button_enabled = True + + def on_next_button_pressed(self, button): + if not self._next_button_enabled: + return + self.controller.next_menu_callback(button, 'Welcome') + + def activate(self): + super().activate() + self.controller.start() diff --git a/ceo/tui/views/View.py b/ceo/tui/views/View.py new file mode 100644 index 0000000..7f104ff --- /dev/null +++ b/ceo/tui/views/View.py @@ -0,0 +1,48 @@ +from abc import ABC + +import urwid + +from .utils import CenterButton, decorate_button + + +class View(ABC): + def __init__(self, model, controller, app): + super().__init__() + self.model = model + self.controller = controller + self.app = app + self.original_widget = None + + def activate(self): + if self.original_widget is None: + raise Exception('child class must set self.original_widget') + self.app.main_widget.original_widget = self.original_widget + + def popup(self, message): + button = CenterButton('OK') + body = urwid.Text(message + '\n\n', align='center') + body = urwid.Pile([ + body, + urwid.Columns([ + ('weight', 1, urwid.WidgetDisable(urwid.Text(''))), + decorate_button(button), + ('weight', 1, urwid.WidgetDisable(urwid.Text(''))), + ]) + ], focus_item=1) + body = urwid.Filler(body) + body = urwid.LineBox(body) + old_original_widget = self.app.main_widget.original_widget + + def on_ok_clicked(*_): + self.app.main_widget.original_widget = old_original_widget + + urwid.connect_signal(button, 'click', on_ok_clicked) + popup = urwid.Overlay( + body, + self.app.main_widget.original_widget, + align='center', + width=('relative', 60), + valign='middle', + height=('relative', 60), + ) + self.app.main_widget.original_widget = popup diff --git a/ceo/tui/views/WelcomeView.py b/ceo/tui/views/WelcomeView.py new file mode 100644 index 0000000..fd07a32 --- /dev/null +++ b/ceo/tui/views/WelcomeView.py @@ -0,0 +1,31 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import decorate_button + + +class WelcomeView(ColumnView): + def __init__(self, model, controller, app): + super().__init__(model, controller, app) + rows = [] + for category, model_classes in model.categories.items(): + if len(rows) > 0: + # Place dividers between sections + rows.append((urwid.Divider(), urwid.Divider())) + for i, model_class in enumerate(model_classes): + if i == 0: + left_col_elem = urwid.Text(category, align='right') + else: + left_col_elem = urwid.Divider() + button = urwid.Button( + model_class.title, + on_press=self.controller.get_next_menu_callback(model_class.name), + ) + rows.append((left_col_elem, decorate_button(button))) + self.set_rows( + rows, + right_col_weight=3, + no_back_button=True, + no_next_button=True, + ) + self.flash_text.set_text('Press q or Esc to quit') diff --git a/ceo/tui/views/__init__.py b/ceo/tui/views/__init__.py new file mode 100644 index 0000000..bb4476e --- /dev/null +++ b/ceo/tui/views/__init__.py @@ -0,0 +1,37 @@ +from .View import View +from .WelcomeView import WelcomeView +from .AddUserView import AddUserView +from .AddUserConfirmationView import AddUserConfirmationView +from .RenewUserView import RenewUserView +from .RenewUserConfirmationView import RenewUserConfirmationView +from .GetUserView import GetUserView +from .GetUserResponseView import GetUserResponseView +from .ResetPasswordView import ResetPasswordView +from .ResetPasswordConfirmationView import ResetPasswordConfirmationView +from .ResetPasswordUsePasswdView import ResetPasswordUsePasswdView +from .ResetPasswordResponseView import ResetPasswordResponseView +from .ChangeLoginShellView import ChangeLoginShellView +from .ChangeLoginShellConfirmationView import ChangeLoginShellConfirmationView +from .AddGroupView import AddGroupView +from .AddGroupConfirmationView import AddGroupConfirmationView +from .GetGroupView import GetGroupView +from .GetGroupResponseView import GetGroupResponseView +from .AddMemberToGroupView import AddMemberToGroupView +from .AddMemberToGroupConfirmationView import AddMemberToGroupConfirmationView +from .RemoveMemberFromGroupView import RemoveMemberFromGroupView +from .RemoveMemberFromGroupConfirmationView import RemoveMemberFromGroupConfirmationView +from .CreateDatabaseView import CreateDatabaseView +from .CreateDatabaseConfirmationView import CreateDatabaseConfirmationView +from .CreateDatabaseResponseView import CreateDatabaseResponseView +from .ResetDatabasePasswordView import ResetDatabasePasswordView +from .ResetDatabasePasswordConfirmationView import ResetDatabasePasswordConfirmationView +from .ResetDatabasePasswordResponseView import ResetDatabasePasswordResponseView +from .GetPositionsView import GetPositionsView +from .SetPositionsView import SetPositionsView +from .TransactionView import TransactionView +from .PlainTextView import PlainTextView +from .ConfirmationView import ConfirmationView +from .ErrorView import ErrorView +from .SyncResponseView import SyncResponseView +from .ColumnView import ColumnView +from .ColumnResponseView import ColumnResponseView diff --git a/ceo/tui/views/position_names.py b/ceo/tui/views/position_names.py new file mode 100644 index 0000000..96b391d --- /dev/null +++ b/ceo/tui/views/position_names.py @@ -0,0 +1,12 @@ +position_names = { + 'president': "President", + 'vice-president': "Vice President", + 'treasurer': "Treasurer", + 'secretary': "Secretary", + 'sysadmin': "Sysadmin", + 'cro': "Chief Returning Officer", + 'librarian': "Librarian", + 'imapd': "IMAPD", + 'webmaster': "Web Master", + 'offsck': "Office Manager", +} diff --git a/ceo/tui/views/utils.py b/ceo/tui/views/utils.py new file mode 100644 index 0000000..0f9bfae --- /dev/null +++ b/ceo/tui/views/utils.py @@ -0,0 +1,70 @@ +import urwid + + +def replace_column_element(columns, idx, elem): + _, options = columns.contents[idx] + columns.contents[idx] = elem, options + + +class CenterButton(urwid.Button): + def __init__(self, label, on_press=None, user_data=None): + super().__init__('', on_press, user_data) + text = urwid.Text(label, align='center') + text._selectable = True + replace_column_element(self._w, 1, text) + + +def decorate_button(button): + # See the palette in start.py + return urwid.AttrMap(button, None, focus_map='reversed') + + +def wrap_in_frame( + widget, + title, + on_back=None, + on_next=None, + next_btn=None, + flash_text=None, + message_text=None, +): + back_button_wrapper = urwid.WidgetDisable(urwid.Text('')) + next_button_wrapper = urwid.WidgetDisable(urwid.Text('')) + if on_back is not None: + back_button = CenterButton('Back', on_back) + back_button_wrapper = decorate_button(back_button) + if on_next is not None: + next_button = CenterButton('Next', on_next) + next_button_wrapper = decorate_button(next_button) + elif next_btn is not None: + next_button_wrapper = next_btn + if on_back is not None or on_next is not None or next_btn is not None: + footer = urwid.Columns([ + urwid.WidgetDisable(urwid.Text('')), + back_button_wrapper, + urwid.WidgetDisable(urwid.Text('')), + next_button_wrapper, + urwid.WidgetDisable(urwid.Text('')), + ]) + footer_height = 1 + if flash_text is not None: + flash_text = urwid.WidgetDisable(flash_text) + footer = urwid.Pile([flash_text, footer]) + footer_height = 2 + elif message_text is not None: + footer = urwid.Pile([message_text, footer]) + footer_height = 6 # ??? + footer_height += 1 # add 1 for the bottom padding + footer = urwid.Filler(footer, valign='bottom', bottom=1) + body = urwid.Pile([widget, (footer_height, footer)]) + else: + body = widget + if flash_text is not None: + flash_text = urwid.WidgetDisable(flash_text) + footer = urwid.Filler(flash_text, valign='bottom', bottom=1) + body = urwid.Pile([body, (2, footer)]) + header = urwid.Pile([ + urwid.Text(('bold', title), align='center'), + urwid.Divider() + ]) + return urwid.Frame(body, header) diff --git a/ceo/utils.py b/ceo/utils.py index e977759..00a86a8 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -8,6 +8,7 @@ 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 @@ -125,9 +126,12 @@ def user_dict_kv(d: Dict) -> List[Tuple[str]]: else: pairs.append(('forwarding addresses', '')) if 'terms' in d: - pairs.append(('member terms', ','.join(d['terms']))) + # sort the terms in chronological order for display purposes + _terms = map(str, sorted(map(Term, d['terms']))) + pairs.append(('member terms', ','.join(_terms))) if 'non_member_terms' in d: - pairs.append(('non-member terms', ','.join(d['non_member_terms']))) + _terms = map(str, sorted(map(Term, d['non_member_terms']))) + pairs.append(('non-member terms', ','.join(_terms))) if 'password' in d: pairs.append(('password', d['password'])) return pairs diff --git a/debian/control b/debian/control index f2a20c8..8918721 100644 --- a/debian/control +++ b/debian/control @@ -11,33 +11,14 @@ Build-Depends: debhelper (>= 12.1.1), python3-venv (>= 3.7), libkrb5-dev (>= 1.17), libpq-dev (>= 11.13), - libfreetype6-dev (>= 2.2.1), - libimagequant-dev (>= 2.11.10), - libjpeg62-turbo-dev (>= 1.3.1), - liblcms2-dev (>= 2.2+git20110628), - libtiff5-dev (>= 4.0.3), - libwebp-dev (>= 0.5.1), - libwebpdemux2 (>= 0.5.1), - libwebpmux3 (>= 0.6.1-2), - zlib1g-dev (>= 1:1.1.4), scdoc (>= 1.9) Package: ceo-common Architecture: amd64 Depends: python3 (>= 3.7), krb5-user (>= 1.17), - libffi6 | libffi7 | libffi8, libkrb5-3 (>= 1.17), libpq5 (>= 11.13), - libfreetype6 (>= 2.2.1), - libimagequant0 (>= 2.11.10), - libjpeg62-turbo (>= 1.3.1), - liblcms2-2 (>= 2.2+git20110628), - libtiff5 (>= 4.0.3), - libwebp6 (>= 0.5.1), - libwebpdemux2 (>= 0.5.1), - libwebpmux3 (>= 0.6.1-2), - zlib1g (>= 1:1.2), ${python3:Depends}, ${misc:Depends} Description: CSC Electronic Office common files diff --git a/requirements.txt b/requirements.txt index beebb2c..6622c75 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -asciimatics==1.13.0 -click==8.0.1 +click==8.1.3 cryptography==35.0.0 -Flask==2.0.1 +Flask==2.1.2 gssapi==1.6.14 gunicorn==20.1.0 -Jinja2==3.0.1 +Jinja2==3.1.2 ldap3==2.9.1 requests==2.26.0 requests-gssapi==1.2.3 @@ -12,3 +11,4 @@ zope.component==5.0.1 zope.interface==5.4.0 mysql-connector-python==8.0.26 psycopg2==2.9.1 +urwid==2.1.2 diff --git a/setup.cfg b/setup.cfg index 1833634..767fbf2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,9 @@ [flake8] ignore = # line too long - E501 + E501, + # unable to detect undefined names + F403, + # name may be undefined or or defined from star imports + F405 exclude = .git,.vscode,venv,__pycache__,__init__.py,build,dist,one_time_scripts