Rewrite TUI #52

Merged
merenber merged 7 commits from tui-urwid into master 2022-05-22 14:09:48 -04:00
32 changed files with 0 additions and 1614 deletions
Showing only changes of commit ff8b56299b - Show all commits

View File

@ -1,160 +0,0 @@
from asciimatics.event import KeyboardEvent
from asciimatics.exceptions import NextScene, StopApplication
from asciimatics.screen import Screen
from asciimatics.widgets import Frame, Layout, Divider, Button, Label, \
PopUpDialog
class CeoFrame(Frame):
def __init__(
self,
screen,
height,
width,
model,
name,
on_load=None,
title=None,
escape_on_q=False, # whether to quit when 'q' is pressed
):
super().__init__(
screen,
height,
width,
name=name,
can_scroll=False,
title=title,
on_load=self._ceoframe_on_load,
)
self._custom_on_load = on_load
self._model = model
self._name = name
# If a view has a custom on_load function, all layouts should
# be created on load (*not* in the constructor)
self._has_dynamic_layouts = on_load is not None
self._quit_keys = [Screen.KEY_ESCAPE]
if escape_on_q:
self._quit_keys.append(ord('q'))
# child classes may override this as a last resort
self.skip_reload = False
def _ceoframe_on_load(self):
if self.skip_reload:
self.skip_reload = False
return
if self._model.title is not None:
self.title = self._model.title
self._model.title = None
if self._has_dynamic_layouts and self._model.nav_direction == 'forward':
# We arrive here after a user pressed 'Back' then 'Next',
# or after we returned to the Welcome screen.
# The data may have changed, so we need to redraw everything,
# via self._custom_on_load().
self.clear_layouts()
self._custom_on_load()
# may be overridden by child classes
def _ceoframe_on_reset(self):
"""
This is called whenever we return to the home screen
after some kind of operation was completed.
This is called from Model.reset().
"""
pass
def clear_layouts(self):
self._layouts.clear()
def force_update(self):
"""
This should be called by background threads after they make changes
to the UI.
"""
# Since we're running in a separate thread, we need to force the
# screen to update. See
# https://github.com/peterbrittain/asciimatics/issues/56
self.fix()
self._model.screen.force_update()
def add_buttons(
self, back_btn=False, back_btn_text='Back',
next_scene=None, next_btn_text='Next', on_next=None,
on_next_excl=None,
):
"""
Add a new layout at the bottom of the frame with buttons.
If back_btn is True, a Back button is added.
If next_scene is set to the name of the next scene, or on_next_excl
is set, a Next button will be added.
If on_next is set to a function, it will be called when the Next
button is pressed, and the screen will switch to the next scene.
If on_next_excl is set to a function, it will be called when the Next
button is pressed, and the scene will not be switched.
If both on_next and on_next_excl are set, on_next will be ignored.
"""
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Divider())
def _back():
self._model.nav_direction = 'backward'
last_scene = self._model.scene_stack.pop()
if last_scene == 'Welcome':
self._model.reset()
raise NextScene(last_scene)
def _next():
self._model.nav_direction = 'forward'
if on_next_excl is not None:
on_next_excl()
return
if on_next is not None:
on_next()
self.go_to_next_scene(next_scene)
layout = Layout([1, 1])
self.add_layout(layout)
if back_btn:
layout.add_widget(Button(back_btn_text, _back), 0)
if next_scene is not None or on_next_excl is not None:
layout.add_widget(Button(next_btn_text, _next), 1)
def go_to_next_scene(self, next_scene: str):
self._model.scene_stack.append(self._name)
raise NextScene(next_scene)
def add_flash_message_layout(self):
layout = Layout([100])
self.add_layout(layout)
self._status_label = Label('')
layout.add_widget(self._status_label)
def flash_message(self, msg: str, force_update: bool = False):
self._status_label.text = msg
if force_update:
self._model.screen.force_update()
self._model.screen.draw_next_frame()
def clear_flash_message(self, force_update: bool = False):
self.flash_message('', force_update)
def process_event(self, event):
if not isinstance(event, KeyboardEvent):
return super().process_event(event)
c = event.key_code
# Stop on 'q' or 'Esc'
if c in self._quit_keys:
self._scene.add_effect(PopUpDialog(
self.screen,
'Are you sure you want to quit?',
['Yes', 'No'],
has_shadow=True,
on_close=self._quit_on_yes,
))
return super().process_event(event)
@staticmethod
def _quit_on_yes(selected):
# Yes is the first button
if selected == 0:
raise StopApplication("User terminated app")

View File

