implement Database views in TUI
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Max Erenberg 2021-09-12 02:08:15 -04:00
parent ad38588141
commit 155c96c500
14 changed files with 239 additions and 60 deletions

View File

@ -4,54 +4,22 @@ from typing import Dict
import click import click
from zope import component from zope import component
from ..utils import http_post, http_get, http_delete from ..utils import http_post, http_get, http_delete, write_db_creds
from .utils import handle_sync_response, check_file_path, check_if_in_development from .utils import handle_sync_response, check_if_in_development
from ceo_common.interfaces import IConfig from ceo_common.interfaces import IConfig
def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str): def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str):
cfg_srv = component.getUtility(IConfig) cfg_srv = component.getUtility(IConfig)
db_host = cfg_srv.get(f'{db_type}_host') db_host = cfg_srv.get(f'{db_type}_host')
username = user_dict['uid']
if db_type == 'mysql': if db_type == 'mysql':
db_type_name = 'MySQL' db_type_name = 'MySQL'
db_cli_local_cmd = f'mysql {username}'
db_cli_cmd = f'mysql {username} -h {db_host} -u {username} -p'
else: else:
db_type_name = 'PostgreSQL' db_type_name = 'PostgreSQL'
db_cli_local_cmd = f'psql {username}' wrote_to_file = write_db_creds(filename, user_dict, password, db_type, db_host)
db_cli_cmd = f'psql -d {username} -h {db_host} -U {username} -W'
username = user_dict['uid']
info = f"""{db_type_name} Database Information for {username}
Your new {db_type_name} database was created. To connect, use the following options:
Database: {username}
Username: {username}
Password: {password}
Host: {db_host}
On {db_host} to connect using the {db_type_name} command-line client use
{db_cli_local_cmd}
From other CSC machines you can connect using
{db_cli_cmd}
"""
wrote_to_file = False
try:
# TODO: use phosphoric-acid to write to file (phosphoric-acid makes
# internal API call to caffeine)
with click.open_file(filename, "w") as f:
f.write(info)
os.chown(filename, user_dict['uid_number'], user_dict['gid_number'])
os.chmod(filename, 0o640)
wrote_to_file = True
except PermissionError:
pass
if op == 'create': if op == 'create':
click.echo(f'{db_type_name} database created.') click.echo(f'{db_type_name} database created.')
username = user_dict['uid']
click.echo(f'''Connection Information: click.echo(f'''Connection Information:
Database: {username} Database: {username}
@ -71,7 +39,6 @@ def create(username: str, db_type: str):
click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True) click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
check_file_path(info_file_path)
resp = http_post(f'/api/db/{db_type}/{username}') resp = http_post(f'/api/db/{db_type}/{username}')
result = handle_sync_response(resp) result = handle_sync_response(resp)
@ -87,7 +54,6 @@ def pwreset(username: str, db_type: str):
click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True) click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
check_file_path(info_file_path)
resp = http_post(f'/api/db/{db_type}/{username}/pwreset') resp = http_post(f'/api/db/{db_type}/{username}/pwreset')
result = handle_sync_response(resp) result = handle_sync_response(resp)

View File

@ -52,15 +52,6 @@ def handle_sync_response(resp: requests.Response):
return resp.json() return resp.json()
def check_file_path(file):
if os.path.isfile(file):
click.echo(f"{file} will be overwritten")
click.confirm('Do you want to continue?', abort=True)
elif os.path.isdir(file):
click.echo(f"Error: there exists a directory at {file}")
raise Abort()
def check_if_in_development() -> bool: def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment.""" """Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'): if not socket.getfqdn().endswith('.csclub.internal'):

View File

@ -61,6 +61,12 @@ class Model:
'uid': '', 'uid': '',
'unsubscribe': True, 'unsubscribe': True,
}, },
'CreateDatabase': {
'uid': '',
},
'ResetDatabasePassword': {
'uid': '',
},
} }
self.viewdata = deepcopy(self._initial_viewdata) self.viewdata = deepcopy(self._initial_viewdata)
# data which is shared between multiple views # data which is shared between multiple views
@ -69,6 +75,8 @@ class Model:
self.operations = None self.operations = None
self.deferred_req = None self.deferred_req = None
self.resp = None self.resp = None
self.db_type = None
self.user_dict = None
def reset(self): def reset(self):
self.viewdata = deepcopy(self._initial_viewdata) self.viewdata = deepcopy(self._initial_viewdata)
@ -77,6 +85,8 @@ class Model:
self.operations = None self.operations = None
self.deferred_req = None self.deferred_req = None
self.resp = None self.resp = None
self.db_type = None
self.user_dict = None
self.title = None self.title = None
self.error_message = None self.error_message = None
self.scene_stack.clear() self.scene_stack.clear()

View File

