Positions TUI (#20)
continuous-integration/drone/push Build is passing Details

Closes #17

Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <>
Reviewed-on: #20
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
This commit is contained in:
Rio Liu 2021-09-26 15:23:47 -04:00 committed by Max Erenberg
parent 2a5d903eba
commit 7edc01e42b
7 changed files with 164 additions and 12 deletions

View File

@ -32,7 +32,6 @@ class CeoFrame(Frame):
self._extra_on_load = on_load self._extra_on_load = on_load
self._model = model self._model = model
self._name = name self._name = name
self._loaded = False
self._has_dynamic_layouts = has_dynamic_layouts self._has_dynamic_layouts = has_dynamic_layouts
self._quit_keys = [Screen.KEY_ESCAPE] self._quit_keys = [Screen.KEY_ESCAPE]
if escape_on_q: if escape_on_q:
@ -40,13 +39,19 @@ class CeoFrame(Frame):
# sanity check # sanity check
if save_data: if save_data:
assert name in model.viewdata assert name in model.viewdata
# child classes may override this as a last resort
self.do_not_reload = False
def _ceoframe_on_load(self): def _ceoframe_on_load(self):
# We usually don't want _on_load() to be called multiple times if self.do_not_reload:
# e.g. when switching back to a scene, or after calling reset() self.do_not_reload = False
if self._loaded:
return return
self._loaded = True self.do_not_reload = True
if self._has_dynamic_layouts:
# We arrive here after a user pressed 'Back' then 'Next'.
# The data may have changed, so we need to redraw everything,
# via self._extra_on_load().
self.clear_layouts()
if self._model.title is not None: if self._model.title is not None:
self.title = self._model.title self.title = self._model.title
self._model.title = None self._model.title = None
@ -76,7 +81,7 @@ class CeoFrame(Frame):
""" """
# We want a fresh slate once we return to the home screen, so we # We want a fresh slate once we return to the home screen, so we
# want on_load() to be called for the scenes. # want on_load() to be called for the scenes.
self._loaded = False self.do_not_reload = False
if self._has_dynamic_layouts: if self._has_dynamic_layouts:
# We don't want layouts to accumulate. # We don't want layouts to accumulate.
self.clear_layouts() self.clear_layouts()
@ -157,8 +162,8 @@ class CeoFrame(Frame):
self._model.screen.force_update() self._model.screen.force_update()
self._model.screen.draw_next_frame() self._model.screen.draw_next_frame()
def clear_flash_message(self): def clear_flash_message(self, force_update: bool = False):
self.flash_message('') self.flash_message('', force_update)
def process_event(self, event): def process_event(self, event):
if not isinstance(event, KeyboardEvent): if not isinstance(event, KeyboardEvent):

View File

@ -1,10 +1,16 @@
from copy import deepcopy from copy import deepcopy
from zope import component
from ceo_common.interfaces import IConfig
class Model: class Model:
"""A convenient place to store View data persistently.""" """A convenient place to store View data persistently."""
def __init__(self): def __init__(self):
cfg = component.getUtility(IConfig)
self.screen = None self.screen = None
self.views = [] self.views = []
self.title = None self.title = None
@ -12,7 +18,9 @@ class Model:
self.result_view_name = None self.result_view_name = None
self.txn_view_name = None self.txn_view_name = None
self.error_message = None self.error_message = None
# view-specific data, to be used when e.g. resizing the window # View-specific data, to be used when e.g. resizing the window.
# For the views where save_data=True was passed to the CeoFrame
# constructor, the keys correspond to the names of text fields.
self._initial_viewdata = { self._initial_viewdata = {
'AddUser': { 'AddUser': {
'uid': '', 'uid': '',
@ -68,7 +76,11 @@ class Model:
'ResetDatabasePassword': { 'ResetDatabasePassword': {
'uid': '', 'uid': '',
}, },
# This one needs to be filled in dynamically
'SetPositions': {},
} }
for pos in cfg.get('positions_available'):
self._initial_viewdata[pos] = ''
self.viewdata = deepcopy(self._initial_viewdata) self.viewdata = deepcopy(self._initial_viewdata)
# data which is shared between multiple views # data which is shared between multiple views
self.is_club_rep = False self.is_club_rep = False

View File

@ -97,7 +97,9 @@ class TransactionView(CeoFrame):
def enable_next_btn(self): def enable_next_btn(self):
self._next_btn.disabled = False self._next_btn.disabled = False
# If we don't reset, the button isn't selectable, even though we # If we don't reset, the button isn't selectable, even though we
# enabled it # enabled it.
# We don't want to reload, though (which reset() will trigger).
self.do_not_reload = True
self.reset() self.reset()
# save the fact that the transaction is completed # save the fact that the transaction is completed
self._model.viewdata['Transaction']['status'] = 'completed' self._model.viewdata['Transaction']['status'] = 'completed'

View File

@ -0,0 +1,79 @@
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)
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)
self._position_widgets = {}
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 _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 _on_load(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._position_widgets[pos].text = username
finally:
self.clear_flash_message(force_update=True)
Thread(target=target).start()
def _ceoframe_on_reset(self):
super()._ceoframe_on_reset()
# clear the labels
for widget in self._position_widgets.values():
widget.text = ''

View File

@ -0,0 +1,49 @@
from asciimatics.widgets import Layout, Label, Text
from zope import component
from ...utils import defer, http_post
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):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'SetPositions',
save_data=True)
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
required = cfg.get('positions_required')
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
for pos in avail:
suffix = ' (*)' if pos in required else ''
widget = Text(position_names[pos] + suffix, pos)
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.fix()
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

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

View File

@ -28,6 +28,7 @@ from .members.RenewUserView import RenewUserView
from .members.ResetPasswordView import ResetPasswordView from .members.ResetPasswordView import ResetPasswordView
from .members.ResetPasswordResultView import ResetPasswordResultView from .members.ResetPasswordResultView import ResetPasswordResultView
from .members.SetForwardingAddressesView import SetForwardingAddressesView from .members.SetForwardingAddressesView import SetForwardingAddressesView
from .positions import GetPositionsView, SetPositionsView
# tuples of (name, view) # tuples of (name, view)
@ -38,8 +39,8 @@ def screen_wrapper(screen, last_scene, model):
global views global views
# unload the old views # unload the old views
for name, view in views: for name, view in views:
if hasattr(view, '_on_ceoframe_unload'): if hasattr(view, '_ceoframe_on_unload'):
view._on_ceoframe_unload() view._ceoframe_on_unload()
width = min(screen.width, 90) width = min(screen.width, 90)
height = min(screen.height, 24) height = min(screen.height, 24)
views = [ views = [
@ -66,6 +67,8 @@ def screen_wrapper(screen, last_scene, model):
('CreateDatabaseResult', CreateDatabaseResultView(screen, width, height, model)), ('CreateDatabaseResult', CreateDatabaseResultView(screen, width, height, model)),
('ResetDatabasePassword', ResetDatabasePasswordView(screen, width, height, model)), ('ResetDatabasePassword', ResetDatabasePasswordView(screen, width, height, model)),
('ResetDatabasePasswordResult', ResetDatabasePasswordResultView(screen, width, height, model)), ('ResetDatabasePasswordResult', ResetDatabasePasswordResultView(screen, width, height, model)),
('GetPositions', GetPositionsView(screen, width, height, model)),
('SetPositions', SetPositionsView(screen, width, height, model)),
] ]
scenes = [ scenes = [
Scene([view], -1, name=name) for name, view in views Scene([view], -1, name=name) for name, view in views