diff --git a/Makefile b/Makefile index 3a95fca..346b37c 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,11 @@ BLACK_OPTS = --exclude templates ${SRC_DIRS} runserver: ## Run a development server tutor dash run --dev -scss: ## Compile SCSS files +css: ## Compile SCSS files to CSS sass ${SASS_OPTS} tutordash/server/static/scss/:tutordash/server/static/css/ -scss-watch: ## Compile SCSS files and watch for changes - $(MAKE) scss SASS_OPTS="--watch" +css-watch: ## Compile SCSS files to CSS and watch for changes + $(MAKE) css SASS_OPTS="--watch" # Warning: These checks are not necessarily run on every PR. test: test-lint test-types test-format # Run some static checks. diff --git a/README.rst b/README.rst index a5058e2..e025e36 100644 --- a/README.rst +++ b/README.rst @@ -11,6 +11,8 @@ Installation pip install git+https://github.com/overhangio/tutor-dash +.. TODO how to package css files? + Usage ***** diff --git a/setup.py b/setup.py index 5487cac..2e4b699 100644 --- a/setup.py +++ b/setup.py @@ -6,13 +6,13 @@ from setuptools import find_packages, setup HERE = os.path.abspath(os.path.dirname(__file__)) -def load_readme(): +def load_readme() -> str: with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f: return f.read() -def load_about(): - about = {} +def load_about() -> dict[str, str]: + about: dict[str, str] = {} with io.open( os.path.join(HERE, "tutordash", "__about__.py"), "rt", @@ -43,13 +43,13 @@ setup( include_package_data=True, python_requires=">=3.9", install_requires=[ - "tutor>=18.0.0,<19.0.0", + "tutor>=19.0.0,<20.0.0", "quart", "aiofiles", ], extras_require={ "dev": [ - "tutor[dev]>=18.0.0,<19.0.0", + "tutor[dev]>=19.0.0,<20.0.0", "quart", "aiofiles", "types-aiofiles", diff --git a/tutordash/__about__.py b/tutordash/__about__.py index c6a8b8e..0122a6f 100644 --- a/tutordash/__about__.py +++ b/tutordash/__about__.py @@ -1 +1 @@ -__version__ = "18.0.0" +__version__ = "19.0.0" diff --git a/tutordash/server/app.py b/tutordash/server/app.py index d042771..6087833 100644 --- a/tutordash/server/app.py +++ b/tutordash/server/app.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import json import shlex import subprocess import tempfile @@ -16,6 +17,7 @@ from quart import ( url_for, ) from quart.helpers import WerkzeugResponse +from quart.typing import ResponseTypes import tutor.env from tutor.exceptions import TutorError @@ -280,6 +282,7 @@ app = Quart( static_url_path="/static", static_folder="static", ) +SHUTDOWN_EVENT = asyncio.Event() def run(root: str, **app_kwargs: t.Any) -> None: @@ -287,9 +290,32 @@ def run(root: str, **app_kwargs: t.Any) -> None: Bootstrap the Quart app and run it. """ TutorProject.connect(root) + + # Manually shutdown application on SIGINT/SIGTERM + # This does not work in development because the signal handler is overridden by app.run(). + # loop = asyncio.new_event_loop() + # asyncio.set_event_loop(loop) + # Monitor disconnection + # loop.background_tasks.add(task) + # app.background_tasks.add(task) + + # import signal + # for s in (signal.SIGINT, signal.SIGTERM): + # loop.add_signal_handler(s, shutdown) + + # TODO app.run() should be called only in development + # app.run(loop=loop, **app_kwargs) app.run(**app_kwargs) +def shutdown() -> None: + """ + For now, this function is not called anywhere + """ + app.logger.info("Shutdown requested...") + SHUTDOWN_EVENT.set() + + @app.get("/") async def home() -> str: return await render_template("index.html", **shared_template_context()) @@ -308,34 +334,19 @@ async def plugin(name: str) -> str: @app.post("/plugin//toggle") -async def toggle_plugin(name: str) -> dict[str, str]: +async def toggle_plugin(name: str) -> WerkzeugResponse: # TODO check plugin exists form = await request.form - enabled = form.get("enabled") - if enabled not in ["on", "off"]: - # TODO request validation. Can't we validate requests with a proper tool, such - # as pydantic or a rest framework? - return {} - - # TODO actually toggle plugin - app.logger.info("Toggling plugin %s", name) - - return {} + enable_plugin = form.get("enabled") == "on" + return tutor_cli(["plugins", "enable" if enable_plugin else "disable", name]) -@app.post("/tutor/cli") -async def tutor_cli() -> WerkzeugResponse: +def tutor_cli(command: list[str]) -> WerkzeugResponse: # Run command asynchronously # if TutorCli.is_thread_alive(): # TODO return 400 if thread is active # TODO parse command from JSON request body - TutorCliPool.run_parallel( - ["dev", "start"], - # ["dev", "dc", "run", "--no-deps", "lms", "bash"], - # ["config", "printvalue", "DOCKER_IMAGE_OPENEDX"], - # ["config", "printvalue", "POUAC"], - # ["local", "launch", "--non-interactive"], - ) + TutorCliPool.run_parallel(command) return redirect(url_for("tutor_cli_logs")) @@ -351,18 +362,36 @@ async def tutor_cli_logs() -> str: @app.get("/tutor/cli/logs/stream") -async def tutor_cli_logs_stream() -> None: - # Websockets were not working for us in dev mode, we were unable to stop the server - # as long as there were open connection. We only need single-direction - # communication, so we use server-sent events - # https://github.com/pallets/quart/issues/333 - # https://quart.palletsprojects.com/en/latest/how_to_guides/server_sent_events.html - async def send_events(): +async def tutor_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: + if SHUTDOWN_EVENT.is_set(): + # TODO explain this + break # TODO this is again causing the stream to never stop... async for data in TutorCliPool.iter_logs(): - event = f"data: {data}\nevent: logs\n" - # TODO encode one way or another to be able to send EOL characters and other weird chars + # TODO important encode one way or another to be able to send EOL characters and other weird chars + json_data = json.dumps(data) + event = f"data: {json_data}\nevent: logs\n\n" yield event.encode() await asyncio.sleep(SHORT_SLEEP_SECONDS) @@ -374,7 +403,7 @@ async def tutor_cli_logs_stream() -> None: "Transfer-Encoding": "chunked", }, ) - response.timeout = None + setattr(response, "timeout", None) return response @@ -386,4 +415,5 @@ def shared_template_context() -> dict[str, t.Any]: """ return { "installed_plugins": sorted(set(hooks.Filters.PLUGINS_INSTALLED.iterate())), + "enabled_plugins": sorted(set(hooks.Filters.PLUGINS_LOADED.iterate())), } diff --git a/tutordash/server/static/scss/dash.scss b/tutordash/server/static/scss/dash.scss index f1d0946..9f6cd77 100644 --- a/tutordash/server/static/scss/dash.scss +++ b/tutordash/server/static/scss/dash.scss @@ -55,7 +55,7 @@ body { .workspace { flex: 1; padding: 20px; - overflow: scroll; + overflow: hidden; .header { margin-bottom: 16px; @@ -66,6 +66,14 @@ body { .content { outline: none; min-height: 200px; + height: 100%; + overflow: hidden; + padding-bottom: 20px; // necessary to display the bottom scrollbar + + pre { + overflow: scroll; + height: calc(100% - 20px); // must match .content padding-bottom to display scrollbar + } } } } diff --git a/tutordash/server/templates/index.html b/tutordash/server/templates/index.html index 4b3877d..2f983cf 100644 --- a/tutordash/server/templates/index.html +++ b/tutordash/server/templates/index.html @@ -43,13 +43,8 @@