rewrite TUI using urwid

This commit is contained in:
Max Erenberg 2022-05-16 02:49:49 -04:00
parent 19496b4568
commit 2bda75d905
83 changed files with 2331 additions and 115 deletions

View File

@ -28,13 +28,16 @@ POSTGRES_DIR=/etc/postgresql/11/main
cat <<EOF > $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

View File

@ -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:

30
ceo/tui/app.py Normal file
View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
from .Controller import Controller
class WelcomeController(Controller):
def __init__(self, model, app):
super().__init__(model, app)

View File

@ -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

View File

@ -0,0 +1,7 @@
class AddGroupModel:
name = 'AddGroup'
title = 'Add group'
def __init__(self):
self.name = ''
self.description = ''

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,8 @@
class ChangeLoginShellModel:
name = 'ChangeLoginShell'
title = 'Change login shell'
def __init__(self):
self.username = ''
self.login_shell = ''
self.resp_json = None

View File

@ -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

View File

@ -0,0 +1,7 @@
class GetGroupModel:
name = 'GetGroup'
title = 'Get group members'
def __init__(self):
self.name = ''
self.resp_json = None

View File

@ -0,0 +1,4 @@
class GetPositionsModel:
name = 'GetPositions'
title = 'Get positions'
positions = {}

View File

@ -0,0 +1,7 @@
class GetUserModel:
name = 'GetUser'
title = 'Get user info'
def __init__(self):
self.username = ''
self.resp_json = None

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,7 @@
class ResetPasswordModel:
name = 'ResetPassword'
title = 'Reset password'
def __init__(self):
self.username = ''
self.resp_json = None

View File

@ -0,0 +1,4 @@
class SetPositionsModel:
name = 'SetPositions'
title = 'Set positions'
positions = {}

View File

@ -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

View File

@ -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,
],
}

View File

@ -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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')

View File

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

View File

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

View File

@ -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

View File

@ -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')
)

View File

@ -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,
)

View File

@ -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,
)

View File

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

View File

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

View File

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

View File

@ -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'),
)

View File

@ -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'),
)

View File

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

View File

@ -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])

View File

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

View File

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

View File

@ -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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
)

View File

@ -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'),
)

View File

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

View File

@ -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]

View File

@ -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'),
)

View File

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

48
ceo/tui/views/View.py Normal file
View File

@ -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

View File

@ -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,
)

37
ceo/tui/views/__init__.py Normal file
View File

@ -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

View File

@ -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",
}

66
ceo/tui/views/utils.py Normal file
View File

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

View File

@ -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

19
debian/control vendored
View File

@ -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

View File

@ -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