Merge branch 'v1' of https://git.csclub.uwaterloo.ca/public/pyceo into db-cli
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Andrew Wang 2021-09-08 20:44:25 -04:00
commit 300688cb9a
39 changed files with 1267 additions and 220 deletions

View File

@ -13,6 +13,9 @@ from ceo_common.model import Config, HTTPClient
def register_services(): def register_services():
# Using base component directly so events get triggered
baseComponent = component.getGlobalSiteManager()
# Config # Config
# This is a hack to determine if we're in the dev env or not # This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'): if socket.getfqdn().endswith('.csclub.internal'):
@ -21,11 +24,11 @@ def register_services():
else: else:
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
cfg = Config(config_file) cfg = Config(config_file)
component.provideUtility(cfg, IConfig) baseComponent.registerUtility(cfg, IConfig)
# HTTPService # HTTPService
http_client = HTTPClient() http_client = HTTPClient()
component.provideUtility(http_client, IHTTPClient) baseComponent.registerUtility(http_client, IHTTPClient)
def main(): def main():

View File

@ -1,3 +1,4 @@
import sys
from typing import List, Union from typing import List, Union
import click import click
@ -20,6 +21,7 @@ class Abort(click.ClickException):
class CLIStreamResponseHandler(StreamResponseHandler): class CLIStreamResponseHandler(StreamResponseHandler):
def __init__(self, operations: List[str]): def __init__(self, operations: List[str]):
super().__init__()
self.operations = operations self.operations = operations
self.idx = 0 self.idx = 0
@ -36,6 +38,7 @@ class CLIStreamResponseHandler(StreamResponseHandler):
click.echo('The transaction was rolled back.') click.echo('The transaction was rolled back.')
click.echo('The error was: ' + err_msg) click.echo('The error was: ' + err_msg)
click.echo('Please check the ceod logs.') click.echo('Please check the ceod logs.')
sys.exit(1)
def handle_completed(self): def handle_completed(self):
click.echo('Transaction successfully completed.') click.echo('Transaction successfully completed.')

View File

@ -2,6 +2,7 @@ import click
from .members import members from .members import members
from .groups import groups from .groups import groups
from .positions import positions
from .updateprograms import updateprograms from .updateprograms import updateprograms
from .mysql import mysql from .mysql import mysql
from .postgresql import postgresql from .postgresql import postgresql
@ -14,6 +15,7 @@ def cli():
cli.add_command(members) cli.add_command(members)
cli.add_command(groups) cli.add_command(groups)
cli.add_command(positions)
cli.add_command(updateprograms) cli.add_command(updateprograms)
cli.add_command(mysql) cli.add_command(mysql)
cli.add_command(postgresql) cli.add_command(postgresql)

View File

