diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 9324164a5..6db295694 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -22133,6 +22133,62 @@ "lineCount": 1 } }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 12, + "endColumn": 22, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 12, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 45, + "endColumn": 54, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 15, + "endColumn": 25, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 15, + "endColumn": 34, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 8, + "endColumn": 24, + "lineCount": 1 + } + }, + { + "code": "reportUnknownMemberType", + "range": { + "startColumn": 12, + "endColumn": 33, + "lineCount": 1 + } + }, { "code": "reportUnknownMemberType", "range": { @@ -80970,6 +81026,54 @@ "endColumn": 56, "lineCount": 1 } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 37, + "endColumn": 48, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 55, + "endColumn": 72, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 34, + "endColumn": 45, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 52, + "endColumn": 59, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 28, + "endColumn": 53, + "lineCount": 1 + } + }, + { + "code": "reportMissingParameterType", + "range": { + "startColumn": 28, + "endColumn": 50, + "lineCount": 1 + } } ], "./tests/test_views.py": [ diff --git a/course/templates/course/git-sha-table.html b/course/templates/course/git-sha-table.html index 0d9bdb2ac..f7cd67fe6 100644 --- a/course/templates/course/git-sha-table.html +++ b/course/templates/course/git-sha-table.html @@ -26,4 +26,16 @@ {{ git_url }} ({% trans "Manage access tokens" %}) + {% if remote_branches %} + + {% trans "Branches (click to preview)" %} + + {% for branch in remote_branches %} + + {{ branch }} » + {% endfor %} + + + {% endif %} diff --git a/course/templates/course/update-branch.html b/course/templates/course/update-branch.html new file mode 100644 index 000000000..f00d7f225 --- /dev/null +++ b/course/templates/course/update-branch.html @@ -0,0 +1,48 @@ +{% extends "course/course-base.html" %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %} + {{ form_description }} - {{ relate_site_name }} +{% endblock %} + +{% block content %} +

{{ form_description }}

+ + + + + + + + + + +
{% trans "Branch" %}{{ branch_name }}
{% trans "Branch SHA" %} + {% if branch_sha %} + {{ branch_sha }} + {% else %} + {% trans "Not found locally – use 'Fetch and preview' to retrieve it." %} + {% endif %} +
+ +
+ {% crispy form %} +
+ + {% if modified_flow_ids %} +
+

{% trans "Modified flows" %}

+

{% trans "The following flows were modified in this branch compared to the currently-live revision. Click a flow to preview:" %}

+ +
+ {% endif %} +{% endblock %} diff --git a/course/versioning.py b/course/versioning.py index a45f86f60..6098278ad 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -36,6 +36,7 @@ ) import dulwich.client +import dulwich.objects import dulwich.repo import dulwich.web import paramiko @@ -332,6 +333,62 @@ def set_up_new_course(request: http.HttpRequest) -> http.HttpResponse: # }}} +def fetch_course( + request: http.HttpRequest, + course: Course, + repo: Repo, + ): + client, remote_path = \ + get_dulwich_client_and_remote_path_from_course(course) + fetch_pack_result = client.fetch(remote_path, repo) + transfer_remote_refs(repo, fetch_pack_result) + messages.add_message(request, messages.SUCCESS, + _("Fetch successful.")) + + return fetch_pack_result + + +def validate_course( + request: http.HttpRequest, + course: Course, + content_repo: Repo | SubdirRepoWrapper, + new_sha: bytes, + ): + from course.validation import ( + ValidationError, + validate_course_content, + ) + try: + warnings = validate_course_content( + content_repo, + course.course_file, course.events_file, + new_sha, course=course) + except ValidationError as e: + messages.add_message(request, messages.ERROR, + _("Course content did not validate " + "successfully:
%s
" + "Update not applied.") % str(e)) + + return False + else: + if not warnings: + messages.add_message(request, messages.SUCCESS, + _("Course content validated " + "successfully.")) + else: + messages.add_message(request, messages.WARNING, + string_concat( + _("Course content validated OK, " + "with warnings: "), + "") + % ("".join( + "
  • %s: %s
  • " + % (w.location, w.text) + for w in warnings))) + + return True + + # {{{ update def is_ancestor_commit( @@ -380,14 +437,8 @@ def run_course_update_command( CourseRevisionCommand.fetch, CourseRevisionCommand.fetch_update, CourseRevisionCommand.fetch_preview]: - client, remote_path = \ - get_dulwich_client_and_remote_path_from_course(pctx.course) - - fetch_pack_result = client.fetch(remote_path, repo) - assert isinstance(fetch_pack_result, dulwich.client.FetchPackResult) - - transfer_remote_refs(repo, fetch_pack_result) + fetch_pack_result = fetch_course(request, pctx.course, repo) remote_head_sha = cast("bytes", fetch_pack_result.refs[b"HEAD"]) if prevent_discarding_revisions: # Guard against bad scenario: @@ -421,37 +472,9 @@ def run_course_update_command( return - # {{{ validate - - from course.validation import ValidationError, validate_course_content - try: - warnings = validate_course_content( - content_repo, pctx.course.course_file, pctx.course.events_file, - new_sha, course=pctx.course) - except ValidationError as e: - from traceback import print_exc - print_exc() - - messages.add_message(request, messages.ERROR, - _("Course content did not validate successfully:
    %s
    " - "Update not applied.") % str(e)) + if not validate_course(request, pctx.course, content_repo, new_sha): return - else: - if not warnings: - messages.add_message(request, messages.SUCCESS, - _("Course content validated successfully.")) - else: - messages.add_message(request, messages.WARNING, - string_concat( - _("Course content validated OK, with warnings: "), - "") - % ("".join( - f"
  • {w.location}: {w.text}
  • " - for w in warnings))) - - # }}} - if command in [CourseRevisionCommand.fetch_preview, CourseRevisionCommand.preview]: messages.add_message(request, messages.INFO, _("Preview activated.")) @@ -639,6 +662,11 @@ def update_course(pctx: CoursePageContext): args=(course.identifier, ""))), "token_url": reverse("relate-manage_authentication_tokens", args=(course.identifier,)), + "remote_branches": sorted( + _remove_prefix(b"refs/remotes/origin/", ref).decode("utf-8", + errors="replace") + for ref in repo.get_refs() + if ref.startswith(b"refs/remotes/origin/")), }) assert form is not None @@ -652,6 +680,195 @@ def update_course(pctx: CoursePageContext): # }}} +# {{{ branch preview + +def get_flow_entry_map( + content_repo: Repo | SubdirRepoWrapper, + commit_sha: bytes + ) -> dict[str, bytes]: + from django.core.exceptions import ObjectDoesNotExist + + from course.repo import get_repo_tree + try: + flows_tree = get_repo_tree(content_repo, "flows", commit_sha) + except ObjectDoesNotExist: + return {} + + assert isinstance(flows_tree, dulwich.objects.Tree) + return { + entry.path[:-4].decode("utf-8"): entry.sha + for entry in flows_tree.items() + if entry.path.endswith(b".yml") + } + + +def get_modified_flow_ids( + content_repo: Repo | SubdirRepoWrapper, + old_sha: bytes, + new_sha: bytes) -> list[str]: + """Return flow IDs that were added or modified between *old_sha* and *new_sha*.""" + + old_entries = get_flow_entry_map(content_repo, old_sha) + new_entries = get_flow_entry_map(content_repo, new_sha) + + return sorted( + flow_id + for flow_id, sha in new_entries.items() + if old_entries.get(flow_id) != sha + ) + + +class BranchPreviewForm(StyledForm): + def __init__(self, previewing: bool, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self.fields["navigate_to_modified_flow"] = forms.BooleanField( + label=_("Go directly to modified flow after preview"), + initial=True, + required=False) + + def add_button(desc: str, label: str | object) -> None: + self.helper.add_input(Submit(desc, label)) + + if previewing: + add_button("end_preview", _("End preview")) + + add_button("fetch", _("Fetch")) + add_button("fetch_preview", _("Fetch and preview")) + add_button("preview", _("Preview")) + + +@login_required +@course_view +def update_course_from_branch( + pctx: CoursePageContext, + branch_name: str) -> http.HttpResponse: + + if not pctx.has_permission(PPerm.preview_content): + raise PermissionDenied() + + course = pctx.course + request = pctx.request + content_repo = pctx.repo + + from course.repo import SubdirRepoWrapper + if isinstance(content_repo, SubdirRepoWrapper): + repo = content_repo.repo + elif isinstance(content_repo, Repo): + repo = content_repo + else: + raise AssertionError("only actual dulwich repos allowed") + assert isinstance(repo, dulwich.repo.Repo) + + participation = pctx.participation + assert participation is not None + + previewing = bool(participation.preview_git_commit_sha) + + branch_ref = ("refs/remotes/origin/" + branch_name).encode() + + if request.method == "POST": + form = BranchPreviewForm(previewing, request.POST) + + command = None + for cmd in [ + CourseRevisionCommand.fetch, + CourseRevisionCommand.fetch_preview, + CourseRevisionCommand.preview, + CourseRevisionCommand.end_preview]: + if cmd.value in form.data: + command = cmd + break + + if command is None: + raise SuspiciousOperation(_("invalid command")) + + if form.is_valid(): + navigate_to_modified_flow = form.cleaned_data.get( + "navigate_to_modified_flow", True) + + try: + if command == CourseRevisionCommand.end_preview: + participation.preview_git_commit_sha = None + participation.save() + messages.add_message(request, messages.INFO, + _("Preview ended.")) + elif command in [ + CourseRevisionCommand.fetch, + CourseRevisionCommand.fetch_preview + ]: + fetch_course(request, course, repo) + + if command in [ + CourseRevisionCommand.fetch_preview, + CourseRevisionCommand.preview + ]: + refs = repo.get_refs() + if branch_ref not in refs: + messages.add_message(request, messages.ERROR, + _("Branch '%(branch)s' not found. " + "Use 'Fetch and preview' to fetch it first.") + % {"branch": branch_name}) + else: + new_sha = refs[branch_ref] + + if validate_course(request, course, repo, new_sha): + participation.preview_git_commit_sha = \ + new_sha.decode() + participation.save() + messages.add_message(request, messages.INFO, + _("Preview activated.")) + + if navigate_to_modified_flow: + modified_flow_ids = get_modified_flow_ids( + content_repo, + course.active_git_commit_sha.encode(), new_sha) + + if len(modified_flow_ids) == 1: + return redirect( + "relate-view_start_flow", + course.identifier, + modified_flow_ids[0]) + + except Exception as e: + import traceback + traceback.print_exc() + + messages.add_message(request, messages.ERROR, + string_concat( + pgettext("Starting of Error message", "Error"), + ": %(err_type)s %(err_str)s") + % {"err_type": type(e).__name__, + "err_str": str(e)}) + + elif request.method == "GET": + previewing = bool(participation.preview_git_commit_sha) + form = BranchPreviewForm(previewing) + + else: + raise SuspiciousOperation("unexpected method") + + refs = repo.get_refs() + branch_sha: bytes | None = None + modified_flow_ids: list[str] | None = None + if branch_ref in refs: + branch_sha = refs[branch_ref] + + modified_flow_ids = get_modified_flow_ids( + content_repo, + course.active_git_commit_sha.encode(), branch_sha) + + return render_course_page(pctx, "course/update-branch.html", { + "form": form, + "branch_name": branch_name, + "branch_sha": branch_sha.decode() if branch_sha is not None else None, + "modified_flow_ids": modified_flow_ids, + "form_description": gettext("Preview Branch"), + }) + +# }}} + + # {{{ git endpoint diff --git a/relate/urls.py b/relate/urls.py index 390285e28..0ffb6a3b6 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -360,6 +360,13 @@ + "/update/$", course.versioning.update_course, name="relate-update_course"), + re_path(r"^course" + "/" + COURSE_ID_REGEX + + "/update/branch" + r"/(?P[-a-zA-Z0-9_./@]+)" + "/$", + course.versioning.update_course_from_branch, + name="relate-update_course_from_branch"), re_path(r"^course" "/" + COURSE_ID_REGEX + "/git" diff --git a/tests/test_versioning.py b/tests/test_versioning.py index b5286967e..082b6a801 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1230,6 +1230,276 @@ def test_repo_is_in_subdir(self): self.assertIsInstance(mock_run_update.call_args[0][1], Repo) +# {{{ UpdateCourseFromBranchTest + +class UpdateCourseFromBranchTest(SingleCourseTestMixin, MockAddMessageMixing, + TestCase): + + default_branch_name = "main" + + def get_branch_preview_url(self, branch_name=None, course_identifier=None): + if branch_name is None: + branch_name = self.default_branch_name + if course_identifier is None: + course_identifier = self.course.identifier + from django.urls import reverse + return reverse("relate-update_course_from_branch", + args=[course_identifier, branch_name]) + + def post_branch_preview(self, branch_name=None, command="preview", + navigate_to_modified_flow=True, + force_login_instructor=True): + if branch_name is None: + branch_name = self.default_branch_name + data = {command: "on"} + if navigate_to_modified_flow: + data["navigate_to_modified_flow"] = "on" + url = self.get_branch_preview_url(branch_name) + if force_login_instructor: + user = self.instructor_participation.user + else: + user = self.get_logged_in_user(self.client) + with self.temporarily_switch_to_user(user): + return self.client.post(url, data) + + def test_no_permission(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.client.get(self.get_branch_preview_url()) + self.assertEqual(resp.status_code, 403) + + def test_get(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.client.get(self.get_branch_preview_url()) + self.assertEqual(resp.status_code, 200) + + def test_get_with_existing_branch(self): + # The repo has refs we can look up + from course.utils import CoursePageContext + rf = RequestFactory() + request = rf.get(self.get_branch_preview_url()) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + repo = pctx.repo + refs = repo.get_refs() + # Check that some refs exist (from the test course setup) + self.assertGreater(len(refs), 0) + + def test_unknown_command(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.client.post( + self.get_branch_preview_url(), + {"unknown_command": "on"}) + self.assertEqual(resp.status_code, 400) + + @suppress_stdout_decorator(suppress_stderr=True) + def test_preview_branch_not_found(self): + # Branch doesn't exist locally → should show error + resp = self.post_branch_preview( + branch_name="nonexistent-branch", + command="preview", + navigate_to_modified_flow=False) + self.assertEqual(resp.status_code, 200) + self.assertAddMessageCalledWith("not found", reset=False) + + @suppress_stdout_decorator(suppress_stderr=True) + def test_end_preview(self): + # Set up a preview SHA first + self.instructor_participation.preview_git_commit_sha = ( + self.course.active_git_commit_sha) + self.instructor_participation.save() + + resp = self.post_branch_preview(command="end_preview") + self.assertEqual(resp.status_code, 200) + self.instructor_participation.refresh_from_db() + self.assertIsNone(self.instructor_participation.preview_git_commit_sha) + self.assertAddMessageCalledWith("Preview ended") + + @suppress_stdout_decorator(suppress_stderr=True) + def test_preview_with_branch(self): + # Set up a branch ref in the local repo + from course.utils import CoursePageContext + rf = RequestFactory() + request = rf.get(self.get_branch_preview_url()) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + from course.repo import SubdirRepoWrapper + repo = pctx.repo + if isinstance(repo, SubdirRepoWrapper): + real_repo = repo.repo + else: + real_repo = repo + head_sha = real_repo.head() + branch_ref = b"refs/remotes/origin/" + self.default_branch_name.encode() + real_repo[branch_ref] = head_sha + + with mock.patch( + "course.validation.validate_course_content", + return_value=[]): + resp = self.post_branch_preview( + command="preview", + navigate_to_modified_flow=False) + self.assertEqual(resp.status_code, 200) + self.instructor_participation.refresh_from_db() + self.assertIsNotNone( + self.instructor_participation.preview_git_commit_sha) + self.assertAddMessageCalledWith("Preview activated") + + @suppress_stdout_decorator(suppress_stderr=True) + def test_preview_navigate_single_modified_flow(self): + # Set up branch ref pointing to same SHA as active (no modifications) + # but mock get_modified_flow_ids to return a single flow + from course.utils import CoursePageContext + rf = RequestFactory() + request = rf.get(self.get_branch_preview_url()) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + from course.repo import SubdirRepoWrapper + repo = pctx.repo + if isinstance(repo, SubdirRepoWrapper): + real_repo = repo.repo + else: + real_repo = repo + head_sha = real_repo.head() + branch_ref = b"refs/remotes/origin/" + self.default_branch_name.encode() + real_repo[branch_ref] = head_sha + + with mock.patch( + "course.validation.validate_course_content", + return_value=[]): + with mock.patch( + "course.versioning.get_modified_flow_ids", + return_value=["quiz-test"]): + resp = self.post_branch_preview( + command="preview", + navigate_to_modified_flow=True) + + # Should redirect to the single modified flow + self.assertEqual(resp.status_code, 302) + from django.urls import reverse + expected_url = reverse("relate-view_start_flow", + args=[self.course.identifier, "quiz-test"]) + self.assertRedirects(resp, expected_url, fetch_redirect_response=False) + + @suppress_stdout_decorator(suppress_stderr=True) + def test_preview_navigate_multiple_modified_flows(self): + # Branch ref pointing to same SHA + from course.utils import CoursePageContext + rf = RequestFactory() + request = rf.get(self.get_branch_preview_url()) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + from course.repo import SubdirRepoWrapper + repo = pctx.repo + if isinstance(repo, SubdirRepoWrapper): + real_repo = repo.repo + else: + real_repo = repo + head_sha = real_repo.head() + branch_ref = b"refs/remotes/origin/" + self.default_branch_name.encode() + real_repo[branch_ref] = head_sha + + with mock.patch( + "course.validation.validate_course_content", + return_value=[]): + with mock.patch( + "course.versioning.get_modified_flow_ids", + return_value=["flow-a", "flow-b"]): + resp = self.post_branch_preview( + command="preview", + navigate_to_modified_flow=True) + + # Should render page (200) with modified_flow_ids in context + self.assertEqual(resp.status_code, 200) + self.assertIn("flow-a", resp.context["modified_flow_ids"]) + self.assertIn("flow-b", resp.context["modified_flow_ids"]) + + @suppress_stdout_decorator(suppress_stderr=True) + def test_form_not_valid(self): + with mock.patch( + "course.versioning.BranchPreviewForm.is_valid", + return_value=False): + resp = self.post_branch_preview( + command="preview", + navigate_to_modified_flow=False) + self.assertEqual(resp.status_code, 200) + + @suppress_stdout_decorator(suppress_stderr=True) + def test_fetch_preview_calls_fetch(self): + from dulwich.client import FetchPackResult + + from course.utils import CoursePageContext + rf = RequestFactory() + request = rf.get(self.get_branch_preview_url()) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + from course.repo import SubdirRepoWrapper + repo = pctx.repo + if isinstance(repo, SubdirRepoWrapper): + real_repo = repo.repo + else: + real_repo = repo + head_sha = real_repo.head() + + mock_client = mock.MagicMock() + mock_client.fetch.return_value = FetchPackResult( + refs={b"HEAD": head_sha, + b"refs/heads/main": head_sha}, + symrefs={}, + agent="Git") + + with mock.patch( + "course.versioning." + "get_dulwich_client_and_remote_path_from_course", + return_value=(mock_client, "/remote")): + with mock.patch( + "course.versioning.transfer_remote_refs") as mock_transfer: + with mock.patch( + "course.validation.validate_course_content", + return_value=[]): + resp = self.post_branch_preview( + command="fetch_preview", + navigate_to_modified_flow=False) + + self.assertEqual(mock_client.fetch.call_count, 1) + self.assertEqual(mock_transfer.call_count, 1) + # Response may be 200 (branch not found after mock) or 200 (found & previewed) + self.assertEqual(resp.status_code, 200) + + +class GetModifiedFlowIdsTest(SingleCourseTestMixin, TestCase): + + def test_same_commit_no_changes(self): + from course.utils import CoursePageContext + rf = RequestFactory() + from django.urls import reverse + request = rf.get(reverse("relate-update_course", + args=[self.course.identifier])) + request.user = self.instructor_participation.user + with CoursePageContext(request, self.course.identifier) as pctx: + repo = pctx.repo + head_sha = repo.get_refs()[b"HEAD"] + result = versioning.get_modified_flow_ids(repo, head_sha, head_sha) + # Same commit → no modifications + self.assertEqual(result, []) + + def test_no_flows_tree(self): + # If flows tree doesn't exist in old or new commit, + # all flows in new are "modified" + old_sha = b"nonexistent000000000000000000000000000000" + new_sha = b"nonexistent000000000000000000000000000001" + + mock_repo = mock.MagicMock() + + from django.core.exceptions import ObjectDoesNotExist + with mock.patch( + "course.repo.get_repo_tree", + side_effect=ObjectDoesNotExist): + result = versioning.get_modified_flow_ids(mock_repo, old_sha, new_sha) + self.assertEqual(result, []) + +# }}} + + @python_repo_class class BasicCourse: course_dot_yml: ClassVar[str] = """