Régis Behmo 3d7b665a6b feat: add a configuration panel
This panel displays all the main configuration item. Note however that
there are still many TODO items, we should really clean them.
2025-08-14 16:14:39 +02:00

501 lines
15 KiB
Python

import asyncio
import json
import logging
import sys
import typing as t
import importlib_metadata
from markdown import markdown
from quart import (
Quart,
Response,
g,
jsonify,
make_response,
redirect,
render_template,
request,
url_for,
)
from quart.typing import ResponseTypes
from werkzeug.sansio.response import Response as BaseResponse
from tutor.plugins.v1 import discover_package
from tutordeck.server.utils import current_page_plugins, pagination_context
from . import constants, tutorclient
app = Quart(
__name__,
static_url_path="/static",
static_folder="static",
)
def run(root: str, **app_kwargs: t.Any) -> None:
"""
Bootstrap the Quart app and run it.
"""
tutorclient.Project.connect(root)
# Configure logging
formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
tutorclient.logger.addHandler(handler)
tutorclient.logger.setLevel(logging.INFO)
# Configure authentication
HttpAuthCredentials.load_credentials()
# TODO app.run() should be called only in development
app.run(**app_kwargs)
class HttpAuthCredentials:
USERNAME: str = ""
PASSWORD: str = ""
@classmethod
def load_credentials(cls) -> None:
"""
Note that credentials will not be automatically reloaded on configuration change.
TODO reload credentials automatically when needed.
"""
config = tutorclient.Project.get_config()
cls.USERNAME = t.cast(str, config.get("DECK_AUTH_USERNAME", ""))
cls.PASSWORD = t.cast(str, config.get("DECK_AUTH_PASSWORD", ""))
@classmethod
def is_auth_success(cls) -> bool:
"""
Returns True if the current request has the right HTTP basic auth credentials.
"""
if not cls.USERNAME or not cls.PASSWORD:
# No credential required
return True
if not request.authorization:
# No credential was provided
return False
# Check provided credentials
username = request.authorization.parameters.get("username")
password = request.authorization.parameters.get("password")
return username == cls.USERNAME and password == cls.PASSWORD
@app.before_request
def http_basic_auth() -> None | tuple[str, int, dict[str, str]]:
"""
Check authentication headers if necessary.
"""
if not HttpAuthCredentials.is_auth_success():
# https://quart.palletsprojects.com/en/latest/reference/response_values/#tuple-str-int-dict-str-str
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/401
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#authentication_schemes
return "", 401, {"WWW-Authenticate": "basic"}
return None
@app.before_request
async def before_request() -> None:
"""
Store installed and enabled plugins as global attributes.
"""
# Shared views and template context
g.installed_plugins = tutorclient.Client.installed_plugins()
g.enabled_plugins = tutorclient.Client.enabled_plugins()
@app.get("/")
async def home() -> BaseResponse:
"""
Home redirects to the list of installed plugins
"""
return redirect(url_for("plugin_installed"))
@app.get("/configuration")
async def configuration() -> str:
config = tutorclient.Project.get_config()
# Load base config with essential settings
base_settings = [
"LMS_HOST",
"CMS_HOST",
"LANGUAGE_CODE",
"ENABLE_HTTPS",
]
base_config = {key: config.pop(key) for key in base_settings}
# User-saved configuration
user_config = tutorclient.Project.get_user_config()
return await render_template(
"configuration.html",
base_config=base_config,
user_config=user_config,
config=dict(sorted(config.items())),
)
@app.post("/configuration")
async def configuration_update() -> BaseResponse:
"""
Update configuration settings.
TODO display "need to run launch".
"""
await process_config_update_request()
# Handle non-ajax call
next_url = request.args.get("next", "")
if next_url:
return redirect(next_url)
# Handle ajax call
response = Response("", status=200, content_type="text/html")
response.headers["HX-Redirect"] = url_for("configuration")
return response
@app.get("/plugin/store")
async def plugin_store() -> str:
return await render_template("plugin_store.html")
@app.get("/plugin/installed")
async def plugin_installed() -> str:
return await render_template("plugin_installed.html")
@app.get("/plugin/store/list")
async def plugin_store_list() -> str:
search_query = request.args.get("search", "")
plugins: list[dict[str, t.Any]] = [
{
"name": p.name,
"url": p.url,
"index": p.index,
"author": tutorclient.Client.get_plugin_author(p),
"description": p.short_description,
"is_installed": p.name in g.installed_plugins,
"is_enabled": p.name in g.enabled_plugins,
}
for p in tutorclient.Client.plugins_in_store()
if p.name in tutorclient.Client.plugins_matching_pattern(search_query)
]
current_page = int(request.args.get("page", "1"))
plugins = current_page_plugins(plugins, current_page)
pagination = pagination_context(plugins, current_page)
return await render_template(
"_plugin_store_list.html",
plugins=plugins,
pagination=pagination,
)
@app.get("/plugin/installed/list")
async def plugin_installed_list() -> str:
# TODO IMPORTANT this displays only the plugins that are in the store. When a plugin
# is installed locally but not in the store, we must display it here anyway.
# TODO this is duplicated code from plugin_store_list
search_query = request.args.get("search", "")
plugins: list[dict[str, t.Any]] = [
{
"name": p.name,
"url": p.url,
"index": p.index,
"author": tutorclient.Client.get_plugin_author(p),
"description": p.short_description,
"is_enabled": p.name in g.enabled_plugins,
}
for p in tutorclient.Client.plugins_in_store()
if p.name in tutorclient.Client.plugins_matching_pattern(search_query)
and p.name in g.installed_plugins
]
return await render_template(
"_plugin_installed_list.html",
plugins=plugins,
)
@app.get("/plugin/<name>")
async def plugin(name: str) -> Response:
index_entry = tutorclient.Client.plugin_in_store(name)
if not index_entry:
return Response("Plugin not found", status=404)
# TODO this seq_command_executed argument is confusing and causing issues, for
# instance with the "unset" button. We need to get rid of it.
seq_command_executed = request.args.get("seq_command_executed")
description = markdown(index_entry.description)
rendered_template = await render_template(
"plugin.html",
plugin_name=name,
is_enabled=name in g.enabled_plugins,
is_installed=name in g.installed_plugins,
author_name=tutorclient.Client.get_plugin_author(index_entry),
plugin_description=description,
seq_command_executed=seq_command_executed,
plugin_config_unique=tutorclient.Client.plugin_config_unique(name),
plugin_config_defaults=tutorclient.Client.plugin_config_defaults(name),
user_config=tutorclient.Project.get_user_config(),
)
# Redirect to plugin page
# TODO this is useful only after a POST to plugin/<name>/update. I don't think these two things should be handled in the same place.
response = Response(rendered_template, status=200, content_type="text/html")
response.headers["HX-Redirect"] = url_for(
"plugin", name=name, seq_command_executed=seq_command_executed
)
return response
@app.get("/plugin/<name>/is-installed")
def plugin_installed_status(name: str) -> Response:
return jsonify({"installed": name in g.installed_plugins})
@app.post("/plugin/<name>/toggle")
async def plugin_toggle(name: str) -> Response:
# TODO check plugin exists
form = await request.form
enable_plugin = form.get("checked") == "on"
tutorclient.CliPool.run_sequential(
["plugins", "enable" if enable_plugin else "disable", name]
)
# TODO error management
response = t.cast(
Response,
await make_response(
redirect(
url_for(
"plugin",
name=name,
seq_command_executed=True,
)
)
),
)
if enable_plugin:
update_plugins_requiring_launch(response, add=name)
else:
update_plugins_requiring_launch(response, remove=name)
return response
@app.post("/plugin/<name>/install")
async def plugin_install(name: str) -> BaseResponse:
async def bg_install_and_reload() -> None:
tutorclient.CliPool.run_parallel(app, ["plugins", "install", name])
while tutorclient.CliPool.THREAD and tutorclient.CliPool.THREAD.is_alive():
await asyncio.sleep(0.1)
# TODO this is hackish. How can we improve?
discover_package(importlib_metadata.entry_points().__getitem__(name))
asyncio.create_task(bg_install_and_reload())
return redirect(
url_for(
"plugin",
name=name,
)
)
@app.post("/plugin/<name>/upgrade")
async def plugin_upgrade(name: str) -> BaseResponse:
tutorclient.CliPool.run_parallel(app, ["plugins", "upgrade", name])
return redirect(
url_for(
"plugin",
name=name,
)
)
@app.post("/plugins/update")
async def plugins_update() -> BaseResponse:
tutorclient.CliPool.run_sequential(["plugins", "update"])
return redirect(url_for("plugin_store"))
@app.post("/plugin/<name>/config/update")
async def plugin_config_update(name: str) -> Response:
await process_config_update_request()
response = t.cast(
Response,
await make_response(
redirect(
url_for(
"plugin",
name=name,
seq_command_executed=True,
)
)
),
)
update_plugins_requiring_launch(response, add=name)
return response
async def process_config_update_request() -> None:
"""
Set/Unset config key/values based on request form.
TODO how to handle configuration changes? For instance: reloading
"""
form = await request.form
if unset := form.get("unset"):
tutorclient.CliPool.run_sequential(["config", "save", f"--unset={unset}"])
else:
cmd = ["config", "save"]
for key, value in form.items():
if value.startswith("{{"):
# Templated values that start with {{ should be explicitely converted to string
# Otherwise there will be a parsing error because it might be considered a dictionary
value = f"'{value}'"
cmd.extend(["--set", f"{key}={value}"])
tutorclient.CliPool.run_sequential(cmd)
# TODO error management
@app.get("/local/launch")
async def local_launch_view() -> str:
return await render_template(
"local_launch.html",
)
@app.post("/cli/local/launch")
async def cli_local_launch() -> str:
tutorclient.CliPool.run_parallel(app, ["local", "launch", "--non-interactive"])
return await render_template(
"local_launch.html",
)
@app.get("/cli/logs/stream")
async def cli_logs_stream() -> ResponseTypes:
"""
We only need single-direction communication, so we use server-sent events, and not
websockets.
https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html
Note that server interruption with ctrl+c does not work in Python 3.12 and 3.13
because of this bug:
https://github.com/pallets/quart/issues/333
https://github.com/python/cpython/issues/123720
Events are sent with the following format:
data: "json-encoded string..."
event: logs
Data is JSON-encoded such that we can sent newline characters, etc.
"""
# TODO check that request accepts event stream (see howto)
async def send_events() -> t.AsyncIterator[bytes]:
while True:
# TODO this is again causing the stream to never stop...
async for data in tutorclient.CliPool.iter_logs():
event = f"""data: {
json.dumps(
{
"stdout": data,
"command": tutorclient.CliPool.current_command(),
"thread_alive": tutorclient.CliPool.is_thread_alive(),
}
)
}\nevent: logs\n\n"""
yield event.encode()
await asyncio.sleep(constants.SHORT_SLEEP_SECONDS)
response = await make_response(
send_events(),
{
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
},
)
setattr(response, "timeout", None)
return response
@app.post("/cli/stop")
async def cli_stop() -> Response:
tutorclient.CliPool.stop()
return Response(status=200)
@app.get("/advanced")
async def advanced() -> str:
return await render_template(
"advanced.html",
)
@app.post("/suggest")
async def suggest() -> Response:
data = await request.get_json()
partial_command = data.get("command", "")
suggestions = tutorclient.Client.autocomplete(partial_command)
return jsonify(suggestions)
@app.post("/command")
async def command() -> BaseResponse:
form = await request.form
command_string = form.get("command", "")
command_args = command_string.split()
tutorclient.CliPool.run_parallel(app, command_args)
return redirect(url_for("advanced"))
def update_plugins_requiring_launch(
response: Response, add: str | None = None, remove: str | None = None
) -> None:
"""
Store the list of plugins for which a recent set of changes require running "local launch".
This list is stored as a "+"-separated string in a cookie. Note that flask will automatically put the content in quotes.
"""
# Note that comma, colon and semi-colon are not supported in cookie values
separator = "+"
# Get current plugins
names = set(
[
cookie
for cookie in request.cookies.get(
constants.PLUGINS_REQUIRE_LAUNCH_COOKIE_NAME, ""
).split(separator)
if cookie
]
)
# Add new plugins
if add:
names.add(add)
# Remove plugins
if remove:
names.discard(remove)
# Update the response
response.set_cookie(
constants.PLUGINS_REQUIRE_LAUNCH_COOKIE_NAME,
separator.join(sorted(names)),
max_age=60 * 60 * 24 * 30, # 1 month
)