Merge branch 'v1' of https://git.csclub.uwaterloo.ca/public/pyceo into db-cli
commit
300688cb9a
@ -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) |
@ -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)) |
@ -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") |
@ -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') |
@ -1,12 +1,86 @@ |
||||
from copy import deepcopy |
||||
|
||||
|
||||
class Model: |
||||
"""A convenient place to share data beween views.""" |
||||
"""A convenient place to store View data persistently.""" |
||||
|
||||
def __init__(self): |
||||
# simple key-value pairs |
||||
self.screen = None |
||||
self.views = [] |
||||
self.title = None |
||||
self.for_member = True |
||||
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.operations = 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() |
||||
|
@ -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') |
@ -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 |
@ -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 |
@ -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) |
@ -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() |
@ -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 |