@ -4,13 +4,12 @@ from typing import Dict
import click import click
from zope import component from zope import component
from ..term_utils import get_terms_for_new_user, get_terms_for_renewal
from ..utils import http_post, http_get, http_patch, http_delete, \ from ..utils import http_post, http_get, http_patch, http_delete, \
get_failed_operations, get_terms_for_new_user, user_dict_lines, \ get_failed_operations, user_dict_lines, get_adduser_operations
get_adduser_operations
from .utils import handle_stream_response, handle_sync_response, print_lines, \ from .utils import handle_stream_response, handle_sync_response, print_lines, \
check_if_in_development check_if_in_development
from ceo_common.interfaces import IConfig from ceo_common.interfaces import IConfig
from ceo_common.model import Term
from ceod.transactions.members import DeleteMemberTransaction from ceod.transactions.members import DeleteMemberTransaction
@ -24,7 +23,7 @@ def members():
@click.option('--cn', help='Full name', prompt='Full name') @click.option('--cn', help='Full name', prompt='Full name')
@click.option('--program', required=False, help='Academic program') @click.option('--program', required=False, help='Academic program')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100), @click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', prompt='Number of terms') help='Number of terms to add', default=1)
@click.option('--clubrep', is_flag=True, default=False, @click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms') help='Add non-member terms instead of member terms')
@click.option('--forwarding-address', required=False, @click.option('--forwarding-address', required=False,
@ -133,22 +132,7 @@ def modify(username, login_shell, forwarding_addresses):
@click.option('--clubrep', is_flag=True, default=False, @click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms') help='Add non-member terms instead of member terms')
def renew(username, num_terms, clubrep): def renew(username, num_terms, clubrep):
resp = http_get('/api/members/' + username) terms = get_terms_for_renewal(username, num_terms, clubrep)
result = handle_sync_response(resp)
max_term = None
current_term = Term.current()
if clubrep and 'non_member_terms' in result:
max_term = max(Term(s) for s in result['non_member_terms'])
elif not clubrep and 'terms' in result:
max_term = max(Term(s) for s in result['terms'])
if max_term is not None and max_term >= current_term:
next_term = max_term + 1
else:
next_term = Term.current()
terms = [next_term + i for i in range(num_terms)]
terms = list(map(str, terms))
if clubrep: if clubrep:
body = {'non_member_terms': terms} body = {'non_member_terms': terms}

44
ceo/cli/positions.py Normal file
View File

@ -0,0 +1,44 @@
import click
from zope import component
from ..utils import http_get, http_post
from .utils import handle_sync_response, handle_stream_response, print_colon_kv
from ceo_common.interfaces import IConfig
from ceod.transactions.members import UpdateMemberPositionsTransaction
@click.group(short_help='List or change exec positions')
def positions():
update_commands()
@positions.command(short_help='Get current positions')
def get():
resp = http_get('/api/positions')
result = handle_sync_response(resp)
print_colon_kv(result.items())
@positions.command(short_help='Update positions')
def set(**kwargs):
body = {k.replace('_', '-'): v for k, v in kwargs.items()}
print_body = {k: v or '' for k, v in body.items()}
click.echo('The positions will be updated:')
print_colon_kv(print_body.items())
click.confirm('Do you want to continue?', abort=True)
resp = http_post('/api/positions', json=body)
handle_stream_response(resp, UpdateMemberPositionsTransaction.operations)
# Provides dynamic parameters for `set' command using config file
def update_commands():
global set
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
required = cfg.get('positions_required')
for pos in avail:
r = pos in required
set = click.option(f'--{pos}', metavar='USERNAME', required=r, prompt=r)(set)

View File

@ -24,4 +24,7 @@ descriptions = {
'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups', 'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups',
'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists', 'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists',
'remove_sudo_role': 'Remove sudo role from LDAP', 'remove_sudo_role': 'Remove sudo role from LDAP',
'update_positions_ldap': 'Update positions in LDAP',
'update_exec_group_ldap': 'Update executive group in LDAP',
'subscribe_to_mailing_lists': 'Subscribe to mailing lists',
} }

38
ceo/term_utils.py Normal file
View File

@ -0,0 +1,38 @@
from typing import List
from .utils import http_get
from ceo_common.model import Term
import ceo.cli.utils as cli_utils
import ceo.tui.utils as tui_utils
# Had to put these in a separate file to avoid a circular import.
def get_terms_for_new_user(num_terms: int) -> List[str]:
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
return list(map(str, terms))
def get_terms_for_renewal(
username: str, num_terms: int, clubrep: bool, tui_model=None,
) -> List[str]:
resp = http_get('/api/members/' + username)
if tui_model is None:
result = cli_utils.handle_sync_response(resp)
else:
result = tui_utils.handle_sync_response(resp, tui_model)
max_term = None
current_term = Term.current()
if clubrep and 'non_member_terms' in result:
max_term = max(Term(s) for s in result['non_member_terms'])
elif not clubrep and 'terms' in result:
max_term = max(Term(s) for s in result['terms'])
if max_term is not None and max_term >= current_term:
next_term = max_term + 1
else:
next_term = Term.current()
terms = [next_term + i for i in range(num_terms)]
return list(map(str, terms))

168
ceo/tui/CeoFrame.py Normal file
View File

@ -0,0 +1,168 @@
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, # key in model.viewdata
on_load=None,
title=None,
save_data=False, # whether to save widget state for resizing
has_dynamic_layouts=False, # whether layouts are created on load
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._save_data = save_data
self._extra_on_load = on_load
self._model = model
self._name = name
self._loaded = False
self._has_dynamic_layouts = has_dynamic_layouts
self._quit_keys = [Screen.KEY_ESCAPE]
if escape_on_q:
self._quit_keys.append(ord('q'))
# sanity check
if save_data:
assert name in model.viewdata
def _ceoframe_on_load(self):
# We usually don't want _on_load() to be called multiple times
# e.g. when switching back to a scene, or after calling reset()
if self._loaded:
return
self._loaded = True
if self._model.title is not None:
self.title = self._model.title
self._model.title = None
if self._save_data:
# restore the saved input fields' values
self.data = self._model.viewdata[self._name]
if self._extra_on_load is not None:
self._extra_on_load()
def _ceoframe_on_unload(self):
"""
This should be called just after the screen gets resized,
but before the new scenes are constructed.
The idea is to save the user's data in the input fields
so that we can restore them in the new scenes.
"""
if not self._save_data:
return
self.save()
self._model.viewdata[self._name] = self.data
def _ceoframe_on_reset(self):
"""
This needs to be called whenever we return to the home screen
after some kind of operation was completed.
Currently this is called from Model.reset().
"""
# We want a fresh slate once we return to the home screen, so we
# want on_load() to be called for the scenes.
self._loaded = False
if self._has_dynamic_layouts:
# We don't want layouts to accumulate.
self.clear_layouts()
def clear_layouts(self):
# OK so this a *really* bad thing to do, since we're reaching
# into the private variables of a third-party library.
# Unfortunately asciimatics doesn't allow us to clear the layouts
# of an existing frame, and we need this to be able to re-use
# frames which create layouts dynamically.
self._layouts.clear()
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():
raise NextScene(self._model.scene_stack.pop())
def _next():
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):
self.flash_message('')
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,28 +1,16 @@
from asciimatics.exceptions import NextScene from asciimatics.widgets import Layout, Label
from asciimatics.widgets import Frame, Layout, Button, Divider, Label
from .CeoFrame import CeoFrame
class ConfirmView(Frame): class ConfirmView(CeoFrame):
def __init__(self, screen, width, height, model): def __init__(self, screen, width, height, model):
super().__init__( super().__init__(
screen, screen, height, width, model, 'Confirm',
height, on_load=self._confirmview_on_load, title='Confirmation',
width, has_dynamic_layouts=True,
can_scroll=False, escape_on_q=True,
on_load=self._on_load,
title='Confirmation',
) )
self._model = model
def _add_buttons(self):
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Divider())
layout = Layout([1, 1])
self.add_layout(layout)
layout.add_widget(Button('No', self._back), 0)
layout.add_widget(Button('Yes', self._next), 1)
def _add_line(self, text: str = ''): def _add_line(self, text: str = ''):
layout = Layout([100]) layout = Layout([100])
@ -35,7 +23,7 @@ class ConfirmView(Frame):
layout.add_widget(Label(key + ':', align='>'), 0) layout.add_widget(Label(key + ':', align='>'), 0)
layout.add_widget(Label(val, align='<'), 2) layout.add_widget(Label(val, align='<'), 2)
def _on_load(self): def _confirmview_on_load(self):
for _ in range(2): for _ in range(2):
self._add_line() self._add_line()
for line in self._model.confirm_lines: for line in self._model.confirm_lines:
@ -48,12 +36,22 @@ class ConfirmView(Frame):
# fill the rest of the space # fill the rest of the space
self.add_layout(Layout([100], fill_frame=True)) self.add_layout(Layout([100], fill_frame=True))
self._add_buttons() kwargs = {
'back_btn': True, 'back_btn_text': 'No', 'next_btn_text': 'Yes',
}
if self._model.operations is not None:
kwargs['next_scene'] = 'Transaction'
else:
self.add_flash_message_layout()
kwargs['on_next_excl'] = self._next
self.add_buttons(**kwargs)
self.fix() self.fix()
def _back(self):
raise NextScene(self._model.scene_stack.pop())
def _next(self): def _next(self):
self._model.scene_stack.append('Confirm') self.flash_message('Sending request...', force_update=True)
raise NextScene('Transaction') 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)

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

