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/app.py b/ceo/tui/app.py new file mode 100644 index 0000000..dcd0693 --- /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 = 60 + # On a full-screen (1366x768) gnome-terminal window, + # I had 168 cols and 36 rows + WIDTH = int(0.6 * 168) + HEIGHT = int(0.6 * 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..5aa46e7 --- /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..e37bc67 --- /dev/null +++ b/ceo/tui/controllers/CreateDatabaseController.py @@ -0,0 +1,53 @@ +import os + +from zope import component + +from ...utils import http_get, http_post, write_db_creds +from .Controller import Controller +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 + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + cfg = component.getUtility(IConfig) + db_host = cfg.get(f'{db_type}_host') + username = krb.get_username() + 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..2e516a5 --- /dev/null +++ b/ceo/tui/controllers/ResetDatabasePasswordController.py @@ -0,0 +1,52 @@ +import os + +from zope import component + +from ...utils import http_get, http_post, write_db_creds +from .Controller import Controller +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 + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + cfg = component.getUtility(IConfig) + db_host = cfg.get(f'{db_type}_host') + username = self.model.user_dict['uid'] + 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..4204ca0 --- /dev/null +++ b/ceo/tui/controllers/TransactionController.py @@ -0,0 +1,112 @@ +from threading import Thread +from typing import Dict, List + +import urwid + +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/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/start.py b/ceo/tui/start.py index a5aaea5..644fff3 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -1,89 +1,43 @@ -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.SolidFill('\N{MEDIUM SHADE}'), + align='center', + width=('relative', App.REL_WIDTH_PCT), + valign='middle', + height=('relative', App.REL_HEIGHT_PCT), + # On a full-screen (1366x768) gnome-terminal window, + # I had 168 cols and 36 rows + min_width=App.WIDTH, + min_height=App.HEIGHT, + ) + loop = urwid.MainLoop( + top, + palette=[ + ('reversed', 'standout', ''), + ('bold', 'bold', ''), + ('green', 'light green', ''), + ('red', 'light red', ''), + ], + # 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..c6e8651 --- /dev/null +++ b/ceo/tui/views/AddUserView.py @@ -0,0 +1,75 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..ab5756f --- /dev/null +++ b/ceo/tui/views/ColumnResponseView.py @@ -0,0 +1,29 @@ +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 + ':', 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..093206b --- /dev/null +++ b/ceo/tui/views/CreateDatabaseView.py @@ -0,0 +1,31 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..dd3d522 --- /dev/null +++ b/ceo/tui/views/GetGroupResponseView.py @@ -0,0 +1,23 @@ +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, + 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..5d0cb8d --- /dev/null +++ b/ceo/tui/views/GetPositionsView.py @@ -0,0 +1,31 @@ +from zope import component +import urwid + +from .ColumnView import ColumnView +from .position_names import position_names +from .utils import wrap_in_frame +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..ad07653 --- /dev/null +++ b/ceo/tui/views/GetUserView.py @@ -0,0 +1,17 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..91790d6 --- /dev/null +++ b/ceo/tui/views/PlainTextView.py @@ -0,0 +1,39 @@ +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', + 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)) + self.original_widget = wrap_in_frame( + urwid.Padding( + urwid.Filler( + urwid.Text('\n'.join(lines), align=align) + ), + 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..5476d2f --- /dev/null +++ b/ceo/tui/views/RemoveMemberFromGroupView.py @@ -0,0 +1,34 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..f34065b --- /dev/null +++ b/ceo/tui/views/RenewUserView.py @@ -0,0 +1,41 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..a660b5a --- /dev/null +++ b/ceo/tui/views/ResetDatabasePasswordView.py @@ -0,0 +1,31 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..f201228 --- /dev/null +++ b/ceo/tui/views/ResetPasswordView.py @@ -0,0 +1,17 @@ +import urwid + +from .ColumnView import ColumnView +from .utils import wrap_in_frame + + +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..8813fbf --- /dev/null +++ b/ceo/tui/views/SetPositionsView.py @@ -0,0 +1,42 @@ +from zope import component + +import urwid + +from .ColumnView import ColumnView +from .position_names import position_names +from .utils import wrap_in_frame +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..c3eea95 --- /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'*2, 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..1b3b8c4 --- /dev/null +++ b/ceo/tui/views/WelcomeView.py @@ -0,0 +1,35 @@ +import urwid + +import ceo.tui.utils as utils +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))) + extra_widgets = [ + urwid.Divider(), + urwid.Text('Press q or Esc to quit') + ] + self.set_rows( + rows, + right_col_weight=3, + extra_widgets=extra_widgets, + no_back_button=True, + no_next_button=True, + ) 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..a34e032 --- /dev/null +++ b/ceo/tui/views/utils.py @@ -0,0 +1,66 @@ +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 + 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..d1a5f90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -asciimatics==1.13.0 click==8.0.1 cryptography==35.0.0 Flask==2.0.1 @@ -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