From f6eab40b7e2e8fcbd266797d0ea6dbe9d6b54ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Sat, 27 Aug 2022 02:07:59 +0200 Subject: [PATCH] Student dashboard for Assignment and JupyterCourse - choice of student group - fetch / submit / open work dir - link to assignment on GitLab - link to submission on GitLab - display score badge (manual and automatic grade) - link to feedback - display/hide/configure log level Also: - cache the forge's user for performance - fix some i18n typos --- travo/assignment.py | 2 +- travo/gitlab.py | 17 ++- travo/jupyter_course.py | 296 +++++++++++++++++++++++++++++++++++++- travo/locale/travo.en.yml | 30 +++- travo/locale/travo.fr.yml | 29 +++- 5 files changed, 350 insertions(+), 24 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index 294ad31..2a6f3a2 100644 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -446,7 +446,7 @@ Déplacez ou supprimez le raise RuntimeError( f"Dépôt personnel inexistant sur GitLab\n" f"Merci de déposer `{name}` (avec submit)") - project = self.ensure_personal_repo() + project = self.personal_repo() return Submission(project) def submissions(self) -> List["Submission"]: diff --git a/travo/gitlab.py b/travo/gitlab.py index e11138b..0388dce 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -37,6 +37,7 @@ class GitLab: debug: bool = False home_dir: str = os.environ.get("HOME", "") token: Optional[str] = None + _get_user_cache: Optional['User'] = None base_url: str api: str session: requests.Session @@ -89,8 +90,8 @@ class GitLab: # TODO: remove authorization header return False if 'error' in status: - self.log.error(_("invalid token", - error_description=status['error_description'])) + self.log.info(_("invalid token", + error_description=status['error_description'])) # TODO: remove authorization header return False @@ -112,6 +113,7 @@ class GitLab: token_file = self.token_file() if os.path.isfile(token_file): os.remove(token_file) + self._get_user_cache = None def login(self, username: Optional[str] = None, @@ -198,11 +200,11 @@ class GitLab: # No token available if username is None or password is None: - print(f"Please authenticate on {self.base_url}:") + print(_("please authenticate on", url=self.base_url)) if username is None: - username = input('Identifiant: ') + username = input(_('username') + ': ') if password is None: - password = getpass.getpass('Mot de passe: ') + password = getpass.getpass(_('password') + ': ') result = self.session.post(self.base_url+"/oauth/token", params=dict(grant_type="password", username=username, @@ -643,7 +645,10 @@ class GitLab: if isinstance(username, User): return username if username is None: - json = self.get("/user").json() + if self._get_user_cache is None: + self._get_user_cache = User(gitlab=self, + **self.get("/user").json()) + return self._get_user_cache else: url = f"/users?username={username}" json = self.get(url).json() diff --git a/travo/jupyter_course.py b/travo/jupyter_course.py index 0628963..8d89456 100644 --- a/travo/jupyter_course.py +++ b/travo/jupyter_course.py @@ -1,21 +1,40 @@ +import contextlib import io +from ipywidgets import ( # type: ignore + Button, + Checkbox, + GridBox, + HBox, + VBox, + Dropdown, + Label, + HTML, + Layout, + Output, +) import glob import os.path import random +import requests import shutil import subprocess import tempfile import time +from threading import Thread import base64 from datetime import datetime -from typing import Any, Optional, List, Union -from .assignment import SubmissionStatus +from typing import Any, Iterator, Optional, List, Union +from .assignment import Assignment, SubmissionStatus from .course import Course from .i18n import _ from .utils import run from .nbgrader_utils import export_scores +@contextlib.contextmanager +def TrivialContextManager() -> Iterator[Any]: + yield + # Currently just a dummy grade report, just for making some tests grade_report = """ @@ -420,7 +439,7 @@ class JupyterCourse(Course): continue time.sleep(0.2) if failed: - self.log.warning(f"Failed force autograde: {' '.join(failed)}") + self.log.warning(f"Failed force autograde: {' '.join(failed)}") def collect_status(self, assignment_name: str, @@ -675,3 +694,274 @@ class JupyterCourse(Course): "") jupyter_notebook(url) + + def student_dashboard(self, + student_group: Optional[str] = None + ) -> 'CourseStudentDashboard': + """ + Return a dashboard for the course for use in Jupyter + """ + return CourseStudentDashboard(self, student_group=student_group) + + +class AssignmentStudentDashboard(HBox): + def __init__(self, + assignment: Assignment, + logUI: Optional[Output] = None, + statusUI: Optional[Label] = None): + + # TODO: find better way to recover the assignment name + self.name = os.path.basename(assignment.repo_path) + self.assignment = assignment + if logUI is None: + logUI = TrivialContextManager() + self.logUI = logUI + if statusUI is None: + statusUI = Label() + self.statusUI = statusUI + + layout = Layout(width="initial") + self.nameUI = Label(self.name) + self.assignmentUI = HTML() # layout=layout) + self.fetchUI = Button( + description=_("fetch"), + button_style="primary", + icon="download", + tooltip=_("fetch assignment", assignment_name=self.name), + layout=layout, + disabled=True, + ) + self.fetchUI.on_click(lambda event: self.fetch()) + self.submitUI = Button( + description=_("submit"), + button_style="primary", + icon="upload", + tooltip=_("submit assignment", assignment_name=self.name), + layout=layout, + disabled=True, + ) + self.submitUI.on_click(lambda event: self.submit()) + self.work_dir_UI = Button( + description=_("open"), + button_style="primary", + icon="edit", + tooltip=_("open assignment", assignment_name=self.name), + layout=layout, + disabled=True, + ) + self.work_dir_UI.on_click(self.open_work_dir) + self.scoreUI = HTML( + "", + tooltip=_("browse feedback") + ) + + self.submissionUI = HTML() # layout=layout) + HBox.__init__( + self, + [ + self.nameUI, + self.assignmentUI, + self.fetchUI, + self.work_dir_UI, + self.submitUI, + self.submissionUI, + self.scoreUI, + ], + ) + Thread(target=self.update).start() + + def update(self) -> None: + status = self.assignment.status() + if status.status == "not released": + self.assignmentUI.value = self.name + self.fetchUI.disabled = True + else: + repo = self.assignment.repo() + self.assignmentUI.value = ( + f'{_("browse")}' + ) + self.fetchUI.disabled = False + + if status.is_submitted(): + personal_repo = self.assignment.personal_repo() + url = personal_repo.web_url + self.submissionUI.value = f'{_("browse")}' + else: + self.submissionUI.value = "" + 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 + else: + self.work_dir_UI.disabled = True + self.submitUI.disabled = True + + if status.autograde_status == "success": + # these two s/could be provided by autograde_status + try: + badge = personal_repo.fetch_artifact( + status.autograde_job, + artifact_path="feedback/scores.svg").text + except requests.HTTPError: + badge = _("browse") + feedback_url = ( + f"{url}/-/jobs/artifacts/main/file/feedback/scores.html?job=autograde" + ) + self.scoreUI.value = f"{badge}" + else: + self.scoreUI.value = "" + + # repo = HTML('Primary link') + # need to update repo_path + + def fetch(self) -> None: + self.fetchUI.disabled = True + action = _("fetching assignment", assignment_name=self.name) + self.statusUI.value = action + ": " + _("ongoing") + try: + with self.logUI: + self.assignment.fetch() + self.statusUI.value = action + ": " + _("finished") + except RuntimeError: + self.statusUI.value = action + ": " + _("failed") + finally: + self.fetchUI.disabled = False + self.update() + + def submit(self) -> None: + self.submitUI.disabled = True + action = _("submitting assignment", assignment_name=self.name) + self.statusUI.value = action + ": " + _("ongoing") + try: + with self.logUI: + self.assignment.submit() + self.statusUI.value = action + ": " + _("finished") + except RuntimeError: + self.statusUI.value = action + ": " + _("failed") + finally: + self.submitUI.disabled = False + self.update() + + def open_work_dir(self, event: Any) -> None: + from ipylab import JupyterFrontEnd # type: ignore + + app = JupyterFrontEnd() + if self.assignment.assignment_dir is None: + raise ValueError("Can't open work dir if assignment_dir is not set") + file = os.path.join(self.assignment.assignment_dir, "index.md") + app.commands.execute( + "docmanager:open", + { + "path": file, # Generalize if there is no index.md + "factory": "Notebook", + # 'options': { + # 'mode': 'split-right' + # }, + "kernelPreference": { + "shutdownOnClose": True, + }, + }, + ) + + +class CourseStudentDashboard(VBox): + def __init__(self, + course: Course, + student_group: Optional[str] = None): + self.course = course + + # TODO: should use the current foreground color rathern than black + border_layout = {'border': '1px solid black'} + + self.header = HBox( + [HTML(f'{course.name}', + layout={'flex': '1 0 content'})], + layout=border_layout) + if self.course.student_groups is not None: + self.student_group_UI = Dropdown( + description=_('student group'), + value=student_group, + options=course.student_groups, + ) + self.header.children += (self.student_group_UI,) + self.student_group_UI.observe(lambda change: self.make_grid(), + names='value') + + self.grid = GridBox( + layout=Layout( + grid_template_columns='repeat(7, max-content)', + grid_gap='5px 5px', + border=border_layout['border'] + ) + ) + + minimize_layout = {'flex': '0 0 content'} + self.statusUI = Label(_('ready'), + layout={'flex': '1 0 content'}) + self.log_show_UI = Checkbox(description=_('show'), + tooltip=_('show command log'), + value=False, + indent=False, + layout=minimize_layout) + self.log_level_UI = Dropdown(options=['WARNING', 'INFO', 'DEBUG'], + tooltip=_('command log level'), + layout=minimize_layout) + self.logUI = Output() + self.logUI.layout.display = 'none' + self.status_bar = VBox( + [HBox([Label(_('status')+': ', + layout=minimize_layout), + self.statusUI, + Label(_('log details') + ': ', + layout=minimize_layout), + self.log_show_UI, + Label(_('level'), + layout=minimize_layout), + self.log_level_UI]), + self.logUI], + layout=border_layout) + + def set_log_show(event: Any) -> None: + self.logUI.layout.display = 'block' if self.log_show_UI.value else 'none' + self.log_show_UI.observe(set_log_show, names='value') + + def set_log_level(event: Any) -> None: + self.course.log.setLevel(self.log_level_UI.value) + self.log_level_UI.observe(set_log_level, names='value') + + super().__init__([self.header, + self.grid, + self.status_bar], + layout={'width': 'fit-content'}, + ) + self.make_grid() + + def make_grid(self) -> None: + if self.student_group_UI.value is None: + self.center = None + if self.course.assignments is None: + raise NotImplementedError( + 'Guessing the list of assignments if course.assignments is not set') + assignment_dashboards = [ + AssignmentStudentDashboard( + self.course.assignment( + assignment_name, student_group=self.student_group_UI.value + ), + logUI=self.logUI, + statusUI=self.statusUI, + ) + for assignment_name in self.course.assignments + ] + self.grid.children = [ + Label(label) + for j, label in enumerate( + [_('assignment'), + _('assignment repository'), '', + _('work directory'), '', + _('submission'), + _('score'), + ]) + ] + [widget + for assignment_dashboard in assignment_dashboards + for widget in assignment_dashboard.children] diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index f716b7d..28d48cd 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -16,11 +16,27 @@ en: browse: "Browse" fetch: "Fetch" submit: "Submit" - fetch assignment: "Fetch assignment %{assignment}" - submit assignment: "Submit assignment %{assignment}" - open assignment: "Open assignment {%assignment}" + fetching assignment: "Fetching assignment %{assignment_name}" + submitting assignment: "Submitting assignment %{assignment_name}" + fetch assignment: "Fetch assignment %{assignment_name}" + submit assignment: "Submit assignment %{assignment_name}" + open assignment: "Open assignment %{assignment_name}" browse feedback: "Browse feedback" - no submission; please submit: "No submission on GitLab\nPlease submit `{%assignment_name}" - validation failed: "Validation failed with {%errors} error(s) and {%failures} failure(s)" - creating work dir: "Creating work directory {%work_dir}" - ensure instructor access: "Update instructor access for {%path}" \ No newline at end of file + no submission; please submit: "No submission on GitLab\nPlease submit `%{assignment_name}" + validation failed: "Validation failed with %{errors} error(s) and %{failures} failure(s)" + creating work dir: "Creating work directory %{work_dir}" + ensure instructor access: "Update instructor access for %{path}" + ready: "Ready" + status: "Status" + ongoing: "🟡 ongoing" + finished: "✅ finished" + failed: "❌ failed" + username: "Username" + password: "Password" + please authenticate on: "Please authenticate on %{url}" + log details: "Logs details" + show: "Afficher" + level: "Niveau" + show command log: "Show the log of the commands" + command log level: "Configure the log level of the commands" + student group: "Student group" \ No newline at end of file diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index cc189b0..64da31c 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -7,8 +7,7 @@ fr: assignment: "Devoir" assignment repository: "Sujet" work directory: "Répertoire de travail" - repository: "Dépôt" - submission: "Dépôt personnel" + submission: "Dépôt" score: "Score" success: "Succès" failure: "Échec" @@ -16,13 +15,29 @@ fr: browse: "Consulter" fetch: "Télécharger" submit: "Déposer" + fetching assignment: "Téléchargement du devoir %{assignment_name}" + submitting assignment: "Dépôt du devoir %{assignment_name}" fetch assignment: "Télécharger (ou mettre à jour) le devoir %{assignment_name}" submit assignment: "Déposer le devoir %{assignment_name}" - open assignment: "Ouvrir le devoir {%assignment_name}" + open assignment: "Ouvrir le devoir %{assignment_name}" browse feedback: "Consulter les détails du score et de la correction" - no submission; please submit: "Dépôt personnel inexistant sur GitLab\nMerci de déposer `{%assignment_name}" + no submission; please submit: "Dépôt personnel inexistant sur GitLab\nMerci de déposer `%{assignment_name}" No need to launch Jupyter on JupyterHub: "Sur le service JupyterHub, il est inutile de lancer Jupyter" "Can't open work dir: assignment_dir is not set": "Ne peux pas ouvrir le répertoire de travail: assignment_dir n'est pas défini" - validation failed: "Échec de la validation avec {%errors} erreur(s) and {%failures} échec(s)" - creating work dir: "Création du répertoire de travail {%work_dir}" - ensure instructor access: "Assure l'accès des instructeurs à {%path}" \ No newline at end of file + validation failed: "Échec de la validation avec %{errors} erreur(s) and %{failures} échec(s)" + creating work dir: "Création du répertoire de travail %{work_dir}" + ensure instructor access: "Assure l'accès des instructeurs à %{path}" + ready: "Prêt" + status: "Statut" + ongoing: "🟡 en cours" + finished: "✅ terminé" + failed: "❌ échec" + username: "Identifiant" + password: "Mot de passe" + please authenticate on: "Merci de vous authentifier sur %{url}" + log details: "Détails" + show: "Afficher" + level: "Niveau" + show command log: "Afficher la trace d'exécution des opérations" + command log level: "Configurer le niveau de détail de la trace d'exécution des opérations" + student group: "Groupe" \ No newline at end of file -- GitLab