From b543f0eb0cd6c46503c89088e599b98ec3f975b7 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Sun, 22 May 2022 14:09:46 -0400 Subject: [PATCH] Rewrite TUI (#52) Closes #44. Closes #47. Closes #49. Closes #50. The TUI has been rewritten using urwid instead of asciimatics. The MVC pattern was also used to help increase organization and readability. The mouse has been disabled, which allows users to easily copy text from the terminal. Terms are now sorted when displayed, for both the CLI and the TUI. Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/52 --- .drone/coffee-setup.sh | 3 + ceo/term_utils.py | 6 +- ceo/tui/CeoFrame.py | 160 ------------------ ceo/tui/ConfirmView.py | 61 ------- ceo/tui/ErrorView.py | 29 ---- ceo/tui/Model.py | 56 ------ ceo/tui/ResultView.py | 62 ------- ceo/tui/TUIStreamResponseHandler.py | 93 ---------- ceo/tui/TransactionView.py | 85 ---------- ceo/tui/WelcomeView.py | 107 ------------ ceo/tui/app.py | 30 ++++ ceo/tui/controllers/AddGroupController.py | 36 ++++ .../controllers/AddMemberToGroupController.py | 37 ++++ ceo/tui/controllers/AddUserController.py | 110 ++++++++++++ .../AddUserTransactionController.py | 38 +++++ .../controllers/ChangeLoginShellController.py | 79 +++++++++ ceo/tui/controllers/Controller.py | 78 +++++++++ .../controllers/CreateDatabaseController.py | 50 ++++++ ceo/tui/controllers/GetGroupController.py | 22 +++ ceo/tui/controllers/GetPositionsController.py | 29 ++++ ceo/tui/controllers/GetUserController.py | 22 +++ .../RemoveMemberFromGroupController.py | 37 ++++ ceo/tui/controllers/RenewUserController.py | 54 ++++++ .../ResetDatabasePasswordController.py | 49 ++++++ .../controllers/ResetPasswordController.py | 27 +++ ceo/tui/controllers/SetPositionsController.py | 47 +++++ ceo/tui/controllers/SyncRequestController.py | 39 +++++ ceo/tui/controllers/TransactionController.py | 110 ++++++++++++ ceo/tui/controllers/WelcomeController.py | 6 + ceo/tui/controllers/__init__.py | 18 ++ ceo/tui/databases/CreateDatabaseResultView.py | 34 ---- ceo/tui/databases/CreateDatabaseView.py | 47 ----- .../ResetDatabasePasswordResultView.py | 29 ---- .../databases/ResetDatabasePasswordView.py | 47 ----- ceo/tui/databases/__init__.py | 1 - ceo/tui/groups/AddGroupView.py | 46 ----- ceo/tui/groups/AddMemberToGroupView.py | 49 ------ ceo/tui/groups/GetGroupResultView.py | 18 -- ceo/tui/groups/GetGroupView.py | 33 ---- ceo/tui/groups/RemoveMemberFromGroupView.py | 48 ------ ceo/tui/groups/__init__.py | 0 ceo/tui/members/AddUserTransactionView.py | 25 --- ceo/tui/members/AddUserView.py | 116 ------------- ceo/tui/members/ChangeLoginShellView.py | 75 -------- ceo/tui/members/GetUserResultView.py | 11 -- ceo/tui/members/GetUserView.py | 33 ---- ceo/tui/members/RenewUserView.py | 65 ------- ceo/tui/members/ResetPasswordResultView.py | 12 -- ceo/tui/members/ResetPasswordView.py | 34 ---- ceo/tui/members/SetForwardingAddressesView.py | 80 --------- ceo/tui/members/__init__.py | 0 ceo/tui/models/AddGroupModel.py | 7 + ceo/tui/models/AddMemberToGroupModel.py | 8 + ceo/tui/models/AddUserModel.py | 13 ++ ceo/tui/models/ChangeLoginShellModel.py | 8 + ceo/tui/models/CreateDatabaseModel.py | 12 ++ ceo/tui/models/GetGroupModel.py | 7 + ceo/tui/models/GetPositionsModel.py | 4 + ceo/tui/models/GetUserModel.py | 7 + ceo/tui/models/RemoveMemberFromGroupModel.py | 8 + ceo/tui/models/RenewUserModel.py | 10 ++ ceo/tui/models/ResetDatabasePasswordModel.py | 11 ++ ceo/tui/models/ResetPasswordModel.py | 7 + ceo/tui/models/SetPositionsModel.py | 4 + ceo/tui/models/TransactionModel.py | 9 + ceo/tui/models/WelcomeModel.py | 43 +++++ ceo/tui/models/__init__.py | 15 ++ ceo/tui/positions/GetPositionsView.py | 80 --------- ceo/tui/positions/SetPositionsView.py | 76 --------- ceo/tui/positions/__init__.py | 2 - ceo/tui/start.py | 119 ++++--------- ceo/tui/utils.py | 92 +++++++++- ceo/tui/views/AddGroupConfirmationView.py | 10 ++ ceo/tui/views/AddGroupView.py | 21 +++ .../views/AddMemberToGroupConfirmationView.py | 10 ++ ceo/tui/views/AddMemberToGroupView.py | 33 ++++ ceo/tui/views/AddUserConfirmationView.py | 12 ++ ceo/tui/views/AddUserView.py | 74 ++++++++ .../views/ChangeLoginShellConfirmationView.py | 10 ++ ceo/tui/views/ChangeLoginShellView.py | 27 +++ ceo/tui/views/ColumnResponseView.py | 32 ++++ ceo/tui/views/ColumnView.py | 59 +++++++ ceo/tui/views/ConfirmationView.py | 18 ++ .../views/CreateDatabaseConfirmationView.py | 14 ++ ceo/tui/views/CreateDatabaseResponseView.py | 33 ++++ ceo/tui/views/CreateDatabaseView.py | 30 ++++ ceo/tui/views/ErrorView.py | 15 ++ ceo/tui/views/GetGroupResponseView.py | 24 +++ ceo/tui/views/GetGroupView.py | 16 ++ ceo/tui/views/GetPositionsView.py | 30 ++++ ceo/tui/views/GetUserResponseView.py | 31 ++++ ceo/tui/views/GetUserView.py | 16 ++ ceo/tui/views/PlainTextView.py | 47 +++++ .../RemoveMemberFromGroupConfirmationView.py | 10 ++ ceo/tui/views/RemoveMemberFromGroupView.py | 33 ++++ ceo/tui/views/RenewUserConfirmationView.py | 18 ++ ceo/tui/views/RenewUserView.py | 40 +++++ .../ResetDatabasePasswordConfirmationView.py | 14 ++ .../ResetDatabasePasswordResponseView.py | 31 ++++ ceo/tui/views/ResetDatabasePasswordView.py | 30 ++++ .../views/ResetPasswordConfirmationView.py | 10 ++ ceo/tui/views/ResetPasswordResponseView.py | 17 ++ ceo/tui/views/ResetPasswordUsePasswdView.py | 15 ++ ceo/tui/views/ResetPasswordView.py | 16 ++ ceo/tui/views/SetPositionsView.py | 41 +++++ ceo/tui/views/SyncResponseView.py | 11 ++ ceo/tui/views/TransactionView.py | 54 ++++++ ceo/tui/views/View.py | 48 ++++++ ceo/tui/views/WelcomeView.py | 31 ++++ ceo/tui/views/__init__.py | 37 ++++ ceo/tui/views/position_names.py | 12 ++ ceo/tui/views/utils.py | 70 ++++++++ ceo/utils.py | 8 +- debian/control | 19 --- requirements.txt | 8 +- setup.cfg | 6 +- 116 files changed, 2333 insertions(+), 1733 deletions(-) delete mode 100644 ceo/tui/CeoFrame.py delete mode 100644 ceo/tui/ConfirmView.py delete mode 100644 ceo/tui/ErrorView.py delete mode 100644 ceo/tui/Model.py delete mode 100644 ceo/tui/ResultView.py delete mode 100644 ceo/tui/TUIStreamResponseHandler.py delete mode 100644 ceo/tui/TransactionView.py delete mode 100644 ceo/tui/WelcomeView.py create mode 100644 ceo/tui/app.py create mode 100644 ceo/tui/controllers/AddGroupController.py create mode 100644 ceo/tui/controllers/AddMemberToGroupController.py create mode 100644 ceo/tui/controllers/AddUserController.py create mode 100644 ceo/tui/controllers/AddUserTransactionController.py create mode 100644 ceo/tui/controllers/ChangeLoginShellController.py create mode 100644 ceo/tui/controllers/Controller.py create mode 100644 ceo/tui/controllers/CreateDatabaseController.py create mode 100644 ceo/tui/controllers/GetGroupController.py create mode 100644 ceo/tui/controllers/GetPositionsController.py create mode 100644 ceo/tui/controllers/GetUserController.py create mode 100644 ceo/tui/controllers/RemoveMemberFromGroupController.py create mode 100644 ceo/tui/controllers/RenewUserController.py create mode 100644 ceo/tui/controllers/ResetDatabasePasswordController.py create mode 100644 ceo/tui/controllers/ResetPasswordController.py create mode 100644 ceo/tui/controllers/SetPositionsController.py create mode 100644 ceo/tui/controllers/SyncRequestController.py create mode 100644 ceo/tui/controllers/TransactionController.py create mode 100644 ceo/tui/controllers/WelcomeController.py create mode 100644 ceo/tui/controllers/__init__.py delete mode 100644 ceo/tui/databases/CreateDatabaseResultView.py delete mode 100644 ceo/tui/databases/CreateDatabaseView.py delete mode 100644 ceo/tui/databases/ResetDatabasePasswordResultView.py delete mode 100644 ceo/tui/databases/ResetDatabasePasswordView.py delete mode 100644 ceo/tui/databases/__init__.py delete mode 100644 ceo/tui/groups/AddGroupView.py delete mode 100644 ceo/tui/groups/AddMemberToGroupView.py delete mode 100644 ceo/tui/groups/GetGroupResultView.py delete mode 100644 ceo/tui/groups/GetGroupView.py delete mode 100644 ceo/tui/groups/RemoveMemberFromGroupView.py delete mode 100644 ceo/tui/groups/__init__.py delete mode 100644 ceo/tui/members/AddUserTransactionView.py delete mode 100644 ceo/tui/members/AddUserView.py delete mode 100644 ceo/tui/members/ChangeLoginShellView.py delete mode 100644 ceo/tui/members/GetUserResultView.py delete mode 100644 ceo/tui/members/GetUserView.py delete mode 100644 ceo/tui/members/RenewUserView.py delete mode 100644 ceo/tui/members/ResetPasswordResultView.py delete mode 100644 ceo/tui/members/ResetPasswordView.py delete mode 100644 ceo/tui/members/SetForwardingAddressesView.py delete mode 100644 ceo/tui/members/__init__.py create mode 100644 ceo/tui/models/AddGroupModel.py create mode 100644 ceo/tui/models/AddMemberToGroupModel.py create mode 100644 ceo/tui/models/AddUserModel.py create mode 100644 ceo/tui/models/ChangeLoginShellModel.py create mode 100644 ceo/tui/models/CreateDatabaseModel.py create mode 100644 ceo/tui/models/GetGroupModel.py create mode 100644 ceo/tui/models/GetPositionsModel.py create mode 100644 ceo/tui/models/GetUserModel.py create mode 100644 ceo/tui/models/RemoveMemberFromGroupModel.py create mode 100644 ceo/tui/models/RenewUserModel.py create mode 100644 ceo/tui/models/ResetDatabasePasswordModel.py create mode 100644 ceo/tui/models/ResetPasswordModel.py create mode 100644 ceo/tui/models/SetPositionsModel.py create mode 100644 ceo/tui/models/TransactionModel.py create mode 100644 ceo/tui/models/WelcomeModel.py create mode 100644 ceo/tui/models/__init__.py delete mode 100644 ceo/tui/positions/GetPositionsView.py delete mode 100644 ceo/tui/positions/SetPositionsView.py delete mode 100644 ceo/tui/positions/__init__.py create mode 100644 ceo/tui/views/AddGroupConfirmationView.py create mode 100644 ceo/tui/views/AddGroupView.py create mode 100644 ceo/tui/views/AddMemberToGroupConfirmationView.py create mode 100644 ceo/tui/views/AddMemberToGroupView.py create mode 100644 ceo/tui/views/AddUserConfirmationView.py create mode 100644 ceo/tui/views/AddUserView.py create mode 100644 ceo/tui/views/ChangeLoginShellConfirmationView.py create mode 100644 ceo/tui/views/ChangeLoginShellView.py create mode 100644 ceo/tui/views/ColumnResponseView.py create mode 100644 ceo/tui/views/ColumnView.py create mode 100644 ceo/tui/views/ConfirmationView.py create mode 100644 ceo/tui/views/CreateDatabaseConfirmationView.py create mode 100644 ceo/tui/views/CreateDatabaseResponseView.py create mode 100644 ceo/tui/views/CreateDatabaseView.py create mode 100644 ceo/tui/views/ErrorView.py create mode 100644 ceo/tui/views/GetGroupResponseView.py create mode 100644 ceo/tui/views/GetGroupView.py create mode 100644 ceo/tui/views/GetPositionsView.py create mode 100644 ceo/tui/views/GetUserResponseView.py create mode 100644 ceo/tui/views/GetUserView.py create mode 100644 ceo/tui/views/PlainTextView.py create mode 100644 ceo/tui/views/RemoveMemberFromGroupConfirmationView.py create mode 100644 ceo/tui/views/RemoveMemberFromGroupView.py create mode 100644 ceo/tui/views/RenewUserConfirmationView.py create mode 100644 ceo/tui/views/RenewUserView.py create mode 100644 ceo/tui/views/ResetDatabasePasswordConfirmationView.py create mode 100644 ceo/tui/views/ResetDatabasePasswordResponseView.py create mode 100644 ceo/tui/views/ResetDatabasePasswordView.py create mode 100644 ceo/tui/views/ResetPasswordConfirmationView.py create mode 100644 ceo/tui/views/ResetPasswordResponseView.py create mode 100644 ceo/tui/views/ResetPasswordUsePasswdView.py create mode 100644 ceo/tui/views/ResetPasswordView.py create mode 100644 ceo/tui/views/SetPositionsView.py create mode 100644 ceo/tui/views/SyncResponseView.py create mode 100644 ceo/tui/views/TransactionView.py create mode 100644 ceo/tui/views/View.py create mode 100644 ceo/tui/views/WelcomeView.py create mode 100644 ceo/tui/views/__init__.py create mode 100644 ceo/tui/views/position_names.py create mode 100644 ceo/tui/views/utils.py 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