@ -44,7 +44,7 @@ class ResultView(CeoFrame):
def _resultview_on_load(self): def _resultview_on_load(self):
self._add_text() self._add_text()
resp = self._model.resp resp = self._model.resp
if resp.status_code != 200: if not resp.ok:
self._add_text('An error occurred:') self._add_text('An error occurred:')
if resp.headers.get('content-type') == 'application/json': if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error'] err_msg = resp.json()['error']

View File

@ -28,10 +28,10 @@ class WelcomeView(CeoFrame):
('Remove member from group', 'RemoveMemberFromGroup'), ('Remove member from group', 'RemoveMemberFromGroup'),
] ]
db_menu_items = [ db_menu_items = [
('Create MySQL database', 'CreateMySQL'), ('Create MySQL database', 'CreateDatabase'),
('Reset MySQL password', 'ResetMySQLPassword'), ('Reset MySQL password', 'ResetDatabasePassword'),
('Create PostgreSQL database', 'CreatePostgreSQL'), ('Create PostgreSQL database', 'CreateDatabase'),
('Reset PostgreSQL password', 'ResetPostgreSQLPassword'), ('Reset PostgreSQL password', 'ResetDatabasePassword'),
] ]
positions_menu_items = [ positions_menu_items = [
('Get positions', 'GetPositions'), ('Get positions', 'GetPositions'),
@ -90,6 +90,11 @@ class WelcomeView(CeoFrame):
if name == 'members': if name == 'members':
if desc.endswith('club rep'): if desc.endswith('club rep'):
self._model.is_club_rep = True self._model.is_club_rep = True
elif name == 'databases':
if 'MySQL' in desc:
self._model.db_type = 'mysql'
else:
self._model.db_type = 'postgresql'
self._welcomeview_go_to_next_scene(desc, view) self._welcomeview_go_to_next_scene(desc, view)
def _welcomeview_go_to_next_scene(self, desc, view): def _welcomeview_go_to_next_scene(self, desc, view):

View File

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

View File

@ -0,0 +1,44 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_post, http_get, defer
from ..CeoFrame import CeoFrame
class CreateDatabaseView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'CreateDatabase',
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_buttons(
back_btn=True, next_scene='Confirm',
on_next=self._next)
self.fix()
def _target(self):
username = self._username.value
db_type = self._model.db_type
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
user_dict = resp.json()
self._model.user_dict = user_dict
return http_post(f'/api/db/{db_type}/{username}')
def _next(self):
username = self._username.value
if not username:
return
if self._model.db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
self._model.confirm_lines = [
f'Are you sure you want to create a {db_type_name} database for {username}?',
]
self._model.deferred_req = defer(self._target)
self._model.result_view_name = 'CreateDatabaseResult'

View File

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

View File

@ -0,0 +1,44 @@
from asciimatics.widgets import Layout, Text
from ...utils import http_post, http_get, defer
from ..CeoFrame import CeoFrame
class ResetDatabasePasswordView(CeoFrame):
def __init__(self, screen, width, height, model):
super().__init__(
screen, height, width, model, 'ResetDatabasePassword',
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_buttons(
back_btn=True, next_scene='Confirm',
on_next=self._next)
self.fix()
def _target(self):
username = self._username.value
db_type = self._model.db_type
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
user_dict = resp.json()
self._model.user_dict = user_dict
return http_post(f'/api/db/{db_type}/{username}/pwreset')
def _next(self):
username = self._username.value
if not username:
return
if self._model.db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
self._model.confirm_lines = [
f'Are you sure you want to reset the {db_type_name} password for {username}?',
]
self._model.deferred_req = defer(self._target)
self._model.result_view_name = 'ResetDatabasePasswordResult'

View File

@ -0,0 +1 @@

View File

@ -10,6 +10,10 @@ from .Model import Model
from .ResultView import ResultView from .ResultView import ResultView
from .TransactionView import TransactionView from .TransactionView import TransactionView
from .WelcomeView import WelcomeView from .WelcomeView import WelcomeView
from .databases.CreateDatabaseView import CreateDatabaseView
from .databases.CreateDatabaseResultView import CreateDatabaseResultView
from .databases.ResetDatabasePasswordView import ResetDatabasePasswordView
from .databases.ResetDatabasePasswordResultView import ResetDatabasePasswordResultView
from .groups.AddGroupView import AddGroupView from .groups.AddGroupView import AddGroupView
from .groups.AddMemberToGroupView import AddMemberToGroupView from .groups.AddMemberToGroupView import AddMemberToGroupView
from .groups.GetGroupView import GetGroupView from .groups.GetGroupView import GetGroupView
@ -56,6 +60,10 @@ def screen_wrapper(screen, last_scene, model):
('GetGroupResult', GetGroupResultView(screen, width, height, model)), ('GetGroupResult', GetGroupResultView(screen, width, height, model)),
('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)), ('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)),
('RemoveMemberFromGroup', RemoveMemberFromGroupView(screen, width, height, model)), ('RemoveMemberFromGroup', RemoveMemberFromGroupView(screen, width, height, model)),
('CreateDatabase', CreateDatabaseView(screen, width, height, model)),
('CreateDatabaseResult', CreateDatabaseResultView(screen, width, height, model)),
('ResetDatabasePassword', ResetDatabasePasswordView(screen, width, height, model)),
('ResetDatabasePasswordResult', ResetDatabasePasswordResultView(screen, width, height, model)),
] ]
scenes = [ scenes = [
Scene([view], -1, name=name) for name, view in views Scene([view], -1, name=name) for name, view in views

View File

@ -1,5 +1,6 @@
import functools import functools
import json import json
import os
from typing import List, Dict, Tuple, Callable from typing import List, Dict, Tuple, Callable
import requests import requests
@ -193,3 +194,50 @@ def defer(f: Callable, *args, **kwargs):
def wrapper(): def wrapper():
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper
def write_db_creds(
filename: str,
user_dict: Dict,
password: str,
db_type: str,
db_host: str,
) -> bool:
username = user_dict['uid']
if db_type == 'mysql':
db_type_name = 'MySQL'
db_cli_local_cmd = f'mysql {username}'
db_cli_cmd = f'mysql {username} -h {db_host} -u {username} -p'
else:
db_type_name = 'PostgreSQL'
db_cli_local_cmd = f'psql {username}'
db_cli_cmd = f'psql -d {username} -h {db_host} -U {username} -W'
info = f"""{db_type_name} Database Information for {username}
Your new {db_type_name} database was created. To connect, use the following options:
Database: {username}
Username: {username}
Password: {password}
Host: {db_host}
On {db_host} to connect using the {db_type_name} command-line client use
{db_cli_local_cmd}
From other CSC machines you can connect using
{db_cli_cmd}
"""
try:
# TODO: use phosphoric-acid to write to file (phosphoric-acid makes
# internal API call to caffeine)
if os.path.isfile(filename):
os.rename(filename, filename + '.bak')
with open(filename, "w") as f:
f.write(info)
os.chown(filename, user_dict['uid_number'], user_dict['gid_number'])
os.chmod(filename, 0o640)
return True
except PermissionError:
return False

View File

@ -1,4 +1,5 @@
import os import os
import shutil
from click.testing import CliRunner from click.testing import CliRunner
from mysql.connector import connect from mysql.connector import connect
@ -33,6 +34,7 @@ def test_mysql(cli_setup, cfg, ldap_user):
# create database for user # create database for user
result = runner.invoke(cli, ['mysql', 'create', username], input='y\n') result = runner.invoke(cli, ['mysql', 'create', username], input='y\n')
print(result.output)
assert result.exit_code == 0 assert result.exit_code == 0
assert os.path.isfile(info_file_path) assert os.path.isfile(info_file_path)
@ -57,8 +59,7 @@ These settings have been written to {info_file_path}.
mysql_attempt_connection(host, username, passwd) mysql_attempt_connection(host, username, passwd)
# perform password reset for user # perform password reset for user
# confirm once to reset password, another to overwrite the file result = runner.invoke(cli, ['mysql', 'pwreset', username], input="y\n")
result = runner.invoke(cli, ['mysql', 'pwreset', username], input="y\ny\n")
assert result.exit_code == 0 assert result.exit_code == 0
response_arr = result.output.split() response_arr = result.output.split()
@ -78,5 +79,4 @@ These settings have been written to {info_file_path}.
with pytest.raises(ProgrammingError): with pytest.raises(ProgrammingError):
mysql_attempt_connection(host, username, passwd) mysql_attempt_connection(host, username, passwd)
os.remove(info_file_path) shutil.rmtree(ldap_user.home_directory)
os.rmdir(ldap_user.home_directory)

View File

@ -1,5 +1,6 @@
import pytest import pytest
import os import os
import shutil
from click.testing import CliRunner from click.testing import CliRunner
from ceo.cli import cli from ceo.cli import cli
@ -59,8 +60,7 @@ These settings have been written to {info_file_path}.
psql_attempt_connection(host, username, passwd) psql_attempt_connection(host, username, passwd)
# perform password reset for user # perform password reset for user
# confirm once to reset password, another to overwrite the file result = runner.invoke(cli, ['postgresql', 'pwreset', username], input="y\n")
result = runner.invoke(cli, ['postgresql', 'pwreset', username], input="y\ny\n")
assert result.exit_code == 0 assert result.exit_code == 0
response_arr = result.output.split() response_arr = result.output.split()
@ -80,5 +80,4 @@ These settings have been written to {info_file_path}.
with pytest.raises(OperationalError): with pytest.raises(OperationalError):
psql_attempt_connection(host, username, passwd) psql_attempt_connection(host, username, passwd)
os.remove(info_file_path) shutil.rmtree(ldap_user.home_directory)
os.rmdir(ldap_user.home_directory)