parent
7d23fd690f
commit
bb56870652
@ -0,0 +1,43 @@ |
||||
from abc import ABC, abstractmethod |
||||
from typing import Union |
||||
|
||||
import requests |
||||
|
||||
|
||||
class StreamResponseHandler(ABC): |
||||
""" |
||||
An abstract class to handle stream responses from the server. |
||||
The CLI and TUI should implement a child class. |
||||
""" |
||||
|
||||
@abstractmethod |
||||
def handle_non_200(self, resp: requests.Response): |
||||
"""Handle a non-200 response.""" |
||||
|
||||
@abstractmethod |
||||
def begin(self): |
||||
"""Begin the transaction.""" |
||||
|
||||
@abstractmethod |
||||
def handle_aborted(self, err_msg: str): |
||||
"""Handle an aborted transaction.""" |
||||
|
||||
@abstractmethod |
||||
def handle_completed(self): |
||||
"""Handle a completed transaction.""" |
||||
|
||||
@abstractmethod |
||||
def handle_successful_operation(self): |
||||
"""Handle a successful operation.""" |
||||
|
||||
@abstractmethod |
||||
def handle_failed_operation(self, err_msg: Union[str, None]): |
||||
"""Handle a failed operation.""" |
||||
|
||||
@abstractmethod |
||||
def handle_skipped_operation(self): |
||||
"""Handle a skipped operation.""" |
||||
|
||||
@abstractmethod |
||||
def handle_unrecognized_operation(self, operation: str): |
||||
"""Handle an unrecognized operation.""" |
@ -1,4 +1,41 @@ |
||||
import importlib.resources |
||||
import os |
||||
import socket |
||||
import sys |
||||
|
||||
from zope import component |
||||
|
||||
from .cli import cli |
||||
from .krb_check import krb_check |
||||
from .tui.start import main as tui_main |
||||
from ceo_common.interfaces import IConfig, IHTTPClient |
||||
from ceo_common.model import Config, HTTPClient |
||||
|
||||
|
||||
def register_services(): |
||||
# Config |
||||
# This is a hack to determine if we're in the dev env or not |
||||
if socket.getfqdn().endswith('.csclub.internal'): |
||||
with importlib.resources.path('tests', 'ceo_dev.ini') as p: |
||||
config_file = p.__fspath__() |
||||
else: |
||||
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini') |
||||
cfg = Config(config_file) |
||||
component.provideUtility(cfg, IConfig) |
||||
|
||||
# HTTPService |
||||
http_client = HTTPClient() |
||||
component.provideUtility(http_client, IHTTPClient) |
||||
|
||||
|
||||
def main(): |
||||
krb_check() |
||||
register_services() |
||||
if len(sys.argv) > 1: |
||||
cli(obj={}) |
||||
else: |
||||
tui_main() |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
cli(obj={}) |
||||
main() |
||||
|
@ -0,0 +1,67 @@ |
||||
from typing import List, Union |
||||
|
||||
import click |
||||
import requests |
||||
|
||||
from ..StreamResponseHandler import StreamResponseHandler |
||||
from ..operation_strings import descriptions as op_desc |
||||
|
||||
|
||||
class Abort(click.ClickException): |
||||
"""Abort silently.""" |
||||
|
||||
def __init__(self, exit_code=1): |
||||
super().__init__('') |
||||
self.exit_code = exit_code |
||||
|
||||
def show(self): |
||||
pass |
||||
|
||||
|
||||
class CLIStreamResponseHandler(StreamResponseHandler): |
||||
def __init__(self, operations: List[str]): |
||||
self.operations = operations |
||||
self.idx = 0 |
||||
|
||||
def handle_non_200(self, resp: requests.Response): |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text.rstrip()) |
||||
raise Abort() |
||||
|
||||
def begin(self): |
||||
click.echo(op_desc[self.operations[0]] + '... ', nl=False) |
||||
|
||||
def handle_aborted(self, err_msg: str): |
||||
click.echo(click.style('ABORTED', fg='red')) |
||||
click.echo('The transaction was rolled back.') |
||||
click.echo('The error was: ' + err_msg) |
||||
click.echo('Please check the ceod logs.') |
||||
|
||||
def handle_completed(self): |
||||
click.echo('Transaction successfully completed.') |
||||
|
||||
def _go_to_next_op(self): |
||||
""" |
||||
Increment the operation index and print the next operation, if |
||||
there is one. |
||||
""" |
||||
self.idx += 1 |
||||
if self.idx < len(self.operations): |
||||
click.echo(op_desc[self.operations[self.idx]] + '... ', nl=False) |
||||
|
||||
def handle_successful_operation(self): |
||||
click.echo(click.style('Done', fg='green')) |
||||
self._go_to_next_op() |
||||
|
||||
def handle_failed_operation(self, err_msg: Union[str, None]): |
||||
click.echo(click.style('Failed', fg='red')) |
||||
if err_msg is not None: |
||||
click.echo(' Error message: ' + err_msg) |
||||
self._go_to_next_op() |
||||
|
||||
def handle_skipped_operation(self): |
||||
click.echo('Skipped') |
||||
self._go_to_next_op() |
||||
|
||||
def handle_unrecognized_operation(self, operation: str): |
||||
click.echo('Unrecognized operation: ' + operation) |
@ -0,0 +1,59 @@ |
||||
from asciimatics.exceptions import NextScene |
||||
from asciimatics.widgets import Frame, Layout, Button, Divider, Label |
||||
|
||||
|
||||
class ConfirmView(Frame): |
||||
def __init__(self, screen, width, height, model): |
||||
super().__init__( |
||||
screen, |
||||
height, |
||||
width, |
||||
can_scroll=False, |
||||
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 = ''): |
||||
layout = Layout([100]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Label(text, align='^')) |
||||
|
||||
def _add_pair(self, key: str, val: str): |
||||
layout = Layout([10, 1, 10]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Label(key + ':', align='>'), 0) |
||||
layout.add_widget(Label(val, align='<'), 2) |
||||
|
||||
def _on_load(self): |
||||
for _ in range(2): |
||||
self._add_line() |
||||
for line in self._model.confirm_lines: |
||||
if isinstance(line, str): |
||||
self._add_line(line) |
||||
else: |
||||
# assume tuple |
||||
key, val = line |
||||
self._add_pair(key, val) |
||||
# fill the rest of the space |
||||
self.add_layout(Layout([100], fill_frame=True)) |
||||
|
||||
self._add_buttons() |
||||
self.fix() |
||||
|
||||
def _back(self): |
||||
raise NextScene(self._model.scene_stack.pop()) |
||||
|
||||
def _next(self): |
||||
self._model.scene_stack.append('Confirm') |
||||
raise NextScene('Transaction') |
@ -0,0 +1,12 @@ |
||||
class Model: |
||||
"""A convenient place to share data beween views.""" |
||||
|
||||
def __init__(self): |
||||
# simple key-value pairs |
||||
self.screen = None |
||||
self.title = None |
||||
self.for_member = True |
||||
self.scene_stack = [] |
||||
self.confirm_lines = None |
||||
self.operations = None |
||||
self.deferred_req = None |
@ -0,0 +1,98 @@ |
||||
from typing import Dict, Union |
||||
|
||||
from asciimatics.widgets import Label, Button, Layout, Frame |
||||
import requests |
||||
|
||||
from .Model import Model |
||||
from ..StreamResponseHandler import StreamResponseHandler |
||||
|
||||
|
||||
class TUIStreamResponseHandler(StreamResponseHandler): |
||||
def __init__( |
||||
self, |
||||
model: Model, |
||||
labels: Dict[str, Label], |
||||
next_btn: Button, |
||||
msg_layout: Layout, |
||||
frame: Frame, |
||||
): |
||||
self.screen = model.screen |
||||
self.operations = model.operations |
||||
self.idx = 0 |
||||
self.labels = labels |
||||
self.next_btn = next_btn |
||||
self.msg_layout = msg_layout |
||||
self.frame = frame |
||||
self.error_messages = [] |
||||
|
||||
def _update(self): |
||||
# Since we're running in a separate thread, we need to force the |
||||
# screen to update. See |
||||
# https://github.com/peterbrittain/asciimatics/issues/56 |
||||
self.frame.fix() |
||||
self.screen.force_update() |
||||
|
||||
def _enable_next_btn(self): |
||||
self.next_btn.disabled = False |
||||
self.frame.reset() |
||||
|
||||
def _show_msg(self, msg: str = ''): |
||||
for line in msg.splitlines(): |
||||
self.msg_layout.add_widget(Label(line, align='^')) |
||||
|
||||
def _abort(self): |
||||
for operation in self.operations[self.idx:]: |
||||
self.labels[operation].text = 'ABORTED' |
||||
self._enable_next_btn() |
||||
|
||||
def handle_non_200(self, resp: requests.Response): |
||||
self._abort() |
||||
self._show_msg('An error occurred:') |
||||
self._show_msg(resp.text) |
||||
self._update() |
||||
|
||||
def begin(self): |
||||
pass |
||||
|
||||
def handle_aborted(self, err_msg: str): |
||||
self._abort() |
||||
self._show_msg('The transaction was rolled back.') |
||||
self._show_msg('The error was:') |
||||
self._show_msg(err_msg) |
||||
self._show_msg('Please check the ceod logs.') |
||||
self._update() |
||||
|
||||
def handle_completed(self): |
||||
self._show_msg('Transaction successfully completed.') |
||||
if len(self.error_messages) > 0: |
||||
self._show_msg('There were some errors, please check the ' |
||||
'ceod logs.') |
||||
# we don't have enough space in the TUI to actually |
||||
# show the error messages |
||||
self._enable_next_btn() |
||||
self._update() |
||||
|
||||
def handle_successful_operation(self): |
||||
operation = self.operations[self.idx] |
||||
self.labels[operation].text = 'Done' |
||||
self.idx += 1 |
||||
self._update() |
||||
|
||||
def handle_failed_operation(self, err_msg: Union[str, None]): |
||||
operation = self.operations[self.idx] |
||||
self.labels[operation].text = 'Failed' |
||||
if err_msg is not None: |
||||
self.error_messages.append(err_msg) |
||||
self.idx += 1 |
||||
self._update() |
||||
|
||||
def handle_skipped_operation(self): |
||||
operation = self.operations[self.idx] |
||||
self.labels[operation].text = 'Skipped' |
||||
self.idx += 1 |
||||
self._update() |
||||
|
||||
def handle_unrecognized_operation(self, operation: str): |
||||
self.error_messages.append('Unrecognized operation: ' + operation) |
||||
self.idx += 1 |
||||
self._update() |
@ -0,0 +1,81 @@ |
||||
from threading import Thread |
||||
|
||||
from asciimatics.exceptions import NextScene |
||||
from asciimatics.widgets import Frame, Layout, Button, Divider, Label |
||||
|
||||
from ..operation_strings import descriptions as op_desc |
||||
from ..utils import generic_handle_stream_response |
||||
from .TUIStreamResponseHandler import TUIStreamResponseHandler |
||||
|
||||
|
||||
class TransactionView(Frame): |
||||
def __init__(self, screen, width, height, model): |
||||
super().__init__( |
||||
screen, |
||||
height, |
||||
width, |
||||
can_scroll=False, |
||||
on_load=self._on_load, |
||||
title='Running Transaction', |
||||
) |
||||
self._model = model |
||||
# map operation names to label widgets |
||||
self._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): |
||||
layout = Layout([100]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Divider()) |
||||
|
||||
layout = Layout([1, 1]) |
||||
self.add_layout(layout) |
||||
self._next_btn = Button('Next', self._next) |
||||
self._next_btn.disabled = True |
||||
layout.add_widget(self._next_btn, 1) |
||||
|
||||
def _add_line(self, text: str = ''): |
||||
layout = Layout([100]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Label(text, align='^')) |
||||
|
||||
def _on_load(self): |
||||
if self._loaded: |
||||
return |
||||
self._loaded = True |
||||
|
||||
for _ in range(2): |
||||
self._add_line() |
||||
for operation in self._model.operations: |
||||
desc = op_desc[operation] |
||||
layout = Layout([10, 1, 10]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Label(desc + '...', align='>'), 0) |
||||
desc_label = Label('', align='<') |
||||
layout.add_widget(desc_label, 2) |
||||
self._labels[operation] = desc_label |
||||
self._add_line() |
||||
self._msg_layout = Layout([100]) |
||||
self.add_layout(self._msg_layout) |
||||
self.add_layout(Layout([100], fill_frame=True)) |
||||
|
||||
self._add_buttons() |
||||
self.fix() |
||||
Thread(target=self._do_txn).start() |
||||
|
||||
def _do_txn(self): |
||||
resp = self._model.deferred_req() |
||||
handler = TUIStreamResponseHandler( |
||||
model=self._model, |
||||
labels=self._labels, |
||||
next_btn=self._next_btn, |
||||
msg_layout=self._msg_layout, |
||||
frame=self, |
||||
) |
||||
generic_handle_stream_response(resp, self._model.operations, handler) |
||||
|
||||
def _next(self): |
||||
self._model.scene_stack.clear() |
||||
raise NextScene('Welcome') |
@ -0,0 +1,57 @@ |
||||
from asciimatics.widgets import Frame, ListBox, Layout, Divider, \ |
||||
Button, Widget |
||||
from asciimatics.exceptions import NextScene, StopApplication |
||||
|
||||
|
||||
class WelcomeView(Frame): |
||||
def __init__(self, screen, width, height, model): |
||||
super().__init__( |
||||
screen, |
||||
height, |
||||
width, |
||||
can_scroll=False, |
||||
title='CSC Electronic Office', |
||||
) |
||||
self._model = model |
||||
self._members_menu_items = [ |
||||
('Add member', 'AddUser'), |
||||
('Add club rep', 'AddUser'), |
||||
('Renew member', 'RenewUser'), |
||||
('Renew club rep', 'RenewUser'), |
||||
('Get user info', 'GetUserInfo'), |
||||
('Reset password', 'ResetPassword'), |
||||
('Modify user', 'ModifyUser'), |
||||
] |
||||
self._members_menu = ListBox( |
||||
Widget.FILL_FRAME, |
||||
[ |
||||
(desc, i) for i, (desc, view) in |
||||
enumerate(self._members_menu_items) |
||||
], |
||||
name='members', |
||||
label='Members', |
||||
on_select=self._members_menu_select, |
||||
) |
||||
layout = Layout([100], fill_frame=True) |
||||
self.add_layout(layout) |
||||
layout.add_widget(self._members_menu) |
||||
layout.add_widget(Divider()) |
||||
|
||||
layout = Layout([1, 1, 1]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Button("Quit", self._quit), 2) |
||||
self.fix() |
||||
|
||||
def _members_menu_select(self): |
||||
self.save() |
||||
item_id = self.data['members'] |
||||
desc, view = self._members_menu_items[item_id] |
||||
if desc.endswith('club rep'): |
||||
self._model.for_member = False |
||||
self._model.title = desc |
||||
self._model.scene_stack.append('Welcome') |
||||
raise NextScene(view) |
||||
|
||||
@staticmethod |
||||
def _quit(): |
||||
raise StopApplication("User pressed quit") |
@ -0,0 +1,105 @@ |
||||
from asciimatics.exceptions import NextScene |
||||
from asciimatics.widgets import Frame, Layout, Text, Button, Divider |
||||
|
||||
from ...utils import http_get, http_post, defer, user_dict_kv, \ |
||||
get_terms_for_new_user, get_adduser_operations |
||||
|
||||
|
||||
class AddUserView(Frame): |
||||
def __init__(self, screen, width, height, model): |
||||
super().__init__( |
||||
screen, |
||||
height, |
||||
width, |
||||
can_scroll=False, |
||||
on_load=self._on_load, |
||||
) |
||||
self._model = model |
||||
self._username_changed = False |
||||
layout = Layout([100], fill_frame=True) |
||||
self.add_layout(layout) |
||||
self._username = Text( |
||||
"Username:", "uid", |
||||
on_change=self._on_username_change, |
||||
on_blur=self._on_username_blur, |
||||
) |
||||
layout.add_widget(self._username) |
||||
self._full_name = Text("Full name:", "cn") |
||||
layout.add_widget(self._full_name) |
||||
self._program = Text("Program:", "program") |
||||
layout.add_widget(self._program) |
||||
self._forwarding_address = Text("Forwarding address:", "forwarding_address") |
||||
layout.add_widget(self._forwarding_address) |
||||
self._num_terms = Text( |
||||
"Number of terms:", "num_terms", |
||||
validator=lambda s: s.isdigit() and s[0] != '0') |
||||
self._num_terms.value = '1' |
||||
layout.add_widget(self._num_terms) |
||||
|
||||
layout = Layout([100]) |
||||
self.add_layout(layout) |
||||
layout.add_widget(Divider()) |
||||
|
||||
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() |
||||
|
||||
def _on_load(self): |
||||
self.title = self._model.title |
||||
|
||||
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 |
||||
self._get_uwldap_info(username) |
||||
|
||||
def _get_uwldap_info(self, username): |
||||
resp = http_get('/api/uwldap/' + username) |
||||
if resp.status_code != 200: |
||||
return |
||||
data = resp.json() |
||||
self._full_name.value = data['cn'] |
||||
self._program.value = data.get('program', '') |
||||
if data.get('mail_local_addresses'): |
||||
self._forwarding_address.value = data['mail_local_addresses'][0] |
||||
|
||||
def _back(self): |
||||
raise NextScene(self._model.scene_stack.pop()) |
||||
|
||||
def _next(self): |
||||
self._model.prev_scene = 'AddUser' |
||||
body = { |
||||
'uid': self._username.value, |
||||
'cn': self._full_name.value, |
||||
} |
||||
if self._program.value: |
||||
body['program'] = self._program.value |
||||
if self._forwarding_address.value: |
||||
body['forwarding_addresses'] = [self._forwarding_address.value] |
||||
new_terms = get_terms_for_new_user(int(self._num_terms.value)) |
||||
if self._model.for_member: |
||||
body['terms'] = new_terms |
||||
else: |
||||
body['non_member_terms'] = new_terms |
||||
pairs = user_dict_kv(body) |
||||
self._model.confirm_lines = [ |
||||
'The following user will be created:', |
||||
'', |
||||
] + pairs + [ |
||||
'', |
||||
'Are you sure you want to continue?', |
||||
] |
||||
|
||||
self._model.deferred_req = defer(http_post, '/api/members', json=body) |
||||
self._model.operations = get_adduser_operations(body) |
||||
|
||||
self._model.scene_stack.append('AddUser') |
||||
raise NextScene('Confirm') |
@ -0,0 +1,46 @@ |
||||
import sys |
||||
|
||||
from asciimatics.event import KeyboardEvent |
||||
from asciimatics.exceptions import ResizeScreenError, StopApplication |
||||
from asciimatics.scene import Scene |
||||
from asciimatics.screen import Screen |
||||
|
||||
from .ConfirmView import ConfirmView |
||||
from .Model import Model |
||||
from .TransactionView import TransactionView |
||||
from .WelcomeView import WelcomeView |
||||
from .members.AddUserView import AddUserView |
||||
|
||||
|
||||
def unhandled(event): |
||||
if isinstance(event, KeyboardEvent): |
||||
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): |
||||
model.screen = screen |
||||
width = min(screen.width, 90) |
||||
height = min(screen.height, 24) |
||||
scenes = [ |
||||
Scene([WelcomeView(screen, width, height, model)], -1, name='Welcome'), |
||||
Scene([AddUserView(screen, width, height, model)], -1, name='AddUser'), |
||||
Scene([ConfirmView(screen, width, height, model)], -1, name='Confirm'), |
||||
Scene([TransactionView(screen, width, height, model)], -1, name='Transaction'), |
||||
] |
||||
screen.play( |
||||
scenes, stop_on_resize=True, start_scene=scene, allow_int=True, |
||||
unhandled_input=unhandled) |
||||
|
||||
|
||||
def main(): |
||||
last_scene = None |
||||
model = Model() |
||||
while True: |
||||
try: |
||||
Screen.wrapper(screen_wrapper, arguments=[last_scene, model]) |
||||
sys.exit(0) |
||||
except ResizeScreenError as e: |
||||
last_scene = e.scene |
@ -1,14 +0,0 @@ |
||||
#!/usr/bin/env python3 |
||||
|
||||
from base64 import b64encode |
||||
import sys |
||||
|
||||
from ceo_common.krb5.utils import get_fwd_tgt |
||||
|
||||
if len(sys.argv) != 2: |
||||
print(f'Usage: {sys.argv[0]} <ceod hostname>', file=sys.stderr) |
||||
sys.exit(1) |
||||
|
||||
b = get_fwd_tgt('ceod/' + sys.argv[1]) |
||||
with open('cred', 'wb') as f: |
||||
f.write(b64encode(b)) |