From 07b8238904e96f22ae0ece2503a73737b47c7786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Sun, 6 Jul 2025 20:53:04 +0200 Subject: [PATCH 1/2] Student dashboard: open assignment with a link rather than a call to ipylab The student dashboard includes buttons to open the assignments. Up to now the opening itself was done by calling the JupyterLab interface through ipylab. After this change, the button is replaced by a simple html link, styled as a button. Advantages: - The student dashboard does not depend on ipylab anymore. - This is a step toward removing a little used dependency. - It's also a step toward integration in dashboard (like voila) that sometimes blocks the interaction with the surrounding jupyterlab. - The user can check in advance in the browser which file will be open upon clicking. Caveat: plain html links are not yet supported by jupyterlab-myst 2.4.2. See https://github.com/jupyter-book/jupyterlab-myst/issues/64. Other changes: - The action is made active as soon as the assignment's work dir exists, even if the student is not yet authentified. - The action is deactivated if there is no index or README file in the work dir to point to. --- travo/dashboards.py | 135 +++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 59 deletions(-) diff --git a/travo/dashboards.py b/travo/dashboards.py index 399e40b..f9c3c85 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -20,6 +20,7 @@ import logging import os import subprocess import requests +import textwrap from threading import Thread from typing import Any, Callable, Dict, List, Optional, Tuple, Union @@ -48,6 +49,7 @@ except ImportError: ) from IPython.display import display, Javascript # type: ignore +import traitlets from .assignment import Assignment @@ -507,6 +509,62 @@ class StatusBar(VBox): ).start() +class LinkAsButton(ipywidgets.HTML): + """ + A widget displaying an html link with a button-like style and interface + """ + + disabled = traitlets.Bool(False) + + def __init__( + self, + description: str, + target: Optional[str] = None, + button_style: str = "", + icon: Optional[str] = None, + tooltip: Optional[str] = None, + layout: Optional[Layout] = None, + disabled: bool = False, + ): + super().__init__("") + + self.target = target + + if icon: + description = f'' + description + self.button_content = description + + classes = "lm-Widget jupyter-widgets jupyter-button widget-button" + if button_style: + classes += f" mod-{button_style}" + self.classes = classes + + self.title = tooltip + + if disabled: + self.disabled = disabled # will trigger an update + else: + self.update() + + def update(self): + opacity = 0.5 if self.disabled else 1 + content = textwrap.dedent( + f""" + + {self.button_content} + + """ + ) + if self.target is not None: + content = f'{content}' + + self.value = content + + @traitlets.observe("disabled") + def _disabled_changed(self, change): + self.update() + + class AssignmentStudentDashboard(HBox): def __init__( self, @@ -526,10 +584,6 @@ class AssignmentStudentDashboard(HBox): ) self.status_bar = status_bar - from ipylab import JupyterFrontEnd # type: ignore - - self.jupyter_front_end = JupyterFrontEnd() - layout = Layout(width="initial") self.nameUI = HTML(self.name, layout=layout) self.fetchUI = Button( @@ -551,7 +605,7 @@ class AssignmentStudentDashboard(HBox): ) self.submitUI.on_click(lambda event: self.submit()) - self.work_dir_UI = Button( + self.work_dir_UI = LinkAsButton( description=_("open"), button_style="primary", icon="edit", @@ -559,7 +613,6 @@ class AssignmentStudentDashboard(HBox): layout=layout, disabled=True, ) - self.work_dir_UI.on_click(self.open_work_dir_callback) self.scoreUI = HTML("", tooltip=_("browse feedback")) self.updateUI = Button( description="", @@ -602,6 +655,22 @@ class AssignmentStudentDashboard(HBox): Thread(target=self.update).start() def update(self) -> None: + # First take care of the UI for the local work dir for the assignment + # as it does not require authentication + self.work_dir_UI.disabled = True + has_assignment_dir = ( + self.assignment.assignment_dir is not None + and os.path.exists(self.assignment.assignment_dir) + ) + if has_assignment_dir: + index_files = ["index.md", "index.ipynb", "README.md", "README.ipynb"] + for x in index_files: + file = os.path.join(self.assignment.assignment_dir, x) + if os.path.isfile(file): + self.work_dir_UI.target = file + self.work_dir_UI.disabled = False + break + # For now, fetching the assignment status requires the user to # be logged in. Fails gracefuly if this is not yet the case. try: @@ -647,13 +716,7 @@ class AssignmentStudentDashboard(HBox): self.submissionUI.value = "" self.submitUI.tooltip = _("submit assignment", assignment_name=self.name) - self.submitUI.disabled = True - self.work_dir_UI.disabled = True - if self.assignment.assignment_dir is not None and os.path.exists( - self.assignment.assignment_dir - ): - self.work_dir_UI.disabled = False - self.submitUI.disabled = False + self.submitUI.disabled = not has_assignment_dir if status.autograde_status == "success": # these two s/could be provided by autograde_status @@ -698,52 +761,6 @@ class AssignmentStudentDashboard(HBox): on_success=self.update, ) - def open_work_dir_callback(self, event: Any) -> None: - self.work_dir_UI.disabled = True - self.status_bar.run( # run_in_subthread( - action=_("open work dir", assignment_name=self.name), - command=self.open_work_dir, - on_finished=lambda: setattr(self.work_dir_UI, "disabled", False), - ) - - def open_work_dir(self) -> None: - if self.assignment.assignment_dir is None: - raise ValueError(_("cannot open work dir unset")) - if os.path.isabs(self.assignment.assignment_dir): - raise NotImplementedError( - _( - _("cannot open work dir absolute"), - work_dir=self.assignment.assignment_dir, - ) - ) - path = os.path.dirname(self.jupyter_front_end.sessions.current_session["path"]) - # TODO : if README.md does not exist neither, try to open gitlab file browser - index_files = ["index.md", "index.ipynb", "README.md", "README.ipynb"] - for x in index_files: - file = os.path.join(self.assignment.assignment_dir, x) - if os.path.isfile(file): - self.jupyter_front_end.commands.execute( - "docmanager:open", - { - "path": path + "/" + file, # Generalize if there is no index.md - "factory": "Notebook", - # 'options': { - # 'mode': 'split-right' - # }, - "kernelPreference": { - "shutdownOnClose": True, - }, - }, - ) - return - raise FileNotFoundError( - _( - "no index file", - assignment_dir=self.assignment.assignment_dir, - index_files=" ".join(index_files), - ) - ) - def other_action(self) -> None: action = self.other_actionsUI.value if not action: -- GitLab From b439b472048f8cbd390f499118cc1619f3bdb40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Sun, 6 Jul 2025 21:58:33 +0200 Subject: [PATCH 2/2] mypy fixes --- travo/dashboards.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/travo/dashboards.py b/travo/dashboards.py index f9c3c85..59784f1 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -49,7 +49,7 @@ except ImportError: ) from IPython.display import display, Javascript # type: ignore -import traitlets +import traitlets # type: ignore from .assignment import Assignment @@ -546,7 +546,7 @@ class LinkAsButton(ipywidgets.HTML): else: self.update() - def update(self): + def update(self) -> None: opacity = 0.5 if self.disabled else 1 content = textwrap.dedent( f""" @@ -561,7 +561,7 @@ class LinkAsButton(ipywidgets.HTML): self.value = content @traitlets.observe("disabled") - def _disabled_changed(self, change): + def _disabled_changed(self, change: dict) -> None: self.update() @@ -663,6 +663,7 @@ class AssignmentStudentDashboard(HBox): and os.path.exists(self.assignment.assignment_dir) ) if has_assignment_dir: + assert self.assignment.assignment_dir is not None index_files = ["index.md", "index.ipynb", "README.md", "README.ipynb"] for x in index_files: file = os.path.join(self.assignment.assignment_dir, x) -- GitLab