"""
..
webapp.py
Responsible for serving the webapp.
"""
# standard imports
import os
from typing import Optional
# lib imports
from flask import Flask, Response
from flask import jsonify, render_template as flask_render_template, request, send_from_directory
from flask_babel import Babel
# local imports
import pyra
from pyra import config
from pyra import hardware
from pyra.definitions import Paths
from pyra import locales
from pyra import logger
# localization
_ = locales.get_text()
# setup flask app
app = Flask(
import_name=__name__,
root_path=os.path.join(Paths.ROOT_DIR, 'web'),
static_folder=os.path.join(Paths.ROOT_DIR, 'web'),
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,
default_locale=locales.default_locale,
default_timezone=locales.default_timezone,
default_translation_directories=Paths.LOCALE_DIR,
default_domain=locales.default_domain,
configure_jinja=True,
locale_selector=locales.get_locale
)
# setup logging for flask
log_handlers = logger.get_logger(name=__name__).handlers
for handler in log_handlers:
app.logger.addHandler(handler)
[docs]
def render_template(template_name_or_list, **context):
"""
Render a template, while providing our default context.
This function is a wrapper around ``flask.render_template``.
Our UI config is added to the template context.
In the future, this function may be used to add other default contexts to templates.
Parameters
----------
template_name_or_list : str
The name of the template to render.
**context
The context to pass to the template.
Returns
-------
render_template
The rendered template.
Examples
--------
>>> render_template(template_name_or_list='home.html', title=_('Home'))
"""
context['ui_config'] = pyra.CONFIG['User_Interface'].copy()
return flask_render_template(template_name_or_list=template_name_or_list, **context)
[docs]
@app.route('/')
@app.route('/home')
def home() -> render_template:
"""
Serve the webapp home page.
.. todo:: This documentation needs to be improved.
Returns
-------
render_template
The rendered page.
Notes
-----
The following routes trigger this function.
`/`
`/home`
Examples
--------
>>> home()
"""
chart_types = hardware.chart_types()
chart_translations = hardware.chart_translations
return render_template('home.html', title=_('Home'), chart_types=chart_types, translations=chart_translations)
[docs]
@app.route('/callback/dashboard', methods=['GET'])
def callback_dashboard() -> Response:
"""
Get dashboard data.
This should be used in a callback in order to update charts in the web app.
Returns
-------
Response
A response formatted as ``flask.jsonify``.
See Also
--------
pyra.hardware.chart_data : This function sets up the data in the proper format.
Examples
--------
>>> callback_dashboard()
<Response ... bytes [200 OK]>
"""
graphs = hardware.chart_data()
data = jsonify(graphs)
return data
[docs]
@app.route('/settings/', defaults={'configuration_spec': None})
@app.route('/settings/<path:configuration_spec>')
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)
[docs]
@app.route('/docs/', defaults={'filename': 'index.html'})
@app.route('/docs/<path:filename>')
def docs(filename) -> send_from_directory:
"""
Serve the Sphinx html documentation.
.. todo:: This documentation needs to be improved.
Parameters
----------
filename : str
The html filename to return.
Returns
-------
flask.send_from_directory
The requested documentation page.
Notes
-----
The following routes trigger this function.
`/docs/`
`/docs/<page.html>`
Examples
--------
>>> docs(filename='index.html')
"""
return send_from_directory(directory=os.path.join(Paths.DOCS_DIR), path=filename)
[docs]
@app.route('/favicon.ico')
def favicon() -> send_from_directory:
"""
Serve the favicon.ico file.
.. todo:: This documentation needs to be improved.
Returns
-------
flask.send_from_directory
The ico file.
Notes
-----
The following routes trigger this function.
`/favicon.ico`
Examples
--------
>>> favicon()
"""
return send_from_directory(directory=os.path.join(app.static_folder, 'images'),
path='retroarcher.ico', mimetype='image/vnd.microsoft.icon')
[docs]
@app.route('/status')
def status() -> dict:
"""
Check the status of RetroArcher.
This is useful for a healthcheck from Docker, and may have many other uses in the future for third party
applications.
Returns
-------
dict
A dictionary of the status.
Examples
--------
>>> status()
"""
web_status = {'result': 'success', 'message': 'Ok'}
return web_status
[docs]
@app.route('/test_logger')
def test_logger() -> str:
"""
Test logging functions.
Check `./logs/pyra.webapp.log` for output.
Returns
-------
str
A message telling the user to check the logs.
Notes
-----
The following routes trigger this function.
`/test_logger`
Examples
--------
>>> test_logger()
"""
app.logger.info('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.'
[docs]
@app.route('/api/settings', methods=['GET', 'POST'], defaults={'configuration_spec': None})
@app.route('/api/settings/<path:configuration_spec>')
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()
<Response ... bytes [200 OK]>
"""
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}'})
[docs]
def start_webapp():
"""
Start the webapp.
Start the flask webapp. This is placed in it's own function to allow the ability to start the webapp within a
thread in a simple way.
Examples
--------
>>> start_webapp()
* Serving Flask app 'pyra.webapp' (lazy loading)
...
* Running on http://.../ (Press CTRL+C to quit)
>>> 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)
...
* Running on http://.../ (Press CTRL+C to quit)
"""
app.run(
host=config.CONFIG['Network']['HTTP_HOST'],
port=config.CONFIG['Network']['HTTP_PORT'],
debug=pyra.DEV,
use_reloader=False # reloader doesn't work when running in a separate thread
)