@ -0,0 +1,30 @@
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',
has_dynamic_layouts=True,
)
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,12 +1,86 @@
from copy import deepcopy
class Model: class Model:
"""A convenient place to share data beween views.""" """A convenient place to store View data persistently."""
def __init__(self): def __init__(self):
# simple key-value pairs
self.screen = None self.screen = None
self.views = []
self.title = None self.title = None
self.for_member = True
self.scene_stack = [] self.scene_stack = []
self.result_view_name = None
self.error_message = None
# view-specific data, to be used when e.g. resizing the window
self._initial_viewdata = {
'AddUser': {
'uid': '',
'cn': '',
'program': '',
'forwarding_address': '',
'num_terms': '1',
},
'RenewUser': {
'uid': '',
'num_terms': '1',
},
'Transaction': {
'op_layout': None,
'msg_layout': None,
'labels': {},
'status': 'not started',
},
'GetUser': {
'uid': '',
},
'ResetPassword': {
'uid': '',
},
'ChangeLoginShell': {
'uid': '',
'login_shell': '',
},
'SetForwardingAddresses': {
'uid': '',
'forwarding_addresses': [''],
},
'AddGroup': {
'cn': '',
'description': '',
},
'GetGroup': {
'cn': '',
},
'AddMemberToGroup': {
'cn': '',
'uid': '',
'subscribe': True,
},
'RemoveMemberFromGroup': {
'cn': '',
'uid': '',
'unsubscribe': True,
},
}
self.viewdata = deepcopy(self._initial_viewdata)
# data which is shared between multiple views
self.is_club_rep = False
self.confirm_lines = None self.confirm_lines = None
self.operations = None self.operations = None
self.deferred_req = None self.deferred_req = None
self.resp = 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.title = None
self.error_message = None
self.scene_stack.clear()
self.result_view_name = None
for view in self.views:
if hasattr(view, '_ceoframe_on_reset'):
view._ceoframe_on_reset()

63
ceo/tui/ResultView.py Normal file
View File

@ -0,0 +1,63 @@
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',
has_dynamic_layouts=True,
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 resp.status_code != 200:
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,6 +1,6 @@
from typing import Dict, Union from typing import Dict, Union
from asciimatics.widgets import Label, Button, Layout, Frame from asciimatics.widgets import Label, Layout
import requests import requests
from .Model import Model from .Model import Model
@ -12,43 +12,42 @@ class TUIStreamResponseHandler(StreamResponseHandler):
self, self,
model: Model, model: Model,
labels: Dict[str, Label], labels: Dict[str, Label],
next_btn: Button,
msg_layout: Layout, msg_layout: Layout,
frame: Frame, txn_view, # TransactionView
): ):
super().__init__()
self.screen = model.screen self.screen = model.screen
self.operations = model.operations self.operations = model.operations
self.idx = 0 self.idx = 0
self.labels = labels self.labels = labels
self.next_btn = next_btn
self.msg_layout = msg_layout self.msg_layout = msg_layout
self.frame = frame self.txn_view = txn_view
self.error_messages = [] self.error_messages = []
def _update(self): def _update(self):
# Since we're running in a separate thread, we need to force the # Since we're running in a separate thread, we need to force the
# screen to update. See # screen to update. See
# https://github.com/peterbrittain/asciimatics/issues/56 # https://github.com/peterbrittain/asciimatics/issues/56
self.frame.fix() self.txn_view.fix()
self.screen.force_update() self.screen.force_update()
def _enable_next_btn(self): def _show_msg(self, msg: str = '\n'):
self.next_btn.disabled = False
self.frame.reset()
def _show_msg(self, msg: str = ''):
for line in msg.splitlines(): for line in msg.splitlines():
self.msg_layout.add_widget(Label(line, align='^')) self.msg_layout.add_widget(Label(line, align='^'))
def _abort(self): def _abort(self):
for operation in self.operations[self.idx:]: for operation in self.operations[self.idx:]:
self.labels[operation].text = 'ABORTED' self.labels[operation].text = 'ABORTED'
self._enable_next_btn() self.txn_view.enable_next_btn()
def handle_non_200(self, resp: requests.Response): def handle_non_200(self, resp: requests.Response):
self._abort() self._abort()
self._show_msg('An error occurred:') self._show_msg('An error occurred:')
self._show_msg(resp.text) 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() self._update()
def begin(self): def begin(self):
@ -57,19 +56,19 @@ class TUIStreamResponseHandler(StreamResponseHandler):
def handle_aborted(self, err_msg: str): def handle_aborted(self, err_msg: str):
self._abort() self._abort()
self._show_msg('The transaction was rolled back.') self._show_msg('The transaction was rolled back.')
self._show_msg('The error was:') self._show_msg('The error was:\n')
self._show_msg(err_msg) self._show_msg(err_msg)
self._show_msg()
self._show_msg('Please check the ceod logs.') self._show_msg('Please check the ceod logs.')
self._update() self._update()
def handle_completed(self): def handle_completed(self):
self._show_msg('Transaction successfully completed.') self._show_msg('Transaction successfully completed.')
if len(self.error_messages) > 0: if len(self.error_messages) > 0:
self._show_msg('There were some errors, please check the ' self._show_msg('There were some errors:')
'ceod logs.') for msg in self.error_messages:
# we don't have enough space in the TUI to actually self._show_msg(msg)
# show the error messages self.txn_view.enable_next_btn()
self._enable_next_btn()
self._update() self._update()
def handle_successful_operation(self): def handle_successful_operation(self):