@ -1,61 +0,0 @@
from asciimatics.widgets import Layout, Label
from .CeoFrame import CeoFrame
class ConfirmView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'Confirm',
on_load=self._confirmview_on_load, title='Confirmation',
escape_on_q=True,
)
def _add_line(self, text: str = ''):
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Label(text, align='^'))
def _add_pair(self, key: str, val: str):
layout = Layout([10, 1, 10])
self.add_layout(layout)
layout.add_widget(Label(key + ':', align='>'), 0)
layout.add_widget(Label(val, align='<'), 2)
def _confirmview_on_load(self):
for _ in range(2):
self._add_line()
for line in self._model.confirm_lines:
if isinstance(line, str):
self._add_line(line)
else:
# assume tuple
key, val = line
self._add_pair(key, val)
# fill the rest of the space
self.add_layout(Layout([100], fill_frame=True))
kwargs = {
'back_btn': True, 'back_btn_text': 'No', 'next_btn_text': 'Yes',
}
if self._model.operations is not None:
kwargs['next_scene'] = self._model.txn_view_name or 'Transaction'
else:
self.add_flash_message_layout()
kwargs['on_next_excl'] = self._next
self.add_buttons(**kwargs)
self.fix()
# OK so there's some weird bug somewhere which causes the buttons to be unselectable
# if we add a new user, return to the Welcome screen, then try to renew a user.
# This is a workaround for that.
self.skip_reload = True
self.reset()
def _next(self):
self.flash_message('Sending request...', force_update=True)
try:
self._model.resp = self._model.deferred_req()
finally:
self.clear_flash_message()
next_scene = self._model.result_view_name or 'Result'
self.go_to_next_scene(next_scene)

View File

@ -1,29 +0,0 @@
from asciimatics.exceptions import NextScene
from asciimatics.widgets import Layout, Label
from .CeoFrame import CeoFrame
class ErrorView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'Error',
on_load=self._errorview_on_load, title='Error',
)
def _errorview_on_load(self):
layout = Layout([1, 10], fill_frame=True)
self.add_layout(layout)
for _ in range(2):
layout.add_widget(Label(''), 1)
layout.add_widget(Label('An error occurred:'), 1)
layout.add_widget(Label(''), 1)
for line in self._model.error_message.splitlines():
layout.add_widget(Label(line), 1)
self.add_buttons(on_next_excl=self._next)
self.fix()
def _next(self):
self._model.reset()
raise NextScene('Welcome')

View File

@ -1,56 +0,0 @@
from copy import deepcopy
from zope import component
from ceo_common.interfaces import IConfig
class Model:
"""A convenient place to store View data persistently."""
def __init__(self):
cfg = component.getUtility(IConfig)
self.screen = None
self.views = []
self.title = None
self.scene_stack = []
self.result_view_name = None
self.txn_view_name = None
self.error_message = None
self.nav_direction = 'forward'
# View-specific data
self._initial_viewdata = {
'ResetPassword': {
'uid': '',
},
}
for pos in cfg.get('positions_available'):
self._initial_viewdata[pos] = ''
self.viewdata = deepcopy(self._initial_viewdata)
# data which is shared between multiple views
self.is_club_rep = False
self.confirm_lines = None
self.operations = None
self.deferred_req = None
self.resp = None
self.db_type = None
self.user_dict = None
def reset(self):
self.viewdata = deepcopy(self._initial_viewdata)
self.is_club_rep = False
self.confirm_lines = None
self.operations = None
self.deferred_req = None
self.resp = None
self.db_type = None
self.user_dict = None
self.title = None
self.error_message = None
self.scene_stack.clear()
self.result_view_name = None
self.txn_view_name = None
for view in self.views:
if hasattr(view, '_ceoframe_on_reset'):
view._ceoframe_on_reset()

View File

@ -1,62 +0,0 @@
from asciimatics.exceptions import NextScene
from asciimatics.widgets import Layout, Label
import requests
from .CeoFrame import CeoFrame
class ResultView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'Result',
on_load=self._resultview_on_load, title='Result',
escape_on_q=True,
)
# TODO: deduplicate this from ConfirmView
def _add_text(self, text: str = '\n', center: bool = False):
if center:
layout = Layout([100])
align = '^'
col = 0
else:
layout = Layout([1, 10])
align = '<'
col = 1
self.add_layout(layout)
for line in text.splitlines():
layout.add_widget(Label(line, align=align), col)
def _add_pair(self, key: str, val: str):
layout = Layout([10, 1, 10])
self.add_layout(layout)
if key:
layout.add_widget(Label(key + ':', align='>'), 0)
else:
layout.add_widget(Label(''), 0)
layout.add_widget(Label(val, align='<'), 2)
# override this method in child classes if desired
def show_result(self, resp: requests.Response):
self._add_text('The operation was successfully performed.', center=True)
def _resultview_on_load(self):
self._add_text()
resp = self._model.resp
if not resp.ok:
self._add_text('An error occurred:')
if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error']
else:
err_msg = resp.text.rstrip()
self._add_text(err_msg)
else:
self.show_result(resp)
# fill the rest of the space
self.add_layout(Layout([100], fill_frame=True))
self.add_buttons(on_next_excl=self._next)
self.fix()
def _next(self):
self._model.reset()
raise NextScene('Welcome')

