From 1f6f60b2010cc0ba75def635bc8ff79ef4ca9ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20M=2E=20Thi=C3=A9ry?= Date: Fri, 21 Jun 2024 18:46:17 +0200 Subject: [PATCH] Checkpoint --- travo/assignment.py | 142 ++++++++++++++++++++++---------------- travo/dashboards.py | 88 +++++++++++++++++++++++ travo/locale/travo.fr.yml | 22 +++++- 3 files changed, 190 insertions(+), 62 deletions(-) diff --git a/travo/assignment.py b/travo/assignment.py index fe06083..09d51f0 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -5,7 +5,7 @@ import os import socket import subprocess import time -from typing import Any, Dict, List, Optional, Tuple, Union, cast +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast from .gitlab import ( anonymous_user, Forge, @@ -443,37 +443,50 @@ class Assignment: ) raise RuntimeError("corrupted assignment directory") - def fetch_branch( + def merge_from( self, + source: Project, assignment_dir: str, - branch: Optional[str], - content: str, - on_failure: str = "error", - ) -> None: + branch: Optional[str] = None, + content: str = "", + on_failure: Literal["warning", "error"] = "error", + ) -> bool: """ - Fetch from a branch of the assignment repository + Try to merge content in the assignment directory + + Parameters + ---------- + assignment_dir : str, optional + Path to directory. The default is None. + + source: Project, optional + The repository from which to merge content. The default is + the assignment repository. - If branch_name is None, the default branch is used. + branch: str, optional + The branch of the source repository from which to merge content. + The default is the default branch of the source repository. - This will try to merge the branch `branch` of the assignment - repository (if it exists) into the local clone. In case of - merge conflict, the local clone is rolled back to its original - state, and a warning or error is emmitted depending on the - value of `on_failure`. + If the branch does not exist, report and return False. + A commit is done before attempting the merge. In case of merge + conflict, the assignment directory is rolled back to its + original state, and a warning or error is emmitted depending + on the value of `on_failure`. - Assumption: `assignment_dir` is a clone of the assignment + Return whether the merge was successful. + + Assumptions: `assignment_dir` is a clone of the assignment repository, with a local git configuration (see :ref:`gitlab.ensure_local_git_configuration`). - """ - self.log.info(f"Intégration des {content} depuis le dépot d'origine") + msg = _("merging", content=content) + self.log.info(msg) - source = self.repo() if branch is None: branch = source.default_branch if not any(b["name"] == branch for b in source.get_branches()): - self.log.info(f"Pas de {content} pour ce devoir") + self.log.info(_("no content", content=content)) return # self.check_assignment("$assignment") @@ -481,46 +494,57 @@ class Assignment: def git(args: List[str], **kwargs: Any) -> subprocess.CompletedProcess: return self.forge.git(args, cwd=assignment_dir, **kwargs) - self.log.info("- Sauvegarde préalable:") + save_msg = _("save before", what=msg) + self.log.info("- " + save_msg) git( - ["commit", "--all", "-m", f"Sauvegarde avant intégration des {content}"], + ["commit", "--all", "-m", save_msg], check=False, ) - self.log.info("- Téléchargement:") + self.log.info("- " + _("download")) git(["fetch", source.http_url_with_base_to_repo(), branch]) + # TODO: nice error message in case of failure + # - Échec au téléchargement des mises à jour" # travo_raise_error f"Échec au téléchargement des {content}" - self.log.info("- Tentative d'intégration:") - if ( - git( - ["merge", "-m", f"Intégration des {content}", "FETCH_HEAD"], check=False - ).returncode - != 0 - ): + self.log.info("- " + _("try to merge")) + if git(["merge", "-m", msg, "FETCH_HEAD"], check=False).returncode != 0: git(["merge", "--abort"]) - message = _("abandon failing merge", content=content) + message = _("abort conflicting merge", content=content) if on_failure == "warning": self.log.warning(message) + return False else: assert on_failure == "error" raise RuntimeError(message) + return True - def fetch(self, assignment_dir: Optional[str] = None, force: bool = False) -> None: + def fetch( + self, + assignment_dir: Optional[str] = None, + username: str = None, + force: bool = False, + ) -> None: """ - Fetch the given assignment + Fetch (i.e. download or update) the assignment The user must have an account on the forge: - to check whether the user has a submission for this assignment - to setup the local git configuration - If logged in as `anonymous`, fetch will ignore any personnal - repository the user may have, will skip the git configuration - and won't fetch updates or erratas. We could try harder on - this last point if there is a use case for it. + If logged in as `anonymous`, `fetch` ignores any personnal + repository the user may have, skips the git configuration and + won't fetch updates or erratas. We could try harder on this + last point if there is a use case for it. Parameters ---------- assignment_dir : str, optional - Path to directory. The default is None. + Path to directory. By default, use the assignment + directory defined in this assignment. + + username : str, optional + If set, the assignment will be fetched from + the submission of `username` instead + force : bool, optional If True, create a backup of existing local repo and forces download of remote repo. The default is False. @@ -528,7 +552,6 @@ class Assignment: Returns ------- None - """ self.forge.login(anonymous_ok=True) user = self.forge.get_current_user() @@ -543,26 +566,17 @@ class Assignment: if not os.path.exists(assignment_dir): self.assignment_clone(assignment_dir) - elif user is not anonymous_user and self.has_submission(): - submission_repo = self.forge.get_project(self.submission_path()) + elif user is not anonymous_user and self.has_submission(username=username): + submission_repo = self.submission_repo(username=username) self.ensure_clone_configuration(assignment_dir) - self.log.info("Intégration des mises à jour depuis votre dépot personnel") - self.log.info("- Sauvegarde préalable:") - git(["commit", "--all", "-m", "Sauvegarde avant mise à jour"], check=False), - self.log.info("- Téléchargement:") - # TODO: nice error message in case of failure - # - Échec au téléchargement des mises à jour" - git(["fetch", submission_repo.http_url_to_repo]) - self.log.info("- Tentative d'intégration:") - - if ( - git( - ["merge", "-m", "Intégration des mises à jour", "FETCH_HEAD"], - check=False, - ).returncode - != 0 - ): - git(["merge", "--abort"]) + success = self.merge_from( + source=submission_repo, + assignment_dir=assignment_dir, + content=_("updates from submission"), + on_failure="warning", + ) + + if not success: if force: now = datetime.datetime.now().isoformat(sep="_", timespec="minutes") # Something special needs to be done for "." @@ -589,12 +603,18 @@ class Assignment: ) raise RuntimeError(_("fetch failed conflict")) if user is not anonymous_user: + # Currently anonymous users won't get updates, as + # merge_from may require a commit which requires git to be + # configured. We could try harder if there is a use case + # for it. self.forge.ensure_local_git_configuration(assignment_dir) - # fetch_branch may require a commit which require git to be configured - # we could try harder if there is a use case for it - self.fetch_branch(assignment_dir, branch=None, content=_("updates")) - self.fetch_branch( - assignment_dir, + repo = self.repo() + self.merge_from( + source=repo, assignment_dir=assignment_dir, content=_("updates") + ) + self.merge_from( + source=repo, + assignment_dir=assignment_dir, branch="errata", content=_("erratas"), on_failure="warning", diff --git a/travo/dashboards.py b/travo/dashboards.py index d9d575a..e02f109 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -325,6 +325,19 @@ class AssignmentStudentDashboard(HBox): self.submissionUI = HTML(layout=Layout(align_self="center")) + self.other_actionsUI = Dropdown( + description="", + options=[ + (_("other actions"), ""), + (_("fetch from"), "fetch from"), + (_("set student group"), "set student group"), + (_("share with"), "share with"), + (_("set main submission"), "set main submission"), + (_("remove submission"), "remove submission"), + ], + ) + self.other_actionsUI.observe(lambda change: self.other_action(), names="value") + HBox.__init__( self, [ @@ -490,6 +503,81 @@ class AssignmentStudentDashboard(HBox): ) ) + def other_action(self) -> None: + action = self.other_actionsUI.value + if not action: + return + input: Dict[str, Any] + + self.other_actionsUI.disabled = True + + def on_finished() -> None: + self.other_actionsUI.index = 0 + + self.other_actionsUI.disabled = False + + assert action in [ + "fetch from", + "share with", + "set main submission", + "set student group", + "remove submission", + ] + + confirm = False + command: Callable + + if action == "remove submission": + confirm = True + input = {} + + def command() -> None: + self.assignment.remove_submission(force=True) + + elif action == "fetch from": + origins = {"assignment": self.assignment.repo()} + status = self.assignment.status() + for username in status.team.keys(): + origins[username] = status.team[username] + command = self.assignment.fetch_from + input = { + "origin": str, + "on_conflict": ( + "abort", + "backup and fetch", + "merge anyway", + ), + } + + elif action == "share with": + command = self.assignment.share_with + input = {"username": str} + + elif action == "set main submission": + input = {"username": str} + + def command(username: str) -> None: + # TODO: simplify and generalize: + # - once leader_name can be passed down to submit without being ignored + # - once there are dedicated method to set the leader or student group + # without submitting + self.assignment.ensure_main_submission(leader_name=username) + + elif action == "set student group": + command = self.assignment.submit + assert isinstance(self.assignment, CourseAssignment) + assert self.assignment.course.student_groups is not None + input = {"student_group": tuple(self.assignment.course.student_groups)} + + self.status_bar.run( + action=_(action), + command=command, + confirm=confirm, + input=input, + on_success=self.update, + on_finished=on_finished, + ) + class CourseStudentDashboard(VBox): """ diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 5cba563..f789f2d 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -24,6 +24,7 @@ fr: 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 main submission: "%{leader_name} n'a pas déposé %{assignment_name} sur GitLab, ou ne l'a pas partagé avec vous" 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)" @@ -52,7 +53,7 @@ fr: invalid token environment variable: "Variable d'environnement TRAVO_TOKEN invalide ou expirée." sign in: "Authentification" authentication required: "Authentification requise" - abandon conflicting merge: "L'intégration des %{content} induirait un conflit; abandon" + abort conflicting merge: "L'intégration des %{content} induirait un conflit; abandon" updates: mises à jour erratas: erratas submission failed: "échec du dépôt: essayer d'abord de télécharger?" @@ -104,3 +105,22 @@ fr: Number of submissions: "#copies: %{number_of_submissions}" last release: "Dernière publication : %{visibility} %{date}" Needs manual grading: "Besoin d'une correction manuelle: %{counts} \nScores: %{mean:.2f}+/-%{dev:.2f}" + remove submission: "Suppression du dépôt" + share with: "Partager avec (travail en équipe)" + set main submission: "Définir le dépôt principal (travail en équipe)" + set student group: "Définir le groupe" + cancel: "Annuler" + validate: "Valider" + confirm: "Êtes vous sûr?" + other actions: "Autres actions" + waiting for information: "En attente d'information" + canceled: "Annulé" + cannot set self as leader: "Ce sont vos équipiers qui doivent définir votre dépôt comme dépôt principal" + missing assignment directory: "Répertoire de travail manquant; téléchargez votre devoir?" + update view: "Mettre à jour" + fetch from: "Télécharger ou mettre à jour depuis" + downloading: "Téléchargement" + "save before": "Sauvegarde avant %{what}" + "merging": "intégration de %{content}" + "try to merge": "Tentative d'intégration" + "no content": "Pas de %{content}" -- GitLab