View File

@ -1,29 +1,24 @@
from threading import Thread from threading import Thread
from asciimatics.exceptions import NextScene from asciimatics.exceptions import NextScene
from asciimatics.widgets import Frame, Layout, Button, Divider, Label from asciimatics.widgets import Layout, Button, Divider, Label
from ..operation_strings import descriptions as op_desc from ..operation_strings import descriptions as op_desc
from ..utils import generic_handle_stream_response from ..utils import generic_handle_stream_response
from .CeoFrame import CeoFrame
from .TUIStreamResponseHandler import TUIStreamResponseHandler from .TUIStreamResponseHandler import TUIStreamResponseHandler
class TransactionView(Frame): class TransactionView(CeoFrame):
def __init__(self, screen, width, height, model): def __init__(self, screen, width, height, model):
super().__init__( super().__init__(
screen, screen, height, width, model, 'Transaction',
height, on_load=self._txnview_on_load, title='Running Transaction',
width, has_dynamic_layouts=True,
can_scroll=False,
on_load=self._on_load,
title='Running Transaction',
) )
self._model = model self._model = model
# map operation names to label widgets # map operation names to label widgets
self._labels = {} self._labels = model.viewdata['Transaction']['labels']
# this is an ugly hack to get around the fact that _on_load()
# will be called again when we reset() in the TUIStreamResponseHandler
self._loaded = False
def _add_buttons(self): def _add_buttons(self):
layout = Layout([100]) layout = Layout([100])
@ -33,49 +28,75 @@ class TransactionView(Frame):
layout = Layout([1, 1]) layout = Layout([1, 1])
self.add_layout(layout) self.add_layout(layout)
self._next_btn = Button('Next', self._next) self._next_btn = Button('Next', self._next)
# we don't want to disable the button if the txn completed
# and the user just resized the window
if self._model.viewdata['Transaction']['status'] != 'completed':
self._next_btn.disabled = True self._next_btn.disabled = True
layout.add_widget(self._next_btn, 1) layout.add_widget(self._next_btn, 1)
def _add_line(self, text: str = ''): def _add_blank_line(self):
layout = Layout([100]) self._op_layout.add_widget(Label(''), 0)
self.add_layout(layout) self._op_layout.add_widget(Label(''), 2)
layout.add_widget(Label(text, align='^'))
def _on_load(self):
if self._loaded:
return
self._loaded = True
def _txnview_on_load(self):
d = self._model.viewdata['Transaction']
if d['op_layout'] is None:
first_time = True
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
d['op_layout'] = self._op_layout
for _ in range(2): for _ in range(2):
self._add_line() self._add_blank_line()
for operation in self._model.operations: for operation in self._model.operations:
desc = op_desc[operation] desc = op_desc[operation]
layout = Layout([10, 1, 10]) self._op_layout.add_widget(Label(desc + '...', align='>'), 0)
self.add_layout(layout)
layout.add_widget(Label(desc + '...', align='>'), 0)
desc_label = Label('', align='<') desc_label = Label('', align='<')
layout.add_widget(desc_label, 2) self._op_layout.add_widget(desc_label, 2)
self._labels[operation] = desc_label self._labels[operation] = desc_label
self._add_line() self._add_blank_line()
# this is the where success/failure messages etc. get placed
self._msg_layout = Layout([100]) self._msg_layout = Layout([100])
self.add_layout(self._msg_layout) self.add_layout(self._msg_layout)
d['msg_layout'] = self._msg_layout
else:
# we arrive here when the screen has been resized
first_time = False
# restore the layouts which we saved
self._op_layout = d['op_layout']
self.add_layout(self._op_layout)
self._msg_layout = d['msg_layout']
self.add_layout(self._msg_layout)
# fill up the rest of the space
self.add_layout(Layout([100], fill_frame=True)) self.add_layout(Layout([100], fill_frame=True))
self._add_buttons() self._add_buttons()
self.fix() self.fix()
# only send the API request the first time we arrive at this
# scene, not when the screen gets resized
if first_time:
Thread(target=self._do_txn).start() Thread(target=self._do_txn).start()
def _do_txn(self): def _do_txn(self):
self._model.viewdata['Transaction']['status'] = 'in progress'
resp = self._model.deferred_req() resp = self._model.deferred_req()
handler = TUIStreamResponseHandler( handler = TUIStreamResponseHandler(
model=self._model, model=self._model,
labels=self._labels, labels=self._labels,
next_btn=self._next_btn,
msg_layout=self._msg_layout, msg_layout=self._msg_layout,
frame=self, txn_view=self,
) )
generic_handle_stream_response(resp, self._model.operations, handler) generic_handle_stream_response(resp, self._model.operations, handler)
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
self.reset()
# save the fact that the transaction is completed
self._model.viewdata['Transaction']['status'] = 'completed'
def _next(self): def _next(self):
self._model.scene_stack.clear() self._model.reset()
raise NextScene('Welcome') raise NextScene('Welcome')

View File