View File

@ -1,93 +0,0 @@
from typing import Dict, Union
from asciimatics.widgets import Label, Layout
import requests
from .Model import Model
from ..StreamResponseHandler import StreamResponseHandler
class TUIStreamResponseHandler(StreamResponseHandler):
def __init__(
self,
model: Model,
labels: Dict[str, Label],
msg_layout: Layout,
txn_view, # TransactionView
):
super().__init__()
self.screen = model.screen
self.operations = model.operations
self.idx = 0
self.labels = labels
self.msg_layout = msg_layout
self.txn_view = txn_view
self.error_messages = []
def _update(self):
self.txn_view.force_update()
def _show_msg(self, msg: str = '\n'):
for line in msg.splitlines():
self.msg_layout.add_widget(Label(line, align='^'))
def _abort(self):
for operation in self.operations[self.idx:]:
self.labels[operation].text = 'ABORTED'
self.txn_view.enable_next_btn()
def handle_non_200(self, resp: requests.Response):
self._abort()
self._show_msg('An error occurred:')
if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error']
else:
err_msg = resp.text
self._show_msg(err_msg)
self._update()
def begin(self):
pass
def handle_aborted(self, err_msg: str):
self._abort()
self._show_msg('The transaction was rolled back.')
self._show_msg('The error was:\n')
self._show_msg(err_msg)
self._show_msg()
self._show_msg('Please check the ceod logs.')
self._update()
def handle_completed(self):
self._show_msg('Transaction successfully completed.')
if len(self.error_messages) > 0:
self._show_msg('There were some errors:')
for msg in self.error_messages:
self._show_msg(msg)
self.txn_view.enable_next_btn()
self._update()
def handle_successful_operation(self):
operation = self.operations[self.idx]
self.labels[operation].text = 'Done'
self.idx += 1
self._update()
def handle_failed_operation(self, err_msg: Union[str, None]):
operation = self.operations[self.idx]
self.labels[operation].text = 'Failed'
if err_msg is not None:
self.error_messages.append(err_msg)
self.idx += 1
self._update()
def handle_skipped_operation(self):
operation = self.operations[self.idx]
self.labels[operation].text = 'Skipped'
self.idx += 1
self._update()
def handle_unrecognized_operation(self, operation: str):
self.error_messages.append('Unrecognized operation: ' + operation)
self.idx += 1
self._update()

View File

@ -1,85 +0,0 @@
from threading import Thread
from typing import List, Dict
from asciimatics.exceptions import NextScene
from asciimatics.widgets import Layout, Button, Divider, Label
from ..operation_strings import descriptions as op_desc
from ..utils import generic_handle_stream_response
from .CeoFrame import CeoFrame
from .TUIStreamResponseHandler import TUIStreamResponseHandler
class TransactionView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'Transaction',
on_load=self._txnview_on_load, title='Running Transaction',
)
# map operation names to label widgets
self._labels = {}
def _add_buttons(self):
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Divider())
layout = Layout([1, 1])
self.add_layout(layout)
self._next_btn = Button('Next', self._next)
layout.add_widget(self._next_btn, 1)
def _add_blank_line(self):
self._op_layout.add_widget(Label(''), 0)
self._op_layout.add_widget(Label(''), 2)
def _txnview_on_load(self):
self._op_layout = Layout([12, 1, 10])
self.add_layout(self._op_layout)
# store the layouts so that we can re-use them when the screen
# gets resized
for _ in range(2):
self._add_blank_line()
for operation in self._model.operations:
desc = op_desc[operation]
self._op_layout.add_widget(Label(desc + '...', align='>'), 0)
desc_label = Label('', align='<')
self._op_layout.add_widget(desc_label, 2)
self._labels[operation] = desc_label
self._add_blank_line()
# this is the where success/failure messages etc. get placed
self._msg_layout = Layout([100])
self.add_layout(self._msg_layout)
# fill up the rest of the space
self.add_layout(Layout([100], fill_frame=True))
self._add_buttons()
self.fix()
Thread(target=self._do_txn).start()
def _do_txn(self):
resp = self._model.deferred_req()
handler = TUIStreamResponseHandler(
model=self._model,
labels=self._labels,
msg_layout=self._msg_layout,
txn_view=self,
)
data = generic_handle_stream_response(resp, self._model.operations, handler)
self.write_extra_txn_info(data)
# to be overridden in child classes if desired
def write_extra_txn_info(self, data: List[Dict]):
pass
def enable_next_btn(self):
self._next_btn.disabled = False
# If we don't reset, the button isn't selectable, even though we
# enabled it.
# We don't want to reload, though (which reset() will trigger).
self.skip_reload = True
self.reset()
def _next(self):
self._model.reset()
raise NextScene('Welcome')

View File

