diff --git a/conftest.py b/conftest.py index fe7021db623af73f0dfd0643e17b0109b8f334f3..067e03d474b13e3c09e8e04426ec8aa1e1114245 100644 --- a/conftest.py +++ b/conftest.py @@ -111,7 +111,7 @@ def standalone_assignment_namespace(gitlab: GitLab) -> Group: @pytest.fixture def standalone_assignment( - gitlab: GitLab, + gitlab: GitLabTest, user_name: str, standalone_assignment_namespace: Group, test_run_id: str, diff --git a/docs/sources/changelog.md b/docs/sources/changelog.md index a4ea87164d85a80abbfbc908ef4a51ba7ea249ac..10b1f1bbe350aa745cf54ca0e58e2d4f7693900d 100644 --- a/docs/sources/changelog.md +++ b/docs/sources/changelog.md @@ -8,6 +8,16 @@ - `GitLab.get_branch` now raises `ResourceNotFound` if the branch is missing. - Manage jupyter courses with jupyter lab options rather than jupyter notebook. +### Student dashboard + +- New student actions available through a dropdown menu + - share with (team work) + - set main submission (team work) + - remove submission + - merge from another submission +- Smoother User Experience through form dialogs that appear only when + information is required from the user (e.g. to choose the student group) + ### Test infrastructure - Improved test robustness with test (rich) course paths and names parametrised by test run id. @@ -15,6 +25,10 @@ - New context managers: `travo.util.working_directory`, `Gitlab.logged_as`. - Refactor user creation in basic gitlab infrastructure. +### Misc + +- Refactored `Assignment.merge` + ## Version 1.0 The 1.0 release has focused on: diff --git a/travo/assignment.py b/travo/assignment.py index 3bb16d3e631a54656a6d95acb91aa4edbd166fc5..4e3b7e804cfcea25fff7dba82ec5e0e09d59472b 100755 --- a/travo/assignment.py +++ b/travo/assignment.py @@ -5,12 +5,13 @@ 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, Job, Project, + Resource, ResourceRef, ResourceNotFoundError, User, @@ -77,7 +78,6 @@ class Assignment: repo_path=repo.path_with_namespace, name=name, instructors_path=instructors_path, - username=username, _repo_cache=repo, ) @@ -176,10 +176,10 @@ class Assignment: ########################################################################## - def submission_repo(self) -> Project: - return self.forge.get_project(self.submission_path()) + def submission_repo(self, username: Optional[str] = None) -> Project: + return self.forge.get_project(self.submission_path(username=username)) - def has_submission(self) -> bool: + def has_submission(self, username: Optional[str] = None) -> bool: """ Return whether the user already has a submission for this assignment @@ -196,7 +196,7 @@ class Assignment: False """ try: - self.submission_repo() + self.submission_repo(username=username) return True except ResourceNotFoundError: return False @@ -212,7 +212,6 @@ class Assignment: Beware that, by default, this does not initialize the repository content from the assignment upon creation. - Example:: >>> assignment = getfixture("standalone_assignment") @@ -292,7 +291,7 @@ class Assignment: my_repo.ensure_badge(name="Scores", link_url=link_url, image_url=image_url) - self.log.info("- Votre dépôt personnel:") + self.log.info(f"- {_('your submission')}:") self.log.info(f" {my_repo.web_url}") return my_repo @@ -430,6 +429,29 @@ class Assignment: Check the assignment directory Raise an error if it does not exist or is dubious + + Tests: + + >>> import io, i18n + >>> i18n.set('locale', 'en') + >>> tmp_path = getfixture("tmp_path") + >>> os.chdir(tmp_path) + >>> assignment = getfixture("course").assignment("Assignment") + + >>> assignment.check_assignment_dir("Assignment") + Traceback (most recent call last): + ... + RuntimeError: Missing assignment directory; fetch your assignment? + + >>> io.open("Assignment", 'w').close() + >>> assignment.check_assignment_dir("Assignment") + Traceback (most recent call last): + ... + RuntimeError: Corrupted assignment directory + >>> os.remove("Assignment") + + >>> os.mkdir("Assignment") + >>> assignment.check_assignment_dir("Assignment") """ self.log.info(f"- Vérification de la présence du devoir {assignment_dir}") if not os.path.exists(assignment_dir): @@ -440,7 +462,7 @@ class Assignment: {self.script} fetch {assignment_dir} """ ) - raise RuntimeError("missing assignment directory") + raise RuntimeError(_("missing assignment directory")) if not os.path.isdir(assignment_dir): self.log.error( f"""Un fichier ou répertoire {assignment_dir} existe @@ -448,86 +470,158 @@ class Assignment: Déplacez ou supprimez le """ ) - raise RuntimeError("corrupted assignment directory") + 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", + anonymous_ok: bool = False, + ) -> 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 (or no merge was needed). + + Assumptions: `assignment_dir` is a clone of the assignment repository, with a local git configuration (see :ref:`gitlab.ensure_local_git_configuration`). + Examples: + + We fetch an assignment which already has a submission: + + >>> gitlab = getfixture("gitlab") + >>> to_be_teared_down = getfixture("to_be_teared_down") + >>> assignment = getfixture("standalone_assignment") + >>> submission_repo = assignment.ensure_submission_repo(initialized=True) + >>> to_be_teared_down(submission_repo) + >>> assignment_dir = getfixture("standalone_assignment_dir") + + >>> assignment.fetch(assignment_dir=assignment_dir) + + >>> assert os.path.isfile(os.path.join(assignment_dir, "README.md")) + >>> assert not os.path.isfile(os.path.join(assignment_dir, "newfile")) + + we add a new file in the submission: + + >>> submission_repo.ensure_file("newfile") + + and check that, after merging from the modified submission, + the new file is present locally: + + >>> assignment.merge_from(submission_repo, assignment_dir=assignment_dir) + True + + >>> assert os.path.isfile(os.path.join(assignment_dir, "newfile")) + + Now let's create a submission for student2, add there a + different file, and share it with the current student: + + >>> with gitlab.logged_as("student2"): + ... submission2_repo = assignment.ensure_submission_repo( + ... initialized=True) + ... to_be_teared_down(submission2_repo) + ... submission2_repo.ensure_file("newfile-student2") + ... assignment.share_with("student1") + + After merging from student2's submission, the new file is present locally: + + >>> assignment.merge_from(submission2_repo, assignment_dir=assignment_dir) + True + + >>> assert os.path.isfile(os.path.join(assignment_dir, "newfile-student2")) """ - 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") - return + self.log.info(_("no content", content=content)) + return True # self.check_assignment("$assignment") # Share this with fetch def git(args: List[str], **kwargs: Any) -> subprocess.CompletedProcess: - return self.forge.git(args, cwd=assignment_dir, **kwargs) + return self.forge.git( + args, cwd=assignment_dir, anonymous_ok=anonymous_ok, **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: Optional[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. @@ -535,7 +629,6 @@ class Assignment: Returns ------- None - """ self.forge.login(anonymous_ok=True) user = self.forge.get_current_user() @@ -550,26 +643,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 "." @@ -595,17 +679,24 @@ class Assignment: """ ) raise RuntimeError(_("fetch failed conflict")) - if user is not anonymous_user: - 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, - branch="errata", - content=_("erratas"), - on_failure="warning", - ) + + # Fetch updates and errata + self.forge.ensure_local_git_configuration(assignment_dir) + repo = self.repo() + self.merge_from( + source=repo, + assignment_dir=assignment_dir, + content=_("updates"), + anonymous_ok=True, + ) + self.merge_from( + source=repo, + assignment_dir=assignment_dir, + branch="errata", + content=_("erratas"), + on_failure="warning", + anonymous_ok=True, + ) def submit( self, assignment_dir: Optional[str] = None, leader_name: Optional[str] = None @@ -790,6 +881,101 @@ class Assignment: for url in bad_projects: self.log.warning(url) + def share_with( + self, + username: str, + access_level: Union[ + int, Resource.AccessLevels + ] = Resource.AccessLevels.DEVELOPER, + ) -> None: + """ + Grant the given user access to the submission repository + """ + try: + repo = self.submission_repo() + except ResourceNotFoundError: + raise RuntimeError( + _("no submission; please submit", assignment_name=self.name) + ) + user = self.forge.get_user(username) + repo.share_with(user, access=access_level) + + def ensure_main_submission(self, leader_name: str) -> None: + """ + Ensure that the main submission is set for this submission. + + This is meant for team work on a submission. The principle is + as follow: among all the submissions for the members of team, + one is chosen as main submission; its owner is called leader + of the team. Following a typical collaboration pattern with + forges, all the others submissions are configured to be forks + of the main submission. + + Example: + + Let's create an assignment with submissions of student1 and student2: + + >>> gitlab = getfixture("gitlab") + >>> to_be_teared_down = getfixture("to_be_teared_down") + >>> assignment = getfixture("standalone_assignment") + >>> for student_name in ["student1", "student2"]: + ... with gitlab.logged_as(student_name): + ... to_be_teared_down( + ... assignment.ensure_submission_repo(initialized=True)) + + student1 and student2 decide together that student2's + submission will be the main submission (so student2 is called + the team leader). + + student2 grants student1 access to their submission: + + >>> with gitlab.logged_as("student2"): + ... assignment.share_with("student1") + + student1 can now set student2's submission as main submission: + + >>> assignment.ensure_main_submission("student2") + >>> assignment.submission().get_leader_and_team() + ('student2', {'student2': ..., 'student1': ...}) + + If there are more students in the team, the same needs to be + done for each of them. They also probably want to grant access + to their submissions to the other members of the team, but + that's optional. + + We test that student1's submission is a fork of student2's + submission, which itself is a fork of the assignment: + + >>> submission_repo = assignment.submission_repo() + >>> leader_repo = assignment.submission_repo("student2") + >>> assert (submission_repo.forked_from_project.path_with_namespace == + ... leader_repo.path_with_namespace) + >>> assert (leader_repo.forked_from_project.path_with_namespace == + ... assignment.repo_path) + """ + try: + repo = self.submission_repo() + except ResourceNotFoundError: + raise RuntimeError( + _("no submission; please submit", assignment_name=self.name) + ) + if leader_name == self.get_username(): + raise RuntimeError( + _("cannot set self as leader", assignment_name=self.name) + ) + + try: + main_repo = self.submission_repo(username=leader_name) + except ResourceNotFoundError: + raise RuntimeError( + _( + "no main submission", + assignment_name=self.name, + leader_name=leader_name, + ) + ) + repo.ensure_is_fork_of(main_repo) + # Only consider running or finished jobs # this excludes: 'canceled', 'skipped', 'manual', @@ -999,6 +1185,7 @@ class Submission: leader_repo = self.repo leader_name = self.assignment.get_submission_username(leader_repo) assert leader_name is not None + # Fetch the team information team = {leader_name: leader_repo} if leader_repo.forks_count > 0: diff --git a/travo/course.py b/travo/course.py index 81c95762622427049601636adff236e246296924..d7309e356a72e0a24fd199807873d83fcb627b34 100755 --- a/travo/course.py +++ b/travo/course.py @@ -15,7 +15,7 @@ from datetime import datetime from dataclasses import dataclass, field import re import subprocess -from typing import List, Optional, Tuple, Union, Any, TYPE_CHECKING +from typing import List, Any, Dict, Optional, Tuple, Union, TYPE_CHECKING from .utils import getLogger, run from .gitlab import ( @@ -58,6 +58,18 @@ forbidden_chars_in_name = re.compile(r"[^-+_. \w0-9]") forbidden_chars_in_path = re.compile("[^-A-Za-z0-9]") +class MissingInformationError(RuntimeError): + """ + An operation could not be executed due to missing information. + + The missing information can be recovered from the `missing` attribute. + """ + + def __init__(self, message: str, missing: Dict[str, Any]) -> None: + super().__init__(message) + self.missing = missing + + @dataclass class CourseAssignment(Assignment): # Until Python 3.10 and keyword only fields, a subdataclass @@ -82,7 +94,6 @@ class CourseAssignment(Assignment): >>> assignment.submission_path_components() ('student1', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') - >>> assignment.submission_path_components(username="john.doo") ('john.doo', 'TestRichCourse-...', '2020-2021', 'SubCourse', 'Assignment1') @@ -127,6 +138,7 @@ class CourseAssignment(Assignment): >>> assignment = course.assignment("SubCourse/Assignment1") >>> assignment.submission_path() 'student1-travo/TestRichCourse-.../2020-2021/SubCourse/Assignment1' + >>> assignment.submission_path(username="john.doo") 'john-doo-travo/TestRichCourse-.../2020-2021/SubCourse/Assignment1' """ @@ -208,6 +220,23 @@ class CourseAssignment(Assignment): else: return " - ".join(components[1:]) + def submit( + self, + assignment_dir: Optional[str] = None, + leader_name: Optional[str] = None, + student_group: Optional[str] = None, + ) -> None: + # Temporarily set the student group for the time of the submission. + # In the long run the logic for handling student groups should be + # reworked to not be stateful anymore, and instead passed down as + # argument to the repository creation + old_student_group = self.student_group + self.student_group = student_group + try: + super().submit(assignment_dir=assignment_dir, leader_name=leader_name) + finally: + self.student_group = old_student_group + def ensure_submission_repo( self, leader_name: Optional[str] = None, initialized: bool = False ) -> Project: @@ -215,19 +244,36 @@ class CourseAssignment(Assignment): Return the submission repository for this assignment Creating it and configuring it if needed. - """ if self.course.group_submissions: - # Ensure the existence of the nested GitLab groups holding the submission - path_components = self.submission_path_components() - name_components = self.submission_name_components() - current_path = "" - for path, name in list(zip(path_components, name_components))[:-1]: - if current_path: - current_path += "/" - current_path += path - self.forge.ensure_group(current_path, name=name, visibility="private") - return super().ensure_submission_repo(leader_name=leader_name) + + def ensure_group_recursive( + path_components: Tuple[str, ...], name_components: Tuple[str, ...] + ) -> None: + """ + Ensure the existence of the nested GitLab groups holding the submission + + It is assumed that if the group or its super groups exist, + they have the correct names and visibility + """ + assert len(path_components) == len(name_components) + if not path_components: + return + path = "/".join(path_components) + try: + self.course.forge.get_group(path) + except ResourceNotFoundError: + ensure_group_recursive(path_components[:-1], name_components[:-1]) + name = name_components[-1] + self.forge.ensure_group(path, name=name, visibility="private") + + ensure_group_recursive( + self.submission_path_components()[:-1], + self.submission_name_components()[:-1], + ) + return super().ensure_submission_repo( + leader_name=leader_name, initialized=initialized + ) def submissions_forked_from_path(self) -> Union[str, Unknown]: """Return the path of the repository that submissions should be a fork of. @@ -610,8 +656,10 @@ class Course: _("specify group", student_groups=", ".join(self.student_groups)) + "\n" ) # message += _('help', script=self.script) + missing = {"student_group": tuple(self.student_groups)} - raise RuntimeError(message) + exception = MissingInformationError(message, missing=missing) + raise exception def check_subcourse(self, subcourse: Optional[str], none_ok: bool = False) -> None: """ @@ -761,7 +809,11 @@ class Course: assignment_name, student_group=student_group, leader_name=leader_name ) assignment_dir = self.work_dir(assignment_name) - the_assignment.submit(assignment_dir=assignment_dir, leader_name=leader_name) + the_assignment.submit( + assignment_dir=assignment_dir, + student_group=student_group, + leader_name=leader_name, + ) def share_with( self, @@ -771,14 +823,9 @@ class Course: int, Resource.AccessLevels ] = Resource.AccessLevels.DEVELOPER, ) -> None: - try: - repo = self.assignment(assignment_name).submission_repo() - except ResourceNotFoundError: - raise RuntimeError( - _("no submission; please submit", assignment_name=assignment_name) - ) - user = self.forge.get_user(username) - repo.share_with(user, access=access_level) + return self.assignment(assignment_name).share_with( + username=username, access_level=access_level + ) def release( self, assignment_name: str, visibility: str = "public", path: str = "." diff --git a/travo/dashboards.py b/travo/dashboards.py index ef231004b6efe45fe446a2c425f63be35fcdebff..15409de25ff67e6178c38cb83782920719a67dfc 100644 --- a/travo/dashboards.py +++ b/travo/dashboards.py @@ -21,7 +21,7 @@ import os import subprocess import requests from threading import Thread -from typing import Any, Callable, Dict, Optional, List, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union try: from ipywidgets import ( # type: ignore @@ -51,11 +51,11 @@ from IPython.display import display, Javascript # type: ignore from .assignment import Assignment -from .gitlab import GitLab, AuthenticationError, ResourceNotFoundError, unknown +from .gitlab import GitLab, AuthenticationError, ResourceNotFoundError from .utils import run from travo.i18n import _ -from .course import Course, CourseAssignment +from .course import Course, CourseAssignment, MissingInformationError from .jupyter_course import JupyterCourse # TODO: should use the current foreground color rather than black @@ -173,12 +173,214 @@ class AuthenticationWidget(VBox): raise AuthenticationError(message) +class FormDialog(ipywidgets.HBox): + """ + A dialog to request information through a form + + Example: + + Let's set english as language: + + >>> import io, i18n + >>> i18n.set('locale', 'en') + + We create a dialog: + + >>> dialog = FormDialog() + + Originally it's empty: + + >>> dialog.children + () + + We now want to request information from the user. To this end, we + describe the name of the requested items together with their types + or list of values: + + >>> input = {"clear scores": bool, + ... "student group": str, + ... "release mode": ("public", "private")} + + We also define a callback function to handle the information upon + validation by the user; here, we just print the values: + + >>> def on_validate(**kwargs): + ... print(kwargs) + + Optionaly, we define a callback function to be called if the user cancels: + + >>> def on_cancel(): + ... print("Cancelling") + + We request the information from the user: + + >>> dialog.request_information(input=input, + ... on_validate=on_validate, + ... on_cancel=on_cancel) + + This populates the dialog with localized labels and widgets to + input the requested entries according to their types, and two + buttons to cancel or validate: + + >>> dialog.children + (Label(value='clear scores'), + Checkbox(value=False), + Label(value='Student group'), + Text(value=''), + Label(value='Release mode'), + Dropdown(options=('public', 'private'), value='public'), + Button(button_style='danger', description='Cancel', style=ButtonStyle()), + Button(button_style='success', description='Validate', style=ButtonStyle())) + + Let's emulate the user clicking on the Cancel button: + + >>> dialog.cancel() + Cancelling + >>> dialog.children + () + + This time, let's rerequest the information, and emulate the user + setting some of the values and clicking the Validate: + + >>> dialog.request_information(input=input, on_validate=on_validate) + >>> dialog.children[1].value = True + >>> dialog.children[3].value = "mygroup" + >>> dialog.children[5].value = "private" + >>> dialog.validate() + {'clear scores': True, 'student group': 'mygroup', 'release mode': 'private'} + + The same, with a confirmation checkbox: + + >>> dialog.request_information(input=input, + ... on_validate=on_validate, + ... confirm=True) + >>> dialog.children + (Label(value='Are you sure?'), + Checkbox(value=False), + Label(value='clear scores'), + Checkbox(value=False), + Label(value='Student group'), + Text(value=''), + Label(value='Release mode'), + Dropdown(options=('public', 'private'), value='public'), + Button(button_style='danger', description='Cancel', style=ButtonStyle()), + Button(button_style='success', description='Validate', disabled=True, + style=ButtonStyle())) + + >>> dialog.children[3].value = True + >>> dialog.children[5].value = "mygroup" + >>> dialog.children[7].value = "private" + + Note that the the validate button is disabled. Let's check the + Confirmation checkbox to enable the validate button: + + >>> dialog.children[1].value = True + >>> dialog.children[9] + Button(button_style='success', description='Validate', style=ButtonStyle()) + + Now we can validate: + + >>> dialog.validate() + {'clear scores': True, 'student group': 'mygroup', 'release mode': 'private'} + """ + + def __init__(self) -> None: + super().__init__([]) + self.on_cancel: Optional[Callable] = None + self.validate_button = ipywidgets.Button( + description=_("validate"), button_style="success" + ) + self.cancel_button = ipywidgets.Button( + description=_("cancel"), button_style="danger" + ) + self.validate_button.on_click(self.validate) + self.cancel_button.on_click(self.cancel) + self.cancel() + + def open_dialog( + self, + input_widgets: List, + on_validate: Callable, + on_cancel: Optional[Callable] = None, + ) -> None: + self.cancel() # in case a dialog is currently open, cancel it + self.children = [*input_widgets, self.cancel_button, self.validate_button] + self.layout.display = "" + self.on_cancel = on_cancel + self.on_validate = on_validate + + def request_information( + self, + input: Dict[str, Any], + on_validate: Callable, + on_cancel: Optional[Callable] = None, + confirm: bool = False, + ) -> None: + def make_widget(T: Union[type, tuple]) -> ipywidgets.Widget: + if T is bool: + return ipywidgets.Checkbox() + if T is str: + return ipywidgets.Text() + if isinstance(T, (tuple, dict)): + return ipywidgets.Dropdown(options=T) + raise ValueError(f"Don't know how to produce a widget for type {T}") + + widgets = {key: make_widget(value) for key, value in input.items()} + input_widgets = [] + if confirm: + self.validate_button.disabled = True + confirmUI = ipywidgets.Checkbox() + + def on_confirmed(_: Any) -> None: + self.validate_button.disabled = not confirmUI.value + + confirmUI.observe(on_confirmed, names="value") + input_widgets.extend([ipywidgets.Label(_("confirm")), confirmUI]) + + for key, widget in widgets.items(): + input_widgets.extend([ipywidgets.Label(_(key)), widget]) + + def c() -> None: + on_validate(**{key: widget.value for key, widget in widgets.items()}) + + self.open_dialog( + input_widgets, + on_validate=c, + on_cancel=on_cancel, + ) + + def validate(self, button: Any = None) -> None: + self.validate_button.disabled = True + try: + self.on_validate() + except Exception: + pass + else: + self.close_dialog() + finally: + self.validate_button.disabled = False + + def cancel(self, button: Any = None) -> None: + if self.on_cancel is not None: + self.on_cancel() + self.on_cancel = None + self.close_dialog() + + def close_dialog(self) -> None: + self.children = [] + self.layout.display = "none" + + class StatusBar(VBox): def __init__( - self, authentication_widget: AuthenticationWidget, log: logging.Logger + self, + authentication_widget: AuthenticationWidget, + log: logging.Logger, + form_dialog: Optional[FormDialog] = None, ) -> None: minimize_layout = {"flex": "0 0 content"} self.authentication_widget = authentication_widget + self.form_dialog = form_dialog self.statusUI = Label(_("ready"), layout={"flex": "1 0 content"}) self.log_show_UI = ToggleButton( description=_("show log details"), @@ -231,26 +433,62 @@ class StatusBar(VBox): self, action: str, command: Callable, - kwargs: dict = {}, + kwargs: Dict[str, Any] = {}, + input: Dict[str, Any] = {}, + confirm: bool = False, on_finished: Optional[Callable] = None, on_success: Optional[Callable] = None, ) -> None: + if self.form_dialog is not None: + self.form_dialog.cancel() self.statusUI.value = action + ": " + _("ongoing") - exit_status = _("failed") # in case of unexpected exception - success = False + + def finish(exit_status: str) -> None: + if on_finished is not None: + on_finished() + self.statusUI.value = action + ": " + exit_status + + def request_information(input: Dict[str, Any], confirm: bool = False) -> None: + assert self.form_dialog is not None + self.statusUI.value = action + ": " + _("waiting for information") + + def on_validate(**information: Any) -> None: + self.run( + action=action, + command=command, + kwargs={**kwargs, **information}, + on_finished=on_finished, + on_success=on_success, + ) + + def on_cancel() -> None: + finish(exit_status=_("canceled")) + + self.form_dialog.request_information( + input=input, + confirm=confirm, + on_validate=on_validate, + on_cancel=on_cancel, + ) + + if input or confirm: + request_information(input=input, confirm=confirm) + return + with self.logUI: try: command(**kwargs) - success = True - exit_status = _("finished") + except MissingInformationError as e: + request_information(e.missing) except (RuntimeError, subprocess.CalledProcessError) as e: - exit_status = _("failed with error", error=str(e)) - finally: - if on_finished is not None: - on_finished() - self.statusUI.value = action + ": " + exit_status - if success and on_success is not None: - on_success() + finish(exit_status=_("failed with error", error=str(e))) + except Exception as e: # in case of unexpected exception + finish(exit_status=f"{_('failed')}: {e}") + raise + else: + if on_success is not None: + on_success() + finish(exit_status=_("finished")) def run_in_subthread( self, @@ -312,6 +550,7 @@ class AssignmentStudentDashboard(HBox): disabled=True, ) self.submitUI.on_click(lambda event: self.submit()) + self.work_dir_UI = Button( description=_("open"), button_style="primary", @@ -322,9 +561,31 @@ class AssignmentStudentDashboard(HBox): ) self.work_dir_UI.on_click(self.open_work_dir_callback) self.scoreUI = HTML("", tooltip=_("browse feedback")) + self.updateUI = Button( + description="", + # button_style="primary", + icon="rotate-right", + tooltip=_("update view"), + layout=layout, + disabled=False, + ) + self.updateUI.on_click(lambda event: self.update()) self.submissionUI = HTML(layout=Layout(align_self="center")) + self.other_actionsUI = Dropdown( + description="", + options=[ + (_("other actions"), ""), + # (_("fetch from"), "fetch from"), # not ready yet + (_("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, [ @@ -334,6 +595,8 @@ class AssignmentStudentDashboard(HBox): self.submitUI, self.submissionUI, self.scoreUI, + self.updateUI, + self.other_actionsUI, ], ) Thread(target=self.update).start() @@ -390,16 +653,7 @@ class AssignmentStudentDashboard(HBox): self.assignment.assignment_dir ): self.work_dir_UI.disabled = False - if ( - self.assignment.submissions_forked_from_path() is unknown - and not status.is_submitted() - ): - # To be generalized: the current tooltip makes the - # assumption of a course assignments with student - # groups - self.submitUI.tooltip += " (" + _("needs student group") + ")" - else: - self.submitUI.disabled = False + self.submitUI.disabled = False if status.autograde_status == "success": # these two s/could be provided by autograde_status @@ -490,6 +744,83 @@ 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 [ + "set student group", + "share with", + "set main submission", + "fetch from", + "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": + # TODO this is not quite yet ready + sources = {"assignment": self.assignment.repo()} + status = self.assignment.status() + assert status.team is not None + for username in status.team.keys(): + sources[username] = status.team[username] + command = self.assignment.merge_from + input = { + "source": sources, + "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): """ @@ -498,7 +829,6 @@ class CourseStudentDashboard(VBox): This class currently assumes that the user is logged in. """ - student_group_UI: Dropdown assignments: Tuple[str, ...] = () assignment_dashboards: Dict[str, AssignmentStudentDashboard] @@ -537,24 +867,12 @@ class CourseStudentDashboard(VBox): else: self.subcourse_UI = Dropdown() - 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.update_student_group(), names="value" - ) - else: - self.student_group_UI = Dropdown() - self.authentication_widget = AuthenticationWidget(forge=self.course.forge) + self.form_dialog = FormDialog() self.grid = GridBox( layout=Layout( - grid_template_columns="repeat(6, max-content)", + grid_template_columns="repeat(8, max-content)", grid_gap="5px 5px", border=border_layout["border"], ) @@ -562,11 +880,29 @@ class CourseStudentDashboard(VBox): self.assignment_dashboards = {} self.status_bar = StatusBar( - authentication_widget=self.authentication_widget, log=self.course.log + authentication_widget=self.authentication_widget, + form_dialog=self.form_dialog, + log=self.course.log, ) + self.updateUI = Button( + description="", + # button_style="primary", + icon="rotate-right", + tooltip=_("update view"), + layout=Layout(width="initial"), + disabled=False, + ) + self.updateUI.on_click(lambda event: self.update(update_assignment_list=True)) + super().__init__( - [self.authentication_widget, self.header, self.grid, self.status_bar], + [ + self.authentication_widget, + self.header, + self.grid, + self.form_dialog, + self.status_bar, + ], layout={"width": "fit-content"}, ) self.update(update_assignment_list=True) @@ -582,17 +918,7 @@ class CourseStudentDashboard(VBox): ) self.update(update_assignment_list=True) - def update_student_group(self) -> None: - student_group = self.student_group_UI.value - for assignment_name in self.assignments: - assignment_dashboard = self.assignment_dashboards[assignment_name] - assignment = assignment_dashboard.assignment - assert isinstance(assignment, CourseAssignment) - assignment.student_group = student_group - assignment_dashboard.update() - def update(self, update_assignment_list: bool = False) -> None: - student_group = self.student_group_UI.value subcourse = self.subcourse_UI.value # if student_group is None: # self.center = None @@ -609,22 +935,21 @@ class CourseStudentDashboard(VBox): for assignment in self.assignments: if assignment not in self.assignment_dashboards: self.assignment_dashboards[assignment] = AssignmentStudentDashboard( - self.course.assignment(assignment, student_group=student_group), + self.course.assignment(assignment), status_bar=self.status_bar, authentication_widget=self.authentication_widget, ) if update_assignment_list: self.grid.children = [ - Label(label) - for label in [ - _("assignment"), - "", - _("work directory"), - "", - _("submission"), - _("score"), - ] + Label(_("assignment")), + Label(""), + Label(_("work directory")), + Label(""), + Label(_("submission")), + Label(_("score")), + self.updateUI, + Label(""), ] + [ widget for assignment in self.assignments @@ -1399,6 +1724,7 @@ class CourseGradeDashboard(VBox): job, artifact_path=path ).text scores = pd.read_csv(io.StringIO(scores_txt)) + # TODO: add type check for scores to help mypy total_score = np.sum(scores["total_score"].values) max_manual_score = np.sum( scores["max_manual_score"].values diff --git a/travo/gitlab.py b/travo/gitlab.py index 2f9531673de81f3509c8289eeadf16bed5fdffc5..a083d81b93f7baf4f1037e1003c50016a81c181a 100644 --- a/travo/gitlab.py +++ b/travo/gitlab.py @@ -391,8 +391,12 @@ class GitLab: Recall that, in GitLab's terminology, a namespace is either a group or a user's home. - Caveat: with GitLab 11 this method only support the users's - own home in addition to groups + Limitation: the current implementation requires the user to be + logged in and to be at least maintainer of the namespace. This + is ok since this method is currently only used for creating + projects or subgroups in the namespace). However the error + message may be misleading if a user tries to create a project + or group in a namepace they are not maintainer of. Examples: @@ -828,11 +832,14 @@ class GitLab: cwd=dir, capture_output=True, check=False, + anonymous_ok=True, ) - if res.returncode != 0: + if res.returncode != 0 or res.stdout.decode() == "anonymous\n": user = self.get_current_user() self.git( - ["config", "--local", f"user.{item}", getattr(user, item)], cwd=dir + ["config", "--local", f"user.{item}", getattr(user, item)], + cwd=dir, + anonymous_ok=True, ) def collect_forks( @@ -1413,12 +1420,12 @@ class Project(Resource): Caveats: - The operation is not atomic, therefore more fragile + - By default, the repository of the fork is *not* initialized. If `initialized` is set to True, then upon creation the default branch of the repository is initialized with that of the origin. - Bonus: - Unlike the forking operation in the API, this is a synchronous operation: once the operation terminates @@ -2692,6 +2699,8 @@ class User(Resource): @dataclass class AnonymousUser: username: str = "anonymous" + name: str = "Anonymous" + email: str = "anonymous mail" anonymous_user = AnonymousUser() diff --git a/travo/locale/travo.en.yml b/travo/locale/travo.en.yml index 366c9e6589e69f0e8fb27e0be8d4d35208c8fbf6..e702f0625e2b79e0103ed740cb18dc7922161399 100644 --- a/travo/locale/travo.en.yml +++ b/travo/locale/travo.en.yml @@ -51,7 +51,7 @@ en: invalid token environment variable: "Invalid or expired TRAVO_TOKEN environment variable." sign in: "Sign in" authentication required: "Authentification required" - abandon conflicting merge: "Integrating %{content} would cause a conflit; abandon" + abort conflicting merge: "Integrating %{content} would cause a conflit; abandon" updates: "updates" erratas: erratas submission failed: "submission failed: try to fetch first?" @@ -103,3 +103,28 @@ en: Number of submissions: "#submission: %{number_of_submissions}" last release: "Last release: %{visibility} %{date}" Needs manual grading: "Needs manual grading: %{counts} \nGrades: %{mean}+/-%{dev}" + initializing submission: "Initializing the submission from the assignment" + your submission: "Your submission" + share with: "Share with (team work)" + no main submission: "%{leader_name} has not submitted %{assignment_name} on GitLab or did not share it with you" + set main submission: "Set main submission (team work)" + No need to launch Jupyter on JupyterHub: "No need to launch Jupyter on JupyterHub" + "Can't open work dir: assignment_dir is not set": "Can't open work dir: assignment_dir is not set" + remove submission: "Remove submission" + set student group: "Set student group" + cancel: "Cancel" + validate: "Validate" + confirm: "Are you sure?" + other actions: "Other actions" + waiting for information: "Waiting for information" + canceled: "Canceled" + cannot set self as leader: "Cannot set self as leader" + missing assignment directory: "Missing assignment directory; fetch your assignment?" + update view: "Update view" + fetch from: "Fetch from" + downloading: "Downloading" + save before: "Saving before %{what}" + merging: "Merging %{content}" + try to merge: "Trying to merge" + no content: "No %{content}" + corrupted assignment directory: "Corrupted assignment directory" diff --git a/travo/locale/travo.fr.yml b/travo/locale/travo.fr.yml index 2107b783944dd5885182e6a0381c042a2c1da91a..04a09f729c549e4080de6652c22e4c54b9da7921 100644 --- a/travo/locale/travo.fr.yml +++ b/travo/locale/travo.fr.yml @@ -9,6 +9,7 @@ fr: assignment: "Devoir" assignment repository: "Sujet" work directory: "Répertoire de travail" + repository: "Dépôt git" submission: "Dépôt" score: "Score" success: "Succès" @@ -24,6 +25,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 +54,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 +106,25 @@ 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}+/-%{dev}" + 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}" + initializing submission: "Initialisation de la soumission à partir du devoir" + your submission: "Votre submission" + corrupted assignment directory: "Corrupted assignment directory" diff --git a/travo/tests/test_utils.py b/travo/tests/test_utils.py index a2d9ff8b0b480ce26e10f35a2a230b7ecc6f0d98..34629123f5b7f947df3850061976e8f7ab945acd 100644 --- a/travo/tests/test_utils.py +++ b/travo/tests/test_utils.py @@ -6,14 +6,16 @@ from travo.utils import git_get_origin def test_get_origin_error(tmp_path: str) -> None: + # Forces english locale for testing. See: + # https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html + os.environ["LC_ALL"] = "C" + os.chdir(tmp_path) with pytest.raises(RuntimeError, match="fatal: not a git repository"): - os.environ["LANG"] = "en_US.UTF-8" # The directory is not a git repository git_get_origin() subprocess.run(["git", "init", "--quiet"], cwd=tmp_path) with pytest.raises(RuntimeError, match="error: No such remote 'origin'"): - os.environ["LANG"] = "en_US.UTF-8" # The remote origin is not defined git_get_origin()