@ -1,40 +1,66 @@
from asciimatics.widgets import Frame, ListBox, Layout, Divider, \ import functools
Button, Widget
from asciimatics.widgets import ListBox, Layout, Divider, Button, Label
from asciimatics.exceptions import NextScene, StopApplication from asciimatics.exceptions import NextScene, StopApplication
from .CeoFrame import CeoFrame
class WelcomeView(Frame):
class WelcomeView(CeoFrame):
def __init__(self, screen, width, height, model): def __init__(self, screen, width, height, model):
super().__init__( super().__init__(
screen, screen, height, width, model, 'Welcome',
height,
width,
can_scroll=False,
title='CSC Electronic Office', title='CSC Electronic Office',
escape_on_q=True,
) )
self._model = model members_menu_items = [
self._members_menu_items = [
('Add member', 'AddUser'), ('Add member', 'AddUser'),
('Add club rep', 'AddUser'), ('Add club rep', 'AddUser'),
('Renew member', 'RenewUser'), ('Renew member', 'RenewUser'),
('Renew club rep', 'RenewUser'), ('Renew club rep', 'RenewUser'),
('Get user info', 'GetUserInfo'), ('Get user info', 'GetUser'),
('Reset password', 'ResetPassword'), ('Reset password', 'ResetPassword'),
('Modify user', 'ModifyUser'), ('Change login shell', 'ChangeLoginShell'),
('Set forwarding addresses', 'SetForwardingAddresses'),
] ]
self._members_menu = ListBox( members_menu = self._create_menu(
Widget.FILL_FRAME, members_menu_items, 'members', self._members_menu_select)
[ groups_menu_items = [
(desc, i) for i, (desc, view) in ('Add group', 'AddGroup'),
enumerate(self._members_menu_items) ('Get group members', 'GetGroup'),
], ('Add member to group', 'AddMemberToGroup'),
name='members', ('Remove member from group', 'RemoveMemberFromGroup'),
label='Members', ]
on_select=self._members_menu_select, groups_menu = self._create_menu(groups_menu_items, 'groups')
) db_menu_items = [
layout = Layout([100], fill_frame=True) ('Create MySQL database', 'CreateMySQL'),
('Reset MySQL password', 'ResetMySQLPassword'),
('Create PostgreSQL database', 'CreatePostgreSQL'),
('Reset PostgreSQL password', 'ResetPostgreSQLPassword'),
]
db_menu = self._create_menu(
db_menu_items, 'databases', self._db_menu_select)
positions_menu_items = [
('Get positions', 'GetPositions'),
('Set positions', 'SetPositions'),
]
positions_menu = self._create_menu(positions_menu_items, 'positions')
self._menu_groups = {
'members': members_menu_items,
'groups': groups_menu_items,
'databases': db_menu_items,
'positions': positions_menu_items,
}
layout = Layout([1, 4, 1], fill_frame=True)
self.add_layout(layout) self.add_layout(layout)
layout.add_widget(self._members_menu) layout.add_widget(members_menu, 1)
layout.add_widget(groups_menu, 1)
layout.add_widget(db_menu, 1)
layout.add_widget(positions_menu, 1)
layout = Layout([100])
self.add_layout(layout)
layout.add_widget(Label('Press <TAB> to switch widgets'))
layout.add_widget(Divider()) layout.add_widget(Divider())
layout = Layout([1, 1, 1]) layout = Layout([1, 1, 1])
@ -42,12 +68,40 @@ class WelcomeView(Frame):
layout.add_widget(Button("Quit", self._quit), 2) layout.add_widget(Button("Quit", self._quit), 2)
self.fix() self.fix()
def _members_menu_select(self): def _create_menu(self, menu_items, name, on_select=None):
if on_select is None:
on_select = functools.partial(self._generic_menu_select, name)
return ListBox(
len(menu_items),
[
(desc, i) for i, (desc, view) in
enumerate(menu_items)
],
name=name,
label=name.capitalize(),
on_select=on_select,
)
def _get_menu_item_desc_view(self, menu_name: str):
self.save() self.save()
item_id = self.data['members'] item_id = self.data[menu_name]
desc, view = self._members_menu_items[item_id] menu_items = self._menu_groups[menu_name]
return menu_items[item_id]
def _members_menu_select(self):
desc, view = self._get_menu_item_desc_view('members')
if desc.endswith('club rep'): if desc.endswith('club rep'):
self._model.for_member = False self._model.is_club_rep = True
self._welcomeview_go_to_next_scene(desc, view)
def _db_menu_select(self):
pass
def _generic_menu_select(self, menu_name):
desc, view = self._get_menu_item_desc_view('groups')
self._welcomeview_go_to_next_scene(desc, view)
def _welcomeview_go_to_next_scene(self, desc, view):
self._model.title = desc self._model.title = desc
self._model.scene_stack.append('Welcome') self._model.scene_stack.append('Welcome')
raise NextScene(view) raise NextScene(view)

View File

@ -0,0 +1,42 @@
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',
save_data=True,
)
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 _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

@ -0,0 +1,44 @@
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',
save_data=True,
)
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 _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

@ -0,0 +1,18 @@
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

@ -0,0 +1,31 @@
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',
save_data=True,
)
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 _next(self):
cn = self._cn.value
self._model.viewdata['GetGroup']['cn'] = cn
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

@ -0,0 +1,44 @@
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',
save_data=True,
)
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 _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

View File