@ -1,107 +0,0 @@
from asciimatics.widgets import ListBox, Layout, Divider, Button, Label
from asciimatics.exceptions import NextScene, StopApplication
from .CeoFrame import CeoFrame
class WelcomeView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'Welcome',
title='CSC Electronic Office',
escape_on_q=True,
)
members_menu_items = [
('Add member', 'AddUser'),
('Add club rep', 'AddUser'),
('Renew member', 'RenewUser'),
('Renew club rep', 'RenewUser'),
('Get user info', 'GetUser'),
('Reset password', 'ResetPassword'),
('Change login shell', 'ChangeLoginShell'),
('Set forwarding addresses', 'SetForwardingAddresses'),
]
groups_menu_items = [
('Add group', 'AddGroup'),
('Get group members', 'GetGroup'),
('Add member to group', 'AddMemberToGroup'),
('Remove member from group', 'RemoveMemberFromGroup'),
]
db_menu_items = [
('Create MySQL database', 'CreateDatabase'),
('Reset MySQL password', 'ResetDatabasePassword'),
('Create PostgreSQL database', 'CreateDatabase'),
('Reset PostgreSQL password', 'ResetDatabasePassword'),
]
positions_menu_items = [
('Get positions', 'GetPositions'),
('Set positions', 'SetPositions'),
]
self.menu_items = [
('members', members_menu_items),
('groups', groups_menu_items),
('databases', db_menu_items),
('positions', positions_menu_items),
]
self.menu_items_dict = dict(self.menu_items)
flat_menu_items = [item for name, items in self.menu_items for item in items]
menu = ListBox(
len(flat_menu_items),
[
(desc, i) for i, (desc, view) in
enumerate(flat_menu_items)
],
name='menu',
on_select=self._menu_select,
)
labels = []
for name, items in self.menu_items:
labels.append(Label(name.capitalize(), align='>'))
for _ in range(len(items) - 1):
labels.append(Label(''))
layout = Layout([5, 1, 8], fill_frame=True)
self.add_layout(layout)
layout.add_widget(menu, 2)
for label in labels:
layout.add_widget(label, 0)
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Label('Press <TAB> to switch widgets'))
layout.add_widget(Divider())
layout = Layout([1, 1, 1])
self.add_layout(layout)
layout.add_widget(Button("Quit", self._quit), 2)
self.fix()
def _menu_select(self):
self.save()
item_id = self.data['menu']
# find which submenu the item belongs to
counter = 0
for name, items in self.menu_items:
if item_id < counter + len(items):
break
counter += len(items)
submenu_idx = item_id - counter
desc, view = items[submenu_idx]
if name == 'members':
if desc.endswith('club rep'):
self._model.is_club_rep = True
elif name == 'databases':
if 'MySQL' in desc:
self._model.db_type = 'mysql'
else:
self._model.db_type = 'postgresql'
self._welcomeview_go_to_next_scene(desc, view)
def _welcomeview_go_to_next_scene(self, desc, view):
self._model.title = desc
self._model.scene_stack.append('Welcome')
raise NextScene(view)
@staticmethod
def _quit():
raise StopApplication("User pressed quit")

View File

@ -1,34 +0,0 @@
import os
import requests
from zope import component
from ...utils import write_db_creds
from ..ResultView import ResultView
from ceo_common.interfaces import IConfig
class CreateDatabaseResultView(ResultView):
def show_result(self, resp: requests.Response):
password = resp.json()['password']
db_type = self._model.db_type
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
db_host = component.getUtility(IConfig).get(f'{db_type}_host')
user_dict = self._model.user_dict
username = user_dict['uid']
filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, user_dict, password, db_type, db_host)
self._add_text(f'{db_type_name} database created.', center=True)
self._add_text()
self._add_text((f'''Connection Information:
Database: {username}
Username: {username}
Password: {password}
Host: {db_host}'''))
self._add_text()
if wrote_to_file:
self._add_text(f"These settings have been written to {filename}.")
else:
self._add_text(f"We were unable to write these settings to {filename}.")

View File

@ -1,47 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_post, http_get, defer
from ..CeoFrame import CeoFrame
class CreateDatabaseView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'CreateDatabase',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text("Username:", "uid")
layout.add_widget(self._username)
self.add_buttons(
back_btn=True, next_scene='Confirm',
on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
def _target(self):
username = self._username.value
db_type = self._model.db_type
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
user_dict = resp.json()
self._model.user_dict = user_dict
return http_post(f'/api/db/{db_type}/{username}')
def _next(self):
username = self._username.value
if not username:
return
if self._model.db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
self._model.confirm_lines = [
f'Are you sure you want to create a {db_type_name} database for {username}?',
]
self._model.deferred_req = defer(self._target)
self._model.result_view_name = 'CreateDatabaseResult'

View File

@ -1,29 +0,0 @@
import os
import requests
from zope import component
from ...utils import write_db_creds
from ..ResultView import ResultView
from ceo_common.interfaces import IConfig
class ResetDatabasePasswordResultView(ResultView):
def show_result(self, resp: requests.Response):
password = resp.json()['password']
db_type = self._model.db_type
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
db_host = component.getUtility(IConfig).get(f'{db_type}_host')
user_dict = self._model.user_dict
username = user_dict['uid']
filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, user_dict, password, db_type, db_host)
self._add_text(f'The new {db_type_name} password for {username} is:')
self._add_text()
self._add_text(password)
self._add_text()
if wrote_to_file:
self._add_text(f"The settings in {filename} have been updated.")
else:
self._add_text(f"We were unable to update the settings in {filename}.")

