diff --git a/package.json b/package.json index 80f2da0f0..a19bd0c99 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@fortawesome/fontawesome-free": "6.5.1", "bootstrap": "5.3.2", "jquery": "3.7.1", + "parsleyjs": "2.9.2", "plotly.js-dist-min": "2.27.1" } } diff --git a/pyra/config.py b/pyra/config.py index 56da3d11c..5caa9276f 100644 --- a/pyra/config.py +++ b/pyra/config.py @@ -6,6 +6,7 @@ """ # standard imports import sys +from typing import Optional, List # lib imports from configobj import ConfigObj @@ -13,10 +14,11 @@ # local imports from pyra import definitions -from pyra import helpers +from pyra import logger +from pyra import locales # get log -log = helpers.get_logger(name=__name__) # must use helpers.get_log due to circular import +log = logger.get_logger(name=__name__) # get the config filename FILENAME = definitions.Files.CONFIG @@ -24,38 +26,266 @@ # access the config dictionary here CONFIG = None -# increase CONFIG_VERSION default when changing default values +# localization +_ = locales.get_text() + +# increase CONFIG_VERSION default and max when changing default values # then do `if CONFIG_VERSION == x:` something to change the old default value to the new default value # then update the CONFIG_VERSION number -_CONFIG_SPEC = [ - '[Hidden]', - 'CONFIG_VERSION = integer(min=0, default=0)', - 'FIRST_RUN_COMPLETE = boolean(default=False)', # todo - '[General]', - 'LOCALE = option("en", "es", default="en")', - 'LAUNCH_BROWSER = boolean(default=True)', - 'SYSTEM_TRAY = boolean(default=True)', - '[Logging]', - 'LOG_DIR = string', - 'DEBUG_LOGGING = boolean(default=True)', - '[Network]', - 'HTTP_HOST = string(default="0.0.0.0")', - 'HTTP_PORT = integer(min=21, max=65535, default=9696)', - 'HTTP_ROOT = string', - '[Updater]', - 'AUTO_UPDATE = boolean(default=False)', -] - -# used for log filters -_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK'] -_WHITELIST_KEYS = ['HTTPS_KEY'] - -LOG_BLACKLIST = [] - - -def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigObj: + + +def on_change_tray_toggle() -> bool: + """ + Toggle the tray icon. + + This is needed, since ``tray_icon`` cannot be imported at the module level without a circular import. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + See Also + -------- + pyra.tray_icon.tray_toggle : ``on_change_tray_toggle`` is an alias of this function. + + Examples + -------- + >>> on_change_tray_toggle() + True """ - Create a config file and `ConfigObj` using a config spec. + from pyra import tray_icon + return tray_icon.tray_toggle() + + +# types +# - section +# - boolean +# - option +# - string +# - integer +# - float +# data parsley types (Parsley validation) +# - alphanum (string) +# - email (string) +# - url (string) +# - number (float, integer) +# - integer (integer) +# - digits (string) +_CONFIG_SPEC_DICT = dict( + Info=dict( + type='section', + name=_('Info'), + description=_('For information purposes only.'), + icon='info', + CONFIG_VERSION=dict( + type='integer', + name=_('Config version'), + description=_('The configuration version.'), + default=0, # increment when updating config + min=0, + max=0, # increment when updating config + data_parsley_type='integer', + extra_class='col-md-3', + locked=True, + ), + FIRST_RUN_COMPLETE=dict( + type='boolean', + name=_('First run complete'), + description=_('Todo: Indicates if the user has completed the initial setup.'), + default=False, + locked=True, + ), + ), + General=dict( + type='section', + name=_('General'), + description=_('General settings.'), + icon='gear', + LOCALE=dict( + type='option', + name=_('Locale'), + description=_('The localization setting to use.'), + default='en', + options=[ + 'en', + 'es', + ], + option_names=[ + f'English ({_("English")})', + f'EspaƱol ({_("Spanish")})', + ], + refresh=True, + extra_class='col-lg-6', + ), + LAUNCH_BROWSER=dict( + type='boolean', + name=_('Launch Browser on Startup '), + description=_(f'Open browser when {definitions.Names.name} starts.'), + default=True, + ), + SYSTEM_TRAY=dict( + type='boolean', + name=_('Enable System Tray Icon'), + description=_(f'Show {definitions.Names.name} shortcut in the system tray.'), + default=True, + # todo - fix circular import + on_change=on_change_tray_toggle, + ), + ), + Logging=dict( + type='section', + name=_('Logging'), + description=_('Logging settings.'), + icon='file-code', + LOG_DIR=dict( + type='string', + name=_('Log directory'), + advanced=True, + description=_('The directory where to store the log files.'), + data_parsley_pattern=r'^[a-zA-Z]:\\(?:\w+\\?)*$' if definitions.Platform.os_platform == 'win32' + else r'^\/(?:[^/]+\/)*$ ', + # https://regexpattern.com/windows-folder-path/ + # https://regexpattern.com/linux-folder-path/ + extra_class='col-lg-8', + button_directory=True, + ), + DEBUG_LOGGING=dict( + type='boolean', + name=_('Debug logging'), + advanced=True, + description=_('Enable debug logging.'), + default=True, + ), + ), + Network=dict( + type='section', + name=_('Network'), + description=_('Network settings.'), + icon='network-wired', + HTTP_HOST=dict( + type='string', + name=_('HTTP host address'), + advanced=True, + description=_('The HTTP address to bind to.'), + default='0.0.0.0', + data_parsley_pattern=r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)[.]){3}' + r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b', + # https://codverter.com/blog/articles/tech/20190105-extract-ipv4-ipv6-ip-addresses-using-regex.html + extra_class='col-md-4', + ), + HTTP_PORT=dict( + type='integer', + name=_('HTTP port'), + advanced=True, + description=_('Port to bind web server to. Note that ports below 1024 may require root.'), + default=9696, + min=21, + max=65535, + data_parsley_type='integer', + extra_class='col-md-3', + ), + HTTP_ROOT=dict( + type='string', + name=_('HTTP root'), + beta=True, + description=_('Todo: The base URL of the web server. Used for reverse proxies.'), + extra_class='col-lg-6', + ), + ), + Updater=dict( + type='section', + name=_('Updater'), + description=_('Updater settings.'), + icon='arrows-spin', + AUTO_UPDATE=dict( + type='boolean', + name=_('Auto update'), + beta=True, + description=_(f'Todo: Automatically update {definitions.Names.name}.'), + default=False, + ), + ), +) + + +def convert_config(d: dict = _CONFIG_SPEC_DICT, _config_spec: Optional[List] = None) -> List: + """ + Convert a config spec dictionary to a config spec list. + + A config spec dictionary is a custom type of dictionary that will be converted into a standard config spec list + which can later be used by ``configobj``. + + Parameters + ---------- + d : dict + The dictionary to convert. + _config_spec : Optional[List] + This should not be set when using this function, but since this function calls itself it needs to pass in the + list that is being built in order to return the correct list. + + Returns + ------- + list + A list representing a configspec for ``configobj``. + + Examples + -------- + >>> convert_config(d=_CONFIG_SPEC_DICT) + [...] + """ + if _config_spec is None: + _config_spec = [] + + for k, v in d.items(): + try: + v['type'] + except TypeError: + pass + else: + checks = ['min', 'max', 'options', 'default'] + check_value = '' + + for check in checks: + try: + v[check] + except KeyError: + pass + else: + check_value += f"{', ' if check_value != '' else ''}" + if check == 'options': + for option_value in v[check]: + if check_value: + check_value += f"{', ' if not check_value.endswith(', ') else ''}" + if isinstance(option_value, str): + check_value += f'"{option_value}"' + else: + check_value += f'{option_value}' + elif isinstance(v[check], str): + check_value += f"{check}=\"{v[check]}\"" + else: + check_value += f"{check}={v[check]}" + + check_value = f'({check_value})' if check_value else '' # add parenthesis if there's a value + + if v['type'] == 'section': # config section + _config_spec.append(f'[{k}]') + else: # int option + _config_spec.append(f"{k} = {v['type']}{check_value}") + + if isinstance(v, dict): + # continue parsing nested dictionary + convert_config(d=v, _config_spec=_config_spec) + + return _config_spec + + +def create_config(config_file: str, config_spec: dict = _CONFIG_SPEC_DICT) -> ConfigObj: + """ + Create a config file and `ConfigObj` using a config spec dictionary. + + A config spec dictionary is a strictly formatted dictionary that will be converted into a standard config spec list + to be later used by ``configobj``. The created config is validated against a Validator object. This function will remove keys from the user's config.ini if they no longer exist in the config spec. @@ -64,7 +294,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO ---------- config_file : str Full filename of config file. - config_spec : list, default = _CONFIG_SPEC + config_spec : dict, default = _CONFIG_SPEC_DICT Config spec to use. Returns @@ -82,8 +312,11 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO >>> create_config(config_file='config.ini') ConfigObj({...}) """ + # convert config spec dictionary to list + config_spec_list = convert_config(d=config_spec) + config = ConfigObj( - configspec=config_spec, + configspec=config_spec_list, encoding='UTF-8', list_values=True, stringify=True, @@ -99,7 +332,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO user_config = ConfigObj( infile=config_file, - configspec=config_spec, + configspec=config_spec_list, encoding='UTF-8', list_values=True, stringify=True, @@ -133,7 +366,7 @@ def create_config(config_file: str, config_spec: list = _CONFIG_SPEC) -> ConfigO config.filename = config_file config.write() # write the config file - if config_spec == _CONFIG_SPEC: # set CONFIG dictionary + if config_spec == _CONFIG_SPEC_DICT: # set CONFIG dictionary global CONFIG CONFIG = config diff --git a/pyra/definitions.py b/pyra/definitions.py index ae6826a6a..e0fc24288 100644 --- a/pyra/definitions.py +++ b/pyra/definitions.py @@ -144,14 +144,12 @@ class Paths: """ PYRA_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(PYRA_DIR) + DATA_DIR = ROOT_DIR + BINARY_PATH = os.path.abspath(os.path.join(DATA_DIR, 'retroarcher.py')) if Modes.FROZEN: # pyinstaller build DATA_DIR = os.path.dirname(sys.executable) BINARY_PATH = os.path.abspath(sys.executable) - else: - DATA_DIR = ROOT_DIR - BINARY_PATH = os.path.abspath(os.path.join(DATA_DIR, 'retroarcher.py')) - if Modes.DOCKER: # docker install DATA_DIR = '/config' # overwrite the value that was already set diff --git a/pyra/logger.py b/pyra/logger.py index 3fb5496b0..6c1601b30 100644 --- a/pyra/logger.py +++ b/pyra/logger.py @@ -29,13 +29,17 @@ from pyra import definitions from pyra import helpers -from pyra.config import _BLACKLIST_KEYS, _WHITELIST_KEYS - # These settings are for file logging only py_name = 'pyra' MAX_SIZE = 5000000 # 5 MB MAX_FILES = 5 +# used for log filters +_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK'] +_WHITELIST_KEYS = ['HTTPS_KEY'] + +LOG_BLACKLIST = [] + _BLACKLIST_WORDS = set() # Global queue for multiprocessing logging @@ -175,7 +179,7 @@ def filter(self, record) -> bool: >>> BlacklistFilter().filter(record=BlacklistFilter()) True """ - if not pyra.config.LOG_BLACKLIST: + if not LOG_BLACKLIST: return True for item in _BLACKLIST_WORDS: @@ -248,7 +252,7 @@ def filter(self, record) -> bool: >>> RegexFilter().filter(record=RegexFilter()) True """ - if not pyra.config.LOG_BLACKLIST: + if not LOG_BLACKLIST: return True try: diff --git a/pyra/tray_icon.py b/pyra/tray_icon.py index 544b98ce6..6b7baee68 100644 --- a/pyra/tray_icon.py +++ b/pyra/tray_icon.py @@ -18,6 +18,7 @@ from pyra import helpers from pyra import locales from pyra import logger +from pyra import threads # setup _ = locales.get_text() @@ -40,7 +41,7 @@ icon_supported = True # additional setup -icon: Union[Icon, bool] = False +icon_object: Union[Icon, bool] = False def tray_initialize() -> Union[Icon, bool]: @@ -143,12 +144,17 @@ def tray_disable(): config.save_config(config.CONFIG) -def tray_end(): +def tray_end() -> bool: """ End the system tray icon. Hide and then stop the system tray icon. + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + Examples -------- >>> tray_end() @@ -156,16 +162,16 @@ def tray_end(): try: icon_class except NameError: - pass + return False else: - if isinstance(icon, icon_class): + if isinstance(icon_object, icon_class): try: # this shouldn't be possible to call, other than through pytest - icon.visible = False + icon_object.visible = False except AttributeError: pass try: - icon.stop() + icon_object.stop() except AttributeError: pass except Exception as e: @@ -173,6 +179,64 @@ def tray_end(): else: global icon_running icon_running = False + return True + + +def tray_run_threaded() -> bool: + """ + Run the system tray in a thread. + + This function exectues various other functions to simplify starting the tray icon. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + See Also + -------- + tray_initialize : This function first, initializes the tray icon using ``tray_initialize()``. + tray_run : Then, ``tray_run`` is executed in a thread. + pyra.threads.run_in_thread : Run a method within a thread. + + Examples + -------- + >>> tray_run_threaded() + True + """ + if icon_supported: + global icon_object + icon_object = tray_initialize() + threads.run_in_thread(target=tray_run, name='pystray', daemon=True).start() + return True + else: + return False + + +def tray_toggle() -> bool: + """ + Toggle the system tray icon. + + Hide/unhide the system tray icon. + + Returns + ------- + bool + ``True`` if successful, otherwise ``False``. + + Examples + -------- + >>> tray_toggle() + """ + if icon_supported: + if icon_running: + result = tray_end() + else: + result = tray_run_threaded() + else: + result = False + + return result def tray_quit(): @@ -218,9 +282,9 @@ def tray_run(): else: global icon_running - if isinstance(icon, icon_class): + if isinstance(icon_object, icon_class): try: - icon.run_detached() + icon_object.run_detached() except AttributeError: pass except NotImplementedError as e: diff --git a/pyra/webapp.py b/pyra/webapp.py index 6a49ba67c..1f3457565 100644 --- a/pyra/webapp.py +++ b/pyra/webapp.py @@ -6,11 +6,11 @@ """ # standard imports import os +from typing import Optional # lib imports -import flask from flask import Flask, Response -from flask import jsonify, render_template, send_from_directory +from flask import jsonify, render_template, request, send_from_directory from flask_babel import Babel # local imports @@ -21,6 +21,9 @@ from pyra import locales from pyra import logger +# localization +_ = locales.get_text() + # setup flask app app = Flask( import_name=__name__, @@ -29,11 +32,17 @@ template_folder=os.path.join(Paths.ROOT_DIR, 'web', 'templates') ) - # remove extra lines rendered jinja templates app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True +# add python builtins to jinja templates +jinja_functions = dict( + int=int, + str=str, +) +app.jinja_env.globals.update(jinja_functions) + # localization babel = Babel( app=app, @@ -79,7 +88,7 @@ def home() -> render_template: chart_types = hardware.chart_types() chart_translations = hardware.chart_translations - return render_template('home.html', title='Home', chart_types=chart_types, translations=chart_translations) + return render_template('home.html', title=_('Home'), chart_types=chart_types, translations=chart_translations) @app.route('/callback/dashboard', methods=['GET']) @@ -110,9 +119,49 @@ def callback_dashboard() -> Response: return data +@app.route('/settings/', defaults={'configuration_spec': None}) +@app.route('/settings/') +def settings(configuration_spec: Optional[str]) -> render_template: + """ + Serve the configuration page page. + + .. todo:: This documentation needs to be improved. + + Parameters + ---------- + configuration_spec : Optional[str] + The spec to return. In the future this will be used to return config specs of plugins; however that is not + currently implemented. + + Returns + ------- + render_template + The rendered page. + + Notes + ----- + The following routes trigger this function. + + `/settings` + + Examples + -------- + >>> settings() + """ + config_settings = pyra.CONFIG + + if not configuration_spec: + config_spec = config._CONFIG_SPEC_DICT + else: + # todo - handle plugin configs + config_spec = None + + return render_template('config.html', title=_('Settings'), config_settings=config_settings, config_spec=config_spec) + + @app.route('/docs/', defaults={'filename': 'index.html'}) @app.route('/docs/') -def docs(filename) -> flask.send_from_directory: +def docs(filename) -> send_from_directory: """ Serve the Sphinx html documentation. @@ -144,7 +193,7 @@ def docs(filename) -> flask.send_from_directory: @app.route('/favicon.ico') -def favicon() -> flask.send_from_directory: +def favicon() -> send_from_directory: """ Serve the favicon.ico file. @@ -213,13 +262,100 @@ def test_logger() -> str: >>> test_logger() """ app.logger.info('testing from app.logger') - app.logger.warn('testing from app.logger') + app.logger.warning('testing from app.logger') app.logger.error('testing from app.logger') app.logger.critical('testing from app.logger') app.logger.debug('testing from app.logger') return f'Testing complete, check "logs/{__name__}.log" for output.' +@app.route('/api/settings', methods=['GET', 'POST'], defaults={'configuration_spec': None}) +@app.route('/api/settings/') +def api_settings(configuration_spec: Optional[str]) -> Response: + """ + Get current settings or save changes to settings from web ui. + + This endpoint accepts a `GET` or `POST` request. A `GET` request will return the current settings. + A `POST` request will process the data passed in and return the results of processing. + + Parameters + ---------- + configuration_spec : Optional[str] + The spec to return. In the future this will be used to return config specs of plugins; however that is not + currently implemented. + + Returns + ------- + Response + A response formatted as ``flask.jsonify``. + + Examples + -------- + >>> callback_dashboard() + + """ + if not configuration_spec: + config_spec = config._CONFIG_SPEC_DICT + else: + # todo - handle plugin configs + config_spec = None + + if request.method == 'GET': + return config.CONFIG + if request.method == 'POST': + # setup return data + message = '' # this will be populated as we progress + result_status = 'OK' + + boolean_dict = { + 'true': True, + 'false': False, + } + + data = request.form + for option, value in data.items(): + split_option = option.split('|', 1) + key = split_option[0] + setting = split_option[1] + + setting_type = config_spec[key][setting]['type'] + + # get the original value + try: + og_value = config.CONFIG[key][setting] + except KeyError: + og_value = '' + finally: + if setting_type == 'boolean': + value = boolean_dict[value.lower()] # using eval could allow code injection, so use dictionary + if setting_type == 'float': + value = float(value) + if setting_type == 'integer': + value = int(value) + + if og_value != value: + # setting changed, get the on change command + try: + setting_change_method = config_spec[key][setting]['on_change'] + except KeyError: + pass + else: + setting_change_method() + + config.CONFIG[key][setting] = value + + valid = config.validate_config(config=config.CONFIG) + + if valid: + message += 'Selected settings are valid.' + config.save_config(config=config.CONFIG) + + else: + message += 'Selected settings are not valid.' + + return jsonify({'status': f'{result_status}', 'message': f'{message}'}) + + def start_webapp(): """ Start the webapp. @@ -234,7 +370,7 @@ def start_webapp(): ... * Running on http://.../ (Press CTRL+C to quit) - >>> from pyra import threads + >>> from pyra import webapp, threads >>> threads.run_in_thread(target=webapp.start_webapp, name='Flask', daemon=True).start() * Serving Flask app 'pyra.webapp' (lazy loading) ... diff --git a/retroarcher.py b/retroarcher.py index 08502cd8d..0b3edac8f 100644 --- a/retroarcher.py +++ b/retroarcher.py @@ -181,9 +181,7 @@ def main(): from pyra import tray_icon # submodule requires translations so importing after initialization # also do not import if not required by config options - if tray_icon.icon_supported: - tray_icon.icon = tray_icon.tray_initialize() - threads.run_in_thread(target=tray_icon.tray_run, name='pystray', daemon=True).start() + tray_icon.tray_run_threaded() # start the webapp if definitions.Modes.SPLASH: # pyinstaller build only, not darwin platforms diff --git a/tests/conftest.py b/tests/conftest.py index f73acf8fe..f65567ad7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,15 +17,15 @@ from pyra import webapp -@pytest.fixture(scope='module') +@pytest.fixture(scope='function') def test_config_file(): """Set a test config file path""" - test_config_file = os.path.join(definitions.Paths().DATA_DIR, 'test_config.ini') # use a dummy ini file + test_config_file = os.path.join(definitions.Paths.DATA_DIR, 'test_config.ini') # use a dummy ini file yield test_config_file -@pytest.fixture(scope='module') +@pytest.fixture(scope='function') def test_config_object(test_config_file): """Create a test config object""" test_config_object = config.create_config(config_file=test_config_file) @@ -35,15 +35,18 @@ def test_config_object(test_config_file): yield test_config_object -@pytest.fixture(scope='module') +@pytest.fixture(scope='function') def test_pyra_init(test_config_file): test_pyra_init = pyra.initialize(config_file=test_config_file) yield test_pyra_init + pyra._INITIALIZED = False + pyra.SIGNAL = 'shutdown' -@pytest.fixture(scope='module') -def test_client(): + +@pytest.fixture(scope='function') +def test_client(test_pyra_init): """Create a test client for testing webapp endpoints""" app = webapp.app app.testing = True diff --git a/tests/functional/test_webapp.py b/tests/functional/test_webapp.py index 524a78382..57ea98ee5 100644 --- a/tests/functional/test_webapp.py +++ b/tests/functional/test_webapp.py @@ -59,6 +59,33 @@ def test_docs(test_client): assert response.status_code == 200 +def test_settings(test_client): + """ + WHEN the '/settings/' page is requested (GET) + THEN check that the response is valid + """ + response = test_client.get('/settings/') + assert response.status_code == 200 + + +def test_api_settings(test_client): + """ + WHEN the '/api/settings' page is requested (GET or POST) + THEN check that the response is valid + """ + get_response = test_client.get('/api/settings') + assert get_response.status_code == 200 + assert get_response.content_type == 'application/json' + + post_response = test_client.post('/api/settings') + assert post_response.status_code == 200 + assert post_response.content_type == 'application/json' + assert post_response.json['status'] == 'OK' + assert post_response.json['message'] == 'Selected settings are valid.' + + # todo, test posting data (valid and invalid) + + def test_status(test_client): """ WHEN the '/status' page is requested (GET) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 08be41415..d8052f018 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -4,8 +4,12 @@ Unit tests for pyra.config. """ +# standard imports +import time + # lib imports from configobj import ConfigObj +import pytest # local imports from pyra import config @@ -31,3 +35,25 @@ def test_validate_config(test_config_object): assert config_valid # todo test invalid config + + +def test_convert_config(): + result = config.convert_config() + + assert isinstance(result, list) + + +def test_on_change_tray_toggle(): + """Tests the on_change_tray_toggle function""" + from pyra import tray_icon + + if not tray_icon.icon_supported: + pytest.skip("tray icon not supported") + + original_value = tray_icon.icon_running + + result = config.on_change_tray_toggle() + assert result is True + + time.sleep(1) + assert tray_icon.icon_running is not original_value diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 78d3d6381..69ae5f979 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -4,13 +4,15 @@ Unit tests for pyra.__init__.py. """ +import pytest def test_initialize(test_pyra_init): """Tests initializing retroarcher""" + print(test_pyra_init) assert test_pyra_init +@pytest.mark.skip(reason="impossible to test as it has a sys.exit() event and won't actually return") def test_stop(): - # todo - how to test this as it has a sys.exit() event and won't actually return... pass diff --git a/tests/unit/test_tray_icon.py b/tests/unit/test_tray_icon.py index ba9fbb4b9..22124b671 100644 --- a/tests/unit/test_tray_icon.py +++ b/tests/unit/test_tray_icon.py @@ -4,6 +4,9 @@ Unit tests for pyra.tray_icon. """ +# standard imports +import time + # lib imports import pytest @@ -12,20 +15,13 @@ from pyra import tray_icon -@pytest.fixture(scope='module') -def test_tray_icon(): - """Initialize and run a test tray icon""" - test_tray_icon = tray_icon.tray_initialize() - - tray_icon.tray_run() - - yield test_tray_icon - - -def test_tray_initialize(test_tray_icon): +@pytest.fixture(scope='function') +def initial_tray(): """Test tray initialization""" - if test_tray_icon is not False: # may be False for linux - assert isinstance(test_tray_icon, tray_icon.icon_class) + tray_icon_instance = tray_icon.tray_initialize() + + if tray_icon_instance is not False: # may be False for linux + assert isinstance(tray_icon_instance, tray_icon.icon_class) # these test whether the OS supports the feature, not if the menu has the feature # assert test_tray_icon.HAS_MENU # on linux this may be False in some cases @@ -33,42 +29,89 @@ def test_tray_initialize(test_tray_icon): # assert test_tray_icon.HAS_MENU_RADIO # does not work on macOS # assert test_tray_icon.HAS_NOTIFICATION # does not work on macOS or xorg + assert tray_icon_instance.visible is False + + yield tray_icon_instance -def test_tray_run(test_tray_icon): + +@pytest.fixture(scope='function') +def tray_supported(initial_tray): """Test tray_run function""" try: tray_icon.icon_class except AttributeError: - pass + yield else: - if isinstance(test_tray_icon, tray_icon.icon_class): # may be False for linux - assert test_tray_icon + if isinstance(initial_tray, tray_icon.icon_class): # may be False for linux + assert initial_tray + yield True + else: + yield False -def test_tray_browser(test_config_object): - """Test tray_browser function""" - original_value = test_config_object['General']['LAUNCH_BROWSER'] - tray_icon.tray_browser() - new_value = test_config_object['General']['LAUNCH_BROWSER'] +@pytest.fixture(scope='function') +def running_tray(initial_tray, tray_supported): + """Test tray_run and tray_disable functions""" + if not tray_supported: + pytest.skip("tray icon not supported") - assert new_value is not original_value + tray_threaded_return_value = tray_icon.tray_run_threaded() + + time.sleep(1) # give a little time for everything to be setup + + assert isinstance(tray_threaded_return_value, bool) + assert tray_threaded_return_value is True + assert tray_icon.icon_running is True + + yield tray_threaded_return_value + if tray_threaded_return_value: + tray_icon.tray_end() + assert tray_icon.icon_running is False -def test_tray_disable(test_config_object): + +def test_tray_disable(initial_tray, running_tray, test_config_object): """Test tray_disable function""" + test_config_object['General']['SYSTEM_TRAY'] = True tray_icon.tray_disable() new_value = test_config_object['General']['SYSTEM_TRAY'] assert new_value is False + assert tray_icon.icon_running is False -def test_tray_end(test_config_object): +def test_tray_end(initial_tray, tray_supported, running_tray): """Test tray_end function""" + if not tray_supported: + pytest.skip("tray icon not supported") + tray_icon.tray_end() - new_value = test_config_object['General']['SYSTEM_TRAY'] - assert new_value is False + assert tray_icon.icon_running is False + assert initial_tray.visible is False + + +def test_tray_toggle(initial_tray, tray_supported, running_tray): + """Test tray_toggle function""" + if not tray_supported: + pytest.skip("tray icon not supported") + + for _ in range(5): + original_value = tray_icon.icon_running + tray_icon.tray_toggle() + time.sleep(1) + assert tray_icon.icon_running is not original_value + + +def test_tray_browser(test_config_object): + """Test tray_browser function""" + original_value = test_config_object['General']['LAUNCH_BROWSER'] + + tray_icon.tray_browser() + new_value = test_config_object['General']['LAUNCH_BROWSER'] + + assert new_value is not original_value def test_tray_quit(): @@ -91,9 +134,14 @@ def test_tray_restart(): def test_tray_open_browser_functions(): """Test all tray functions that open a page in a browser""" - assert tray_icon.open_webapp() - assert tray_icon.github_releases() - assert tray_icon.donate_github() - assert tray_icon.donate_mee6() - assert tray_icon.donate_patreon() - assert tray_icon.donate_paypal() + open_browser_functions = [ + tray_icon.open_webapp, + tray_icon.github_releases, + tray_icon.donate_github, + tray_icon.donate_mee6, + tray_icon.donate_patreon, + tray_icon.donate_paypal + ] + + for function in open_browser_functions: + assert function.__call__() diff --git a/web/css/custom.css b/web/css/custom.css index 70b485213..23bd0053f 100644 --- a/web/css/custom.css +++ b/web/css/custom.css @@ -154,3 +154,102 @@ iframe.feedback { .hover-zoom:hover img { transform: scale(1.1); } + +/*settings page*/ + +.advanced-setting { + /*display: none;*/ +} + +div.advanced-setting { + border-left: 1px solid #cc7b19; +} + +li.advanced-setting { + border-left: 1px solid #cc7b19; +} + +.beta-setting { + /*display: none;*/ +} + +div.beta-setting { + border-left: 1px solid #b30016; +} + +li.beta-setting { + border-left: 1px solid #b30016; +} + +.form-group, +.checkbox { + padding-left: 10px; + margin-left: -10px; + margin-bottom: 15px; +} + +.settings-alert { + /*float: left;*/ + padding: 0; + margin: 5px 0; + border: 0; + position: relative; +} + +.settings-alert ul { + padding: 0; +} + +.settings-alert ul li { + list-style: none; + padding: 5px 12px; + margin: 0; + border: 1px solid #ebccd1; + border-radius: 4px; +} + +.settings-alert ul li:before { + content: "\f071"; + font-family: "Font Awesome 6 Free"; + font-weight: 900; + margin-right: 5px; +} + +.form-control, +.form-select, +.form-select option { + border-radius: 4px; + background-color: #151515; + border-width: 0; + color: white; +} + +.alert_placeholder { + /* borrowed from widgetbot */ + display: flex; + flex-direction: column-reverse; + position: fixed; + z-index: 3147482999; /* display above widgetbot */ + padding: 7px 0px 20px; + width: 350px; + max-height: calc(70% - 100px); + right: 20px; + bottom: 76px; +} + +.form-control:disabled { + background-color: dimgray; +} + +.form-control:focus { + background-color: #151515; + color: white; +} + +.form-check-input:not(:checked):disabled { + background-color: dimgray; +} + +.form-check-input:not(:checked):enabled { + background-color: #151515; +} diff --git a/web/css/sidebar.css b/web/css/sidebar.css new file mode 100644 index 000000000..cf456418f --- /dev/null +++ b/web/css/sidebar.css @@ -0,0 +1,106 @@ +/*https://codepen.io/lfrichter/pen/mQJJyB*/ +#wrapper { + -webkit-transition: all 0.5s ease; + -moz-transition: all 0.5s ease; + -o-transition: all 0.5s ease; + transition: all 0.5s ease; +} + +#wrapper.sidebar-expanded { + padding-left: 250px; +} + +#wrapper.sidebar-collapsed { + padding-left: 80px; +} + +#sidebar-wrapper { + top: 80px; + position: fixed; + height: 100vh; + background-color: #151515; + /*padding: 0;*/ + overflow-y: scroll; + overflow-x: hidden; + -webkit-transition: all 0.5s ease; + -moz-transition: all 0.5s ease; + -o-transition: all 0.5s ease; + transition: all 0.5s ease; +} + +#sidebar-wrapper.sidebar-expanded { + width: 250px; + left: 250px; + margin-left: -250px; +} + +#sidebar-wrapper.sidebar-collapsed { + width: 80px; + left: 80px; + margin-left: -80px; +} + +#page-content-wrapper { + position: absolute; + padding: 10px; +} + +#page-content-wrapper.sidebar-expanded { + /*calc method - https://stackoverflow.com/a/14101451/11214013*/ + width: -moz-calc(100% - 250px); /* Firefox */ + width: -webkit-calc(100% - 250px); /* WebKit */ + width: -o-calc(100% - 250px); /* Opera */ + width: calc(100% - 250px); /* Standard */ +} + +#page-content-wrapper.sidebar-collapsed { + /*calc method - https://stackoverflow.com/a/14101451/11214013*/ + width: -moz-calc(100% - 80px); /* Firefox */ + width: -webkit-calc(100% - 80px); /* WebKit */ + width: -o-calc(100% - 80px); /* Opera */ + width: calc(100% - 80px); /* Standard */ +} + +/* ----------| Menu item*/ +#sidebar-wrapper .list-group a { + height: 50px; + color: white; +} + +/* ----------| Submenu item*/ +#sidebar-wrapper .list-group li.list-group-item { + background-color: #151515; +} + +#sidebar-wrapper .list-group .sidebar-submenu a { + height: 45px; + padding-left: 30px; +} + +.sidebar-submenu { + font-size: 0.9rem; +} + +/* ----------| Separators */ +.sidebar-separator-title { + background-color: #151515; + height: 35px; +} + +.sidebar-separator { + background-color: #151515; + height: 25px; +} + +.logo-separator { + background-color: #151515; + height: 60px; +} + +a.bg-dark { + background-color: #151515 !important; +} + +a.bg-dark:not(.no-hover):hover { + background-color: #303436 !important; +} diff --git a/web/js/show_alert.js b/web/js/show_alert.js new file mode 100644 index 000000000..8d01aec91 --- /dev/null +++ b/web/js/show_alert.js @@ -0,0 +1,42 @@ +// create alert placeholder container +let alert_placeholder = document.createElement('div'); +alert_placeholder.id = 'alert_placeholder'; +alert_placeholder.className = 'container alert_placeholder'; +document.body.appendChild(alert_placeholder); + +function showAlert(message, alert_type = 'alert-info', icon_class = null, timeout = null) { + // get current timestamp + let now = Date.now() + + // create the alert div + let alert_div = document.createElement('div'); + alert_div.id = `alert_div_${now}`; + alert_div.className = `alert ${alert_type} alert-dismissible fade show`; + alert_div.setAttribute('role', 'alert'); + alert_div.textContent = message; + + // create the icon and prepend it to the message + if (icon_class !== null) { + let icon = document.createElement('i'); + icon.className = icon_class; + alert_div.prepend(icon); + } + + // create the alert close button + let alert_close = document.createElement('button'); + alert_close.type = 'button'; + alert_close.className = 'btn-close'; + alert_close.setAttribute('data-bs-dismiss', 'alert'); + alert_close.setAttribute('aria-label', 'Close'); + + // append the elements to the placeholder + alert_div.appendChild(alert_close); + alert_placeholder.appendChild(alert_div); + + // close alert after timeout + if (timeout !== null) { + setTimeout(function () { + $(`#alert_div_${now}`).remove(); + }, timeout); + } +} diff --git a/web/js/sidebar.js b/web/js/sidebar.js new file mode 100644 index 000000000..f1c31ee9d --- /dev/null +++ b/web/js/sidebar.js @@ -0,0 +1,22 @@ +//https://codepen.io/lfrichter/pen/mQJJyB + +// Collapse/Expand icon +button = $('#collapse-icon') +button.addClass('fa-xmark'); + +// Collapse click +$('[data-toggle=sidebar-collapse]').click(function() { + SidebarCollapse(); +}); + +function SidebarCollapse () { + $('.sidebar-separator-title').toggleClass('invisible'); + $('.menu-collapsed').toggleClass('d-none'); + $('#wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('#sidebar-wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('#page-content-wrapper').toggleClass('sidebar-expanded sidebar-collapsed'); + $('.sidebar-item').toggleClass('justify-content-start justify-content-center') + + // Collapse/Expand icon + button.toggleClass('fa-xmark fa-bars'); +} diff --git a/web/templates/base.html b/web/templates/base.html index 32e43d406..e9bfd8b27 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -21,6 +21,7 @@ + @@ -28,21 +29,25 @@ - - + {% block head %}{% endblock head %} + {% block modals %}{% endblock %}
{% include 'navbar.html' %} -
- - {% block content %}{% endblock %} + {% block content %}{% endblock content %}
- + + + + + + {% block scripts %}{% endblock %} + diff --git a/web/templates/config.html b/web/templates/config.html new file mode 100644 index 000000000..c7d5a60f7 --- /dev/null +++ b/web/templates/config.html @@ -0,0 +1,232 @@ +{% extends 'base.html' %} +{% block head %} + + +{% endblock head %} + +{% block modals %} +{% endblock modals %} + +{% block content %} + +{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/web/templates/home.html b/web/templates/home.html index ddc286d5e..56371e484 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1,11 +1,14 @@ {% extends 'base.html' %} +{% block head %} +{% endblock head %} + {% block content %}
-
+
{% for chart in chart_types %}
{{ _(translations[chart]['bare']) }}
@@ -21,8 +24,9 @@
+{% endblock content %} - +{% block scripts %} -{% endblock content %} +{% endblock scripts %} diff --git a/web/templates/navbar.html b/web/templates/navbar.html index 70c994ad9..a81716760 100644 --- a/web/templates/navbar.html +++ b/web/templates/navbar.html @@ -1,4 +1,4 @@ -