@ -1,21 +1,21 @@
from asciimatics.exceptions import NextScene from threading import Thread
from asciimatics.widgets import Frame, Layout, Text, Button, Divider
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, \ from ...utils import http_get, http_post, defer, user_dict_kv, \
get_terms_for_new_user, get_adduser_operations get_adduser_operations
from ..CeoFrame import CeoFrame
class AddUserView(Frame): class AddUserView(CeoFrame):
def __init__(self, screen, width, height, model): def __init__(self, screen, width, height, model):
super().__init__( super().__init__(
screen, screen, height, width, model, 'AddUser',
height, save_data=True,
width,
can_scroll=False,
on_load=self._on_load,
) )
self._model = model
self._username_changed = False self._username_changed = False
layout = Layout([100], fill_frame=True) layout = Layout([100], fill_frame=True)
self.add_layout(layout) self.add_layout(layout)
self._username = Text( self._username = Text(
@ -33,22 +33,14 @@ class AddUserView(Frame):
self._num_terms = Text( self._num_terms = Text(
"Number of terms:", "num_terms", "Number of terms:", "num_terms",
validator=lambda s: s.isdigit() and s[0] != '0') validator=lambda s: s.isdigit() and s[0] != '0')
self._num_terms.value = '1'
layout.add_widget(self._num_terms) layout.add_widget(self._num_terms)
layout = Layout([100]) self.add_flash_message_layout()
self.add_layout(layout) self.add_buttons(
layout.add_widget(Divider()) back_btn=True,
next_scene='Confirm', on_next=self._next)
layout = Layout([1, 1])
self.add_layout(layout)
layout.add_widget(Button('Back', self._back), 0)
layout.add_widget(Button("Next", self._next), 1)
self.fix() self.fix()
def _on_load(self):
self.title = self._model.title
def _on_username_change(self): def _on_username_change(self):
self._username_changed = True self._username_changed = True
@ -59,23 +51,24 @@ class AddUserView(Frame):
username = self._username.value username = self._username.value
if username == '': if username == '':
return return
self._get_uwldap_info(username) Thread(target=self._get_uwldap_info, args=[username]).start()
def _get_uwldap_info(self, username): def _get_uwldap_info(self, username):
self.flash_message('Looking up user...')
try:
resp = http_get('/api/uwldap/' + username) resp = http_get('/api/uwldap/' + username)
if resp.status_code != 200: if resp.status_code != 200:
return return
data = resp.json() data = resp.json()
self._status_label.text = ''
self._full_name.value = data['cn'] self._full_name.value = data['cn']
self._program.value = data.get('program', '') self._program.value = data.get('program', '')
if data.get('mail_local_addresses'): if data.get('mail_local_addresses'):
self._forwarding_address.value = data['mail_local_addresses'][0] self._forwarding_address.value = data['mail_local_addresses'][0]
finally:
def _back(self): self.clear_flash_message()
raise NextScene(self._model.scene_stack.pop())
def _next(self): def _next(self):
self._model.prev_scene = 'AddUser'
body = { body = {
'uid': self._username.value, 'uid': self._username.value,
'cn': self._full_name.value, 'cn': self._full_name.value,
@ -85,10 +78,10 @@ class AddUserView(Frame):
if self._forwarding_address.value: if self._forwarding_address.value:
body['forwarding_addresses'] = [self._forwarding_address.value] body['forwarding_addresses'] = [self._forwarding_address.value]
new_terms = get_terms_for_new_user(int(self._num_terms.value)) new_terms = get_terms_for_new_user(int(self._num_terms.value))
if self._model.for_member: if self._model.is_club_rep:
body['terms'] = new_terms
else:
body['non_member_terms'] = new_terms body['non_member_terms'] = new_terms
else:
body['terms'] = new_terms
pairs = user_dict_kv(body) pairs = user_dict_kv(body)
self._model.confirm_lines = [ self._model.confirm_lines = [
'The following user will be created:', 'The following user will be created:',
@ -100,6 +93,3 @@ class AddUserView(Frame):
self._model.deferred_req = defer(http_post, '/api/members', json=body) self._model.deferred_req = defer(http_post, '/api/members', json=body)
self._model.operations = get_adduser_operations(body) self._model.operations = get_adduser_operations(body)
self._model.scene_stack.append('AddUser')
raise NextScene('Confirm')

View File

@ -0,0 +1,71 @@
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',
save_data=True,
)
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()
# 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

@ -0,0 +1,11 @@
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

@ -0,0 +1,30 @@
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',
save_data=True,
)
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 _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

@ -0,0 +1,60 @@
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',
save_data=True,
)
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')
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 _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

@ -0,0 +1,12 @@
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

@ -0,0 +1,31 @@
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',
save_data=True,
)
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 _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

@ -0,0 +1,76 @@
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',
save_data=True,
)
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()
# 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,38 +1,70 @@
import sys import sys
from asciimatics.event import KeyboardEvent from asciimatics.exceptions import ResizeScreenError
from asciimatics.exceptions import ResizeScreenError, StopApplication
from asciimatics.scene import Scene from asciimatics.scene import Scene
from asciimatics.screen import Screen from asciimatics.screen import Screen
from .ConfirmView import ConfirmView from .ConfirmView import ConfirmView
from .ErrorView import ErrorView
from .Model import Model from .Model import Model
from .ResultView import ResultView
from .TransactionView import TransactionView from .TransactionView import TransactionView
from .WelcomeView import WelcomeView from .WelcomeView import WelcomeView
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.AddUserView import AddUserView 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
def unhandled(event): # tuples of (name, view)
if isinstance(event, KeyboardEvent): views = []
c = event.key_code
# Stop on 'q' or 'Esc'
if c in (113, 27):
raise StopApplication("User terminated app")
def screen_wrapper(screen, scene, model): def screen_wrapper(screen, last_scene, model):
model.screen = screen global views
# unload the old views
for name, view in views:
if hasattr(view, '_on_ceoframe_unload'):
view._on_ceoframe_unload()
width = min(screen.width, 90) width = min(screen.width, 90)
height = min(screen.height, 24) height = min(screen.height, 24)
scenes = [ views = [
Scene([WelcomeView(screen, width, height, model)], -1, name='Welcome'), ('Welcome', WelcomeView(screen, width, height, model)),
Scene([AddUserView(screen, width, height, model)], -1, name='AddUser'), ('Confirm', ConfirmView(screen, width, height, model)),
Scene([ConfirmView(screen, width, height, model)], -1, name='Confirm'), ('Transaction', TransactionView(screen, width, height, model)),
Scene([TransactionView(screen, width, height, model)], -1, name='Transaction'), ('Result', ResultView(screen, width, height, model)),
('Error', ErrorView(screen, width, height, model)),
('AddUser', AddUserView(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)),
] ]
scenes = [
Scene([view], -1, name=name) for name, view in views
]
model.screen = screen
model.views = [view for name, view in views]
screen.play( screen.play(
scenes, stop_on_resize=True, start_scene=scene, allow_int=True, scenes, stop_on_resize=True, start_scene=last_scene, allow_int=True,
unhandled_input=unhandled) )
def main(): def main():

10
ceo/tui/utils.py Normal file
View File

@ -0,0 +1,10 @@
from asciimatics.exceptions import NextScene
import requests
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()

View File

@ -1,6 +1,5 @@
import functools import functools
import json import json
import sys
from typing import List, Dict, Tuple, Callable from typing import List, Dict, Tuple, Callable
import requests import requests
@ -8,7 +7,6 @@ from zope import component
from .StreamResponseHandler import StreamResponseHandler from .StreamResponseHandler import StreamResponseHandler
from ceo_common.interfaces import IHTTPClient, IConfig from ceo_common.interfaces import IHTTPClient, IConfig
from ceo_common.model import Term
from ceod.transactions.members import AddMemberTransaction from ceod.transactions.members import AddMemberTransaction
@ -71,6 +69,8 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
key1000: val3 key1000: val3
val4 val4
""" """
if not pairs:
return []
lines = [] lines = []
maxlen = max(len(key) for key, val in pairs) maxlen = max(len(key) for key, val in pairs)
for key, val in pairs: for key, val in pairs:
@ -85,12 +85,6 @@ def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
return lines return lines
def get_terms_for_new_user(num_terms: int) -> List[str]:
current_term = Term.current()
terms = [current_term + i for i in range(num_terms)]
return list(map(str, terms))
def user_dict_kv(d: Dict) -> List[Tuple[str]]: def user_dict_kv(d: Dict) -> List[Tuple[str]]:
"""Pretty-format a serialized User as (key, value) pairs.""" """Pretty-format a serialized User as (key, value) pairs."""
pairs = [ pairs = [
@ -149,15 +143,16 @@ def generic_handle_stream_response(
""" """
if resp.status_code != 200: if resp.status_code != 200:
handler.handle_non_200(resp) handler.handle_non_200(resp)
return
handler.begin() handler.begin()
idx = 0 idx = 0
data = [] data = []
for line in resp.iter_lines(decode_unicode=True, chunk_size=8): for line in resp.iter_lines(decode_unicode=True, chunk_size=1):
d = json.loads(line) d = json.loads(line)
data.append(d) data.append(d)
if d['status'] == 'aborted': if d['status'] == 'aborted':
handler.handle_aborted(d['error']) handler.handle_aborted(d['error'])
sys.exit(1) return
elif d['status'] == 'completed': elif d['status'] == 'completed':
while idx < len(operations): while idx < len(operations):
handler.handle_skipped_operation() handler.handle_skipped_operation()

View File

@ -29,6 +29,12 @@ def update_positions():
required = cfg.get('positions_required') required = cfg.get('positions_required')
available = cfg.get('positions_available') available = cfg.get('positions_available')
# remove falsy values
body = {
positions: username for positions, username in body.items()
if username
}
for position in body.keys(): for position in body.keys():
if position not in available: if position not in available:
return { return {

View File

@ -11,7 +11,7 @@ You can hear about upcoming events in a number of ways:
* Check our website from time to time: http://csclub.uwaterloo.ca/ * Check our website from time to time: http://csclub.uwaterloo.ca/
* Subscribe to our events calendar feed: http://csclub.uwaterloo.ca/events.ics * Subscribe to our events calendar feed: http://csclub.uwaterloo.ca/events.ics
* Like the CSC on Facebook: https://www.facebook.com/uw.computerscienceclub * Like the CSC on Facebook: https://www.facebook.com/uw.computerscienceclub
* Join the CSC Discord server: https://discord.gg/uwcsclub * Join the CSC Discord server: https://discord.gg/pHfYBCg
* Read your email: announcements are sent via the csc-general mailing list * Read your email: announcements are sent via the csc-general mailing list
* Keep an eye out in the MC: posters for upcoming events appear in stairwells and hallways * Keep an eye out in the MC: posters for upcoming events appear in stairwells and hallways

View File

@ -69,9 +69,9 @@ class AddMemberToGroupTransaction(AbstractTransaction):
yield 'subscribe_user_to_auxiliary_mailing_lists' yield 'subscribe_user_to_auxiliary_mailing_lists'
except KeyError: except KeyError:
pass pass
except Exception: except Exception as err:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
yield 'failed_to_subscribe_user_to_auxiliary_mailing_lists' yield 'failed_to_subscribe_user_to_auxiliary_mailing_lists: ' + str(err)
result = { result = {
'added_to_groups': [self.group_name] + [ 'added_to_groups': [self.group_name] + [

View File

@ -69,9 +69,9 @@ class RemoveMemberFromGroupTransaction(AbstractTransaction):
yield 'unsubscribe_user_from_auxiliary_mailing_lists' yield 'unsubscribe_user_from_auxiliary_mailing_lists'
except KeyError: except KeyError:
pass pass
except Exception: except Exception as err:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
yield 'failed_to_unsubscribe_user_from_auxiliary_mailing_lists' yield 'failed_to_unsubscribe_user_from_auxiliary_mailing_lists: ' + str(err)
result = { result = {
'removed_from_groups': [self.group_name] + [ 'removed_from_groups': [self.group_name] + [

View File

@ -0,0 +1,55 @@
from click.testing import CliRunner
from ceo.cli import cli
def test_positions(cli_setup):
runner = CliRunner()
# Setup test data
for i in range(5):
runner.invoke(cli, ['members', 'add', f'test_{i}', '--cn', f'Test {i}', '--program', 'Math', '--terms', '1'], input='y\n')
runner.invoke(cli, ['groups', 'add', 'exec', '--description', 'Test Group'], input='y\n')
result = runner.invoke(cli, [
'positions', 'set',
'--president', 'test_0',
'--vice-president', 'test_1',
'--sysadmin', 'test_2',
'--secretary', 'test_3',
'--webmaster', 'test_4',
], input='y\n')
assert result.exit_code == 0
assert result.output == '''
The positions will be updated:
president: test_0
vice-president: test_1
sysadmin: test_2
secretary: test_3
webmaster: test_4
treasurer:
cro:
librarian:
imapd:
offsck:
Do you want to continue? [y/N]: y
Update positions in LDAP... Done
Update executive group in LDAP... Done
Subscribe to mailing lists... Done
Transaction successfully completed.
'''[1:] # noqa: W291
result = runner.invoke(cli, ['positions', 'get'])
assert result.exit_code == 0
assert result.output == '''
president: test_0
secretary: test_3
sysadmin: test_2
vice-president: test_1
webmaster: test_4
'''[1:]
# Cleanup test data
for i in range(5):
runner.invoke(cli, ['members', 'delete', f'test_{i}'], input='y\n')
runner.invoke(cli, ['groups', 'delete', 'exec'], input='y\n')

View File

@ -7,3 +7,8 @@ uw_domain = uwaterloo.internal
admin_host = phosphoric-acid admin_host = phosphoric-acid
use_https = false use_https = false
port = 9987 port = 9987
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck

View File

@ -51,7 +51,7 @@ def cfg(_drone_hostname_mock):
with importlib.resources.path('tests', 'ceod_test_local.ini') as p: with importlib.resources.path('tests', 'ceod_test_local.ini') as p:
config_file = p.__fspath__() config_file = p.__fspath__()
_cfg = Config(config_file) _cfg = Config(config_file)
component.provideUtility(_cfg, IConfig) component.getGlobalSiteManager().registerUtility(_cfg, IConfig)
return _cfg return _cfg
@ -75,7 +75,7 @@ def krb_srv(cfg):
else: else:
principal = 'ceod/' + socket.getfqdn() principal = 'ceod/' + socket.getfqdn()
krb = KerberosService(principal) krb = KerberosService(principal)
component.provideUtility(krb, IKerberosService) component.getGlobalSiteManager().registerUtility(krb, IKerberosService)
delete_test_princs(krb) delete_test_princs(krb)
yield krb yield krb
@ -160,7 +160,7 @@ def ldap_srv_session(cfg, krb_srv, ldap_conn):
conn.add(base_dn, 'organizationalUnit') conn.add(base_dn, 'organizationalUnit')
_ldap_srv = LDAPService() _ldap_srv = LDAPService()
component.provideUtility(_ldap_srv, ILDAPService) component.getGlobalSiteManager().registerUtility(_ldap_srv, ILDAPService)
yield _ldap_srv yield _ldap_srv
@ -180,7 +180,7 @@ def ldap_srv(ldap_srv_session, g_admin_ctx):
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def file_srv(cfg): def file_srv(cfg):
_file_srv = FileService() _file_srv = FileService()
component.provideUtility(_file_srv, IFileService) component.getGlobalSiteManager().registerUtility(_file_srv, IFileService)
members_home = cfg.get('members_home') members_home = cfg.get('members_home')
clubs_home = cfg.get('clubs_home') clubs_home = cfg.get('clubs_home')
@ -194,7 +194,7 @@ def file_srv(cfg):
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def http_client(cfg): def http_client(cfg):
_client = HTTPClient() _client = HTTPClient()
component.provideUtility(_client, IHTTPClient) component.getGlobalSiteManager().registerUtility(_client, IHTTPClient)
return _client return _client
@ -210,7 +210,7 @@ def mock_mailman_server():
def mailman_srv(mock_mailman_server, cfg, http_client): def mailman_srv(mock_mailman_server, cfg, http_client):
# TODO: test the RemoteMailmanService as well # TODO: test the RemoteMailmanService as well
mailman = MailmanService() mailman = MailmanService()
component.provideUtility(mailman, IMailmanService) component.getGlobalSiteManager().registerUtility(mailman, IMailmanService)
return mailman return mailman
@ -223,7 +223,7 @@ def uwldap_srv(cfg, ldap_conn):
conn.add(base_dn, 'organizationalUnit') conn.add(base_dn, 'organizationalUnit')
_uwldap_srv = UWLDAPService() _uwldap_srv = UWLDAPService()
component.provideUtility(_uwldap_srv, IUWLDAPService) component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv yield _uwldap_srv
delete_subtree(conn, base_dn) delete_subtree(conn, base_dn)
@ -240,21 +240,21 @@ def mock_mail_server():
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def mail_srv(cfg, mock_mail_server): def mail_srv(cfg, mock_mail_server):
_mail_srv = MailService() _mail_srv = MailService()
component.provideUtility(_mail_srv, IMailService) component.getGlobalSiteManager().registerUtility(_mail_srv, IMailService)
return _mail_srv return _mail_srv
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def mysql_srv(cfg): def mysql_srv(cfg):
mysql_srv = MySQLService() mysql_srv = MySQLService()
component.provideUtility(mysql_srv, IDatabaseService, 'mysql') component.getGlobalSiteManager().registerUtility(mysql_srv, IDatabaseService, 'mysql')
return mysql_srv return mysql_srv
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def postgresql_srv(cfg): def postgresql_srv(cfg):
psql_srv = PostgreSQLService() psql_srv = PostgreSQLService()
component.provideUtility(psql_srv, IDatabaseService, 'postgresql') component.getGlobalSiteManager().registerUtility(psql_srv, IDatabaseService, 'postgresql')
return psql_srv return psql_srv