View File

@ -1,47 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_post, http_get, defer
from ..CeoFrame import CeoFrame
class ResetDatabasePasswordView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'ResetDatabasePassword',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text("Username:", "uid")
layout.add_widget(self._username)
self.add_buttons(
back_btn=True, next_scene='Confirm',
on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
def _target(self):
username = self._username.value
db_type = self._model.db_type
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
user_dict = resp.json()
self._model.user_dict = user_dict
return http_post(f'/api/db/{db_type}/{username}/pwreset')
def _next(self):
username = self._username.value
if not username:
return
if self._model.db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
self._model.confirm_lines = [
f'Are you sure you want to reset the {db_type_name} password for {username}?',
]
self._model.deferred_req = defer(self._target)
self._model.result_view_name = 'ResetDatabasePasswordResult'

View File

@ -1 +0,0 @@

View File

@ -1,46 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...utils import defer, http_post
from ..CeoFrame import CeoFrame
from ceod.transactions.groups import AddGroupTransaction
class AddGroupView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'AddGroup',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._cn = Text('Name:', 'cn')
layout.add_widget(self._cn)
self._description = Text('Description:', 'description')
layout.add_widget(self._description)
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._cn.value = None
self._description.value = None
def _next(self):
cn = self._cn.value
description = self._description.value
body = {
'cn': cn,
'description': description,
}
self._model.confirm_lines = [
'The following group will be created:',
'',
('cn', cn),
('description', description),
'',
'Are you sure you want to continue?',
]
self._model.deferred_req = defer(http_post, '/api/groups', json=body)
self._model.operations = AddGroupTransaction.operations

View File

@ -1,49 +0,0 @@
from asciimatics.widgets import Layout, Text, CheckBox, Label
from ...utils import defer, http_post
from ..CeoFrame import CeoFrame
from ceod.transactions.groups import AddMemberToGroupTransaction
class AddMemberToGroupView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'AddMemberToGroup',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._cn = Text('Group name:', 'cn')
layout.add_widget(self._cn)
self._username = Text('Username:', 'uid')
layout.add_widget(self._username)
layout.add_widget(Label(''))
self._checkbox = CheckBox(
'subscribe to auxiliary mailing lists', name='subscribe')
self._checkbox.value = True
layout.add_widget(self._checkbox)
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._cn.value = None
self._username.value = None
self._checkbox.value = True
def _next(self):
cn = self._cn.value
uid = self._username.value
self._model.confirm_lines = [
f'Are you sure you want to add {uid} to {cn}?',
]
operations = AddMemberToGroupTransaction.operations
url = f'/api/groups/{cn}/members/{uid}'
# TODO: deduplicate this logic from the CLI
if not self._checkbox.value:
url += '?subscribe_to_lists=false'
operations.remove('subscribe_user_to_auxiliary_mailing_lists')
self._model.deferred_req = defer(http_post, url)
self._model.operations = operations

View File

@ -1,18 +0,0 @@
import requests
from ..ResultView import ResultView
class GetGroupResultView(ResultView):
def show_result(self, resp: requests.Response):
d = resp.json()
if 'description' in d:
desc = d['description'] + ' (' + d['cn'] + ')'
else:
desc = d['cn']
self._add_text('Members of ' + desc, center=True)
self._add_text()
for member in d['members']:
self._add_text(
member['cn'] + ' (' + member['uid'] + ')',
center=True)

View File

@ -1,33 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_get
from ..CeoFrame import CeoFrame
class GetGroupView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'GetGroup',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._cn = Text("Group name", "cn")
layout.add_widget(self._cn)
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='GetGroupResult', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._cn.value = None
def _next(self):
cn = self._cn.value
self.flash_message('Looking up group...', force_update=True)
try:
self._model.resp = http_get(f'/api/groups/{cn}')
finally:
self.clear_flash_message()

View File

@ -1,48 +0,0 @@
from asciimatics.widgets import Layout, Text, CheckBox, Label
from ...utils import defer, http_delete
from ..CeoFrame import CeoFrame
from ceod.transactions.groups import RemoveMemberFromGroupTransaction
class RemoveMemberFromGroupView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'RemoveMemberFromGroup',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._cn = Text('Group name:', 'cn')
layout.add_widget(self._cn)
self._username = Text('Username:', 'uid')
layout.add_widget(self._username)
layout.add_widget(Label(''))
self._checkbox = CheckBox(
'unsubscribe from auxiliary mailing lists', name='unsubscribe')
self._checkbox.value = True
layout.add_widget(self._checkbox)
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._cn.value = None
self._username.value = None
def _next(self):
cn = self._cn.value
uid = self._username.value
self._model.confirm_lines = [
f'Are you sure you want to remove {uid} from {cn}?',
]
operations = RemoveMemberFromGroupTransaction.operations
url = f'/api/groups/{cn}/members/{uid}'
# TODO: deduplicate this logic from the CLI
if not self._checkbox.value:
url += '?unsubscribe_from_lists=false'
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists')
self._model.deferred_req = defer(http_delete, url)
self._model.operations = operations

View File

@ -1,25 +0,0 @@
from typing import List, Dict
from asciimatics.widgets import Label
from ...utils import get_failed_operations
from ..TransactionView import TransactionView
class AddUserTransactionView(TransactionView):
def _show_msg(self, msg: str = '\n'):
for line in msg.splitlines():
self._msg_layout.add_widget(Label(line, align='^'))
def write_extra_txn_info(self, data: List[Dict]):
if data[-1]['status'] != 'completed':
return
result = data[-1]['result']
failed_operations = get_failed_operations(data)
self._show_msg()
self._show_msg('User password is: ' + result['password'])
if 'send_welcome_message' in failed_operations:
self._show_msg()
self._show_msg('Since the welcome message was not sent,')
self._show_msg('you need to email this password to the user.')
self.force_update()

View File

@ -1,116 +0,0 @@
from threading import Thread
from asciimatics.widgets import Layout, Text
from ...term_utils import get_terms_for_new_user
from ...utils import http_get, http_post, defer, user_dict_kv, \
get_adduser_operations
from ..CeoFrame import CeoFrame
class AddUserView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'AddUser',
)
self._username_changed = False
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text(
"Username:", "uid",
on_change=self._on_username_change,
on_blur=self._on_username_blur,
)
layout.add_widget(self._username)
self._full_name = Text("Full name:", "cn")
layout.add_widget(self._full_name)
self._first_name = Text("First name:", "given_name")
layout.add_widget(self._first_name)
self._last_name = Text("Last name:", "sn")
layout.add_widget(self._last_name)
self._program = Text("Program:", "program")
layout.add_widget(self._program)
self._forwarding_address = Text("Forwarding address:", "forwarding_address")
layout.add_widget(self._forwarding_address)
self._num_terms = Text(
"Number of terms:", "num_terms",
validator=lambda s: s.isdigit() and s[0] != '0')
self._num_terms.value = '1'
layout.add_widget(self._num_terms)
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
self._full_name.value = None
self._program.value = None
self._forwarding_address.value = None
self._num_terms.value = '1'
def _on_username_change(self):
self._username_changed = True
def _on_username_blur(self):
if not self._username_changed:
return
self._username_changed = False
username = self._username.value
if username == '':
return
Thread(target=self._get_uwldap_info, args=[username]).start()
def _get_uwldap_info(self, username):
self.flash_message('Looking up user...')
try:
resp = http_get('/api/uwldap/' + username)
if resp.status_code != 200:
return
data = resp.json()
self._status_label.text = ''
if data.get('cn'):
self._full_name.value = data['cn']
if data.get('given_name'):
self._first_name.value = data['given_name']
if data.get('sn'):
self._last_name.value = data['sn']
if data.get('program'):
self._program.value = data.get('program', '')
if data.get('mail_local_addresses'):
self._forwarding_address.value = data['mail_local_addresses'][0]
finally:
self.clear_flash_message()
def _next(self):
body = {
'uid': self._username.value,
'cn': self._full_name.value,
'given_name': self._first_name.value,
'sn': self._last_name.value,
}
if self._program.value:
body['program'] = self._program.value
if self._forwarding_address.value:
body['forwarding_addresses'] = [self._forwarding_address.value]
new_terms = get_terms_for_new_user(int(self._num_terms.value))
if self._model.is_club_rep:
body['non_member_terms'] = new_terms
else:
body['terms'] = new_terms
pairs = user_dict_kv(body)
self._model.confirm_lines = [
'The following user will be created:',
'',
] + pairs + [
'',
'Are you sure you want to continue?',
]
self._model.deferred_req = defer(http_post, '/api/members', json=body)
self._model.operations = get_adduser_operations(body)
self._model.txn_view_name = 'AddUserTransaction'

View File

@ -1,75 +0,0 @@
from threading import Thread
from asciimatics.widgets import Layout, Text
from ...utils import defer, http_patch, http_get
from ..CeoFrame import CeoFrame
class ChangeLoginShellView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'ChangeLoginShell',
)
self._username_changed = False
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text(
"Username:", "uid",
on_change=self._on_username_change,
on_blur=self._on_username_blur,
)
layout.add_widget(self._username)
self._login_shell = Text('Login shell:', 'login_shell')
layout.add_widget(self._login_shell)
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
self._login_shell.value = None
# TODO: deduplicate this from AddUserView
def _on_username_change(self):
self._username_changed = True
def _on_username_blur(self):
if not self._username_changed:
return
self._username_changed = False
username = self._username.value
if username == '':
return
Thread(target=self._get_user_info, args=[username]).start()
def _get_user_info(self, username):
self.flash_message('Looking up user...')
try:
resp = http_get('/api/members/' + username)
if resp.status_code != 200:
return
data = resp.json()
self._login_shell.value = data['login_shell']
finally:
self.clear_flash_message()
def _next(self):
uid = self._username.value
login_shell = self._login_shell.value
body = {'login_shell': login_shell}
self._model.deferred_req = defer(
http_patch, f'/api/members/{uid}', json=body)
self._model.confirm_lines = [
f"{uid}'s login shell will be changed to:",
'',
login_shell,
'',
'Are you sure you want to continue?',
]
self._model.operations = ['replace_login_shell']

View File

@ -1,11 +0,0 @@
import requests
from ...utils import user_dict_kv
from ..ResultView import ResultView
class GetUserResultView(ResultView):
def show_result(self, resp: requests.Response):
pairs = user_dict_kv(resp.json())
for key, val in pairs:
self._add_pair(key, val)

View File

@ -1,33 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_get
from ..CeoFrame import CeoFrame
class GetUserView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'GetUser',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text("Username:", "uid")
layout.add_widget(self._username)
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='GetUserResult', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
def _next(self):
uid = self._username.value
self.flash_message('Looking up user...', force_update=True)
try:
self._model.resp = http_get(f'/api/members/{uid}')
finally:
self.clear_flash_message()

View File

@ -1,65 +0,0 @@
from asciimatics.widgets import Layout, Text
from ...term_utils import get_terms_for_renewal
from ...utils import http_post, defer
from ..CeoFrame import CeoFrame
class RenewUserView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'RenewUser',
)
self._model = model
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text("Username:", "uid")
layout.add_widget(self._username)
self._num_terms = Text(
"Number of terms:", "num_terms",
validator=lambda s: s.isdigit() and s[0] != '0')
self._num_terms.value = '1'
layout.add_widget(self._num_terms)
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
self._num_terms.value = '1'
def _next(self):
uid = self._username.value
self.flash_message('Looking up user...', force_update=True)
try:
new_terms = get_terms_for_renewal(
uid,
int(self._num_terms.value),
self._model.is_club_rep,
self._model,
)
finally:
self.clear_flash_message()
body = {'uid': uid}
if self._model.is_club_rep:
body['non_member_terms'] = new_terms
terms_str = 'non-member terms'
else:
body['terms'] = new_terms
terms_str = 'member terms'
self._model.confirm_lines = [
'The following ' + terms_str + ' will be added:',
'',
','.join(new_terms),
'',
'Are you sure you want to continue?',
]
self._model.deferred_req = defer(
http_post, f'/api/members/{uid}/renew', json=body)

View File

@ -1,12 +0,0 @@
from ..ResultView import ResultView
import requests
class ResetPasswordResultView(ResultView):
def show_result(self, resp: requests.Response):
result = resp.json()
uid = self._model.viewdata['ResetPassword']['uid']
self._add_text(f'The new password for {uid} is:', center=True)
self._add_text()
self._add_text(result['password'], center=True)

View File

@ -1,34 +0,0 @@
from asciimatics.widgets import Layout, Text, Label
from ...utils import defer, http_post
from ..CeoFrame import CeoFrame
class ResetPasswordView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'ResetPassword',
)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
layout.add_widget(Label('Enter the username of the user whose password will be reset:'))
self._username = Text(None, "uid")
layout.add_widget(self._username)
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
def _next(self):
uid = self._username.value
self._model.viewdata['ResetPassword']['uid'] = uid
self._model.confirm_lines = [
f"Are you sure you want to reset {uid}'s password?",
]
self._model.deferred_req = defer(http_post, f'/api/members/{uid}/pwreset')
self._model.result_view_name = 'ResetPasswordResult'

View File

@ -1,80 +0,0 @@
from threading import Thread
from asciimatics.widgets import Layout, Label, Text, TextBox, Widget
from ...utils import defer, http_patch, http_get
from ..CeoFrame import CeoFrame
class SetForwardingAddressesView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'SetForwardingAddresses',
)
self._username_changed = False
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._username = Text(
"Username:", "uid",
on_change=self._on_username_change,
on_blur=self._on_username_blur,
)
layout.add_widget(self._username)
self._forwarding_addresses = TextBox(
Widget.FILL_FRAME, 'Forwarding addresses:', 'forwarding_addresses',
line_wrap=True)
layout.add_widget(self._forwarding_addresses)
layout.add_widget(Label('Press <TAB> to switch widgets'))
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self._username.value = None
self._forwarding_addresses.value = None
# TODO: deduplicate this from AddUserView
def _on_username_change(self):
self._username_changed = True
def _on_username_blur(self):
if not self._username_changed:
return
self._username_changed = False
username = self._username.value
if username == '':
return
Thread(target=self._get_user_info, args=[username]).start()
def _get_user_info(self, username):
self.flash_message('Looking up user...')
try:
resp = http_get('/api/members/' + username)
if resp.status_code != 200:
return
data = resp.json()
if 'forwarding_addresses' not in data:
return
self._forwarding_addresses.value = data['forwarding_addresses']
finally:
self.clear_flash_message()
def _next(self):
uid = self._username.value
forwarding_addresses = self._forwarding_addresses.value
body = {'forwarding_addresses': forwarding_addresses}
self._model.deferred_req = defer(
http_patch, f'/api/members/{uid}', json=body)
self._model.confirm_lines = [
f"{uid}'s forwarding addresses will be set to:",
'',
*forwarding_addresses,
'',
'Are you sure you want to continue?',
]
self._model.operations = ['replace_forwarding_addresses']

View File

@ -1,80 +0,0 @@
from threading import Thread
from asciimatics.widgets import Layout, Label
from zope import component
from ...utils import http_get
from ..CeoFrame import CeoFrame
from ceo_common.interfaces import IConfig
position_names = {
'president': "President",
'vice-president': "Vice President",
'treasurer': "Treasurer",
'secretary': "Secretary",
'sysadmin': "Sysadmin",
'cro': "Chief Returning Officer",
'librarian': "Librarian",
'imapd': "IMAPD",
'webmaster': "Web Master",
'offsck': "Office Manager",
}
class GetPositionsView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'GetPositions',
escape_on_q=True,
on_load=self._on_load
)
self._position_widgets = {}
def _on_load(self):
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Label(''))
self._main_layout = Layout([10, 1, 10], fill_frame=True)
self.add_layout(self._main_layout)
for pos in avail:
self._position_widgets[pos] = self._add_pair(position_names[pos], '')
self.add_flash_message_layout()
self.add_buttons(back_btn=True)
self.fix()
def target():
self.flash_message('Looking up positions...')
try:
resp = http_get('/api/positions')
if not resp.ok:
return
positions = resp.json()
for pos, username in positions.items():
self._position_widgets[pos].text = username
finally:
self.clear_flash_message(force_update=True)
Thread(target=target).start()
def _add_blank_line(self):
self._main_layout.add_widget(Label(' ', 0))
self._main_layout.add_widget(Label(' ', 2))
def _add_pair(self, key: str, val: str):
key_widget = Label(key + ':', align='>')
value_widget = Label(val, align='<')
self._main_layout.add_widget(key_widget, 0)
self._main_layout.add_widget(value_widget, 2)
return value_widget
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
# clear the labels
for widget in self._position_widgets.values():
widget.text = ''

View File

@ -1,76 +0,0 @@
from threading import Thread
from asciimatics.widgets import Layout, Label, Text
from zope import component
from ...utils import defer, http_post, http_get
from ..CeoFrame import CeoFrame
from .GetPositionsView import position_names
from ceo_common.interfaces import IConfig
from ceod.transactions.members.UpdateMemberPositionsTransaction import UpdateMemberPositionsTransaction
class SetPositionsView(CeoFrame):
"""
Reset the positions to the currently set positions
"""
def reset_positions(self):
def target():
self.flash_message('Looking up positions...')
try:
resp = http_get('/api/positions')
if not resp.ok:
return
positions = resp.json()
for pos, username in positions.items():
self._widgets[pos].value = username
finally:
self.clear_flash_message(force_update=True)
Thread(target=target).start()
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'SetPositions',
)
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
required = cfg.get('positions_required')
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
self._widgets = {}
for pos in avail:
suffix = ' (*)' if pos in required else ''
widget = Text(position_names[pos] + suffix, pos)
self._widgets[pos] = widget
layout.add_widget(widget)
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Label('(*) Required'))
self.add_flash_message_layout()
self.add_buttons(
back_btn=True,
next_scene='Confirm', on_next=self._next)
self.reset_positions()
self.fix()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
self.reset_positions()
def _next(self):
self.save()
body = {pos: username for pos, username in self.data.items() if username}
self._model.deferred_req = defer(http_post, '/api/positions', json=body)
self._model.operations = UpdateMemberPositionsTransaction.operations
self._model.confirm_lines = [
"The positions will be updated as follows:",
'',
*self.data.items(),
'',
'Are you sure you want to continue?',
]

View File

@ -1,2 +0,0 @@
from .GetPositionsView import GetPositionsView
from .SetPositionsView import SetPositionsView