From 51d5af11ac5a263040b11cc4093baec236c5e511 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:07:53 +0000 Subject: [PATCH 1/4] Add preview-to-flow workflow: branch preview view, helper, template, and tests Co-authored-by: inducer <352067+inducer@users.noreply.github.com> Show branch preview links in the update course page (git-sha-table) Co-authored-by: inducer <352067+inducer@users.noreply.github.com> --- course/templates/course/git-sha-table.html | 12 + course/templates/course/update-branch.html | 48 ++++ course/versioning.py | 230 ++++++++++++++++++ relate/urls.py | 7 + tests/test_versioning.py | 270 +++++++++++++++++++++ 5 files changed, 567 insertions(+) create mode 100644 course/templates/course/update-branch.html 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..3b1b60ef1 --- /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 it:" %}

+ +
+ {% endif %} +{% endblock %} diff --git a/course/versioning.py b/course/versioning.py index 128da1edf..ce334cc4c 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 @@ -601,6 +602,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 @@ -614,6 +620,230 @@ def update_course(pctx: CoursePageContext): # }}} +# {{{ branch preview + + +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*.""" + + from django.core.exceptions import ObjectDoesNotExist + + from course.repo import get_repo_tree + + def get_flow_entry_map(commit_sha: bytes) -> dict[str, bytes]: + 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") + } + + old_entries = get_flow_entry_map(old_sha) + new_entries = get_flow_entry_map(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_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() + + modified_flow_ids: list[str] | None = None + + form = None + response_form = None + if request.method == "POST": + form = BranchPreviewForm(previewing, request.POST) + + command = None + for cmd in [ + 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.")) + else: + if command == CourseRevisionCommand.fetch_preview: + client, remote_path = \ + get_dulwich_client_and_remote_path_from_course(course) + fetch_pack_result = client.fetch(remote_path, repo) + assert isinstance( + fetch_pack_result, dulwich.client.FetchPackResult) + transfer_remote_refs(repo, fetch_pack_result) + messages.add_message(request, messages.SUCCESS, + _("Fetch successful.")) + + 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}) + response_form = form + else: + new_sha = refs[branch_ref] + + 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)) + response_form = form + 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))) + + participation.preview_git_commit_sha = \ + new_sha.decode() + participation.save() + messages.add_message(request, messages.INFO, + _("Preview activated.")) + + if navigate_to_modified_flow: + active_sha = \ + course.active_git_commit_sha.encode() + modified_flow_ids = get_modified_flow_ids( + content_repo, active_sha, 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)}) + else: + response_form = form + + elif request.method == "GET": + pass + else: + raise SuspiciousOperation("unexpected method") + + if response_form is None: + previewing = bool(participation.preview_git_commit_sha) + form = BranchPreviewForm(previewing) + + assert form is not None + + refs = repo.get_refs() + branch_sha: str | None = None + if branch_ref in refs: + branch_sha = refs[branch_ref].decode() + + return render_course_page(pctx, "course/update-branch.html", { + "form": form, + "branch_name": branch_name, + "branch_sha": branch_sha, + "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 43579ebc4..29f65cbf0 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1222,3 +1222,273 @@ def test_repo_is_in_subdir(self): from course.content import SubdirRepoWrapper 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, []) + +# }}} From ff48fa583462bd226686665f37956bc1d6c5d39e Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Fri, 27 Feb 2026 15:06:12 -0600 Subject: [PATCH 2/4] Update baseline --- .basedpyright/baseline.json | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index 3e07b0c24..6523e3e9c 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -22515,6 +22515,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": { @@ -82072,6 +82128,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": [ From 8fecdf9347aef436a40821fb2171078a49538242 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 21 Apr 2026 18:58:20 -0500 Subject: [PATCH 3/4] Clean up the logic a bit --- course/templates/course/update-branch.html | 2 +- course/versioning.py | 33 ++++++++-------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/course/templates/course/update-branch.html b/course/templates/course/update-branch.html index 3b1b60ef1..f00d7f225 100644 --- a/course/templates/course/update-branch.html +++ b/course/templates/course/update-branch.html @@ -33,7 +33,7 @@

    {{ form_description }}

    {% 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 it:" %}

    +

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

      {% for flow_id in modified_flow_ids %}
    • diff --git a/course/versioning.py b/course/versioning.py index 21c3d8385..cd780ab2f 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -660,7 +660,6 @@ def update_course(pctx: CoursePageContext): # {{{ branch preview - def get_modified_flow_ids( content_repo: Repo | SubdirRepoWrapper, old_sha: bytes, @@ -743,10 +742,6 @@ def update_course_from_branch( branch_ref = ("refs/remotes/origin/" + branch_name).encode() - modified_flow_ids: list[str] | None = None - - form = None - response_form = None if request.method == "POST": form = BranchPreviewForm(previewing, request.POST) @@ -789,7 +784,6 @@ def update_course_from_branch( _("Branch '%(branch)s' not found. " "Use 'Fetch and preview' to fetch it first.") % {"branch": branch_name}) - response_form = form else: new_sha = refs[branch_ref] @@ -807,7 +801,6 @@ def update_course_from_branch( _("Course content did not validate " "successfully:
      %s
      " "Update not applied.") % str(e)) - response_form = form else: if not warnings: messages.add_message(request, messages.SUCCESS, @@ -831,10 +824,9 @@ def update_course_from_branch( _("Preview activated.")) if navigate_to_modified_flow: - active_sha = \ - course.active_git_commit_sha.encode() modified_flow_ids = get_modified_flow_ids( - content_repo, active_sha, new_sha) + content_repo, + course.active_git_commit_sha.encode(), new_sha) if len(modified_flow_ids) == 1: return redirect( @@ -852,29 +844,28 @@ def update_course_from_branch( ": %(err_type)s %(err_str)s") % {"err_type": type(e).__name__, "err_str": str(e)}) - else: - response_form = form elif request.method == "GET": - pass - else: - raise SuspiciousOperation("unexpected method") - - if response_form is None: previewing = bool(participation.preview_git_commit_sha) form = BranchPreviewForm(previewing) - assert form is not None + else: + raise SuspiciousOperation("unexpected method") refs = repo.get_refs() - branch_sha: str | None = None + branch_sha: bytes | None = None + modified_flow_ids: list[str] | None = None if branch_ref in refs: - branch_sha = refs[branch_ref].decode() + 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, + "branch_sha": branch_sha.decode() if branch_sha is not None else None, "modified_flow_ids": modified_flow_ids, "form_description": gettext("Preview Branch"), }) From 4ec600af27119701b4baadffc9a20f845ca34d30 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Sat, 2 May 2026 09:03:35 -0500 Subject: [PATCH 4/4] Factor out fetch and validate --- course/versioning.py | 190 +++++++++++++++++++++---------------------- 1 file changed, 93 insertions(+), 97 deletions(-) diff --git a/course/versioning.py b/course/versioning.py index cd780ab2f..6098278ad 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -333,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: "), + "
        %s
      ") + % ("".join( + "
    • %s: %s
    • " + % (w.location, w.text) + for w in warnings))) + + return True + + # {{{ update def is_ancestor_commit( @@ -381,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: @@ -422,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: "), - "
        %s
      ") - % ("".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.")) @@ -660,31 +682,34 @@ def update_course(pctx: CoursePageContext): # {{{ branch preview -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*.""" - +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 {} - def get_flow_entry_map(commit_sha: bytes) -> dict[str, bytes]: - 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") + } - 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") - } - old_entries = get_flow_entry_map(old_sha) - new_entries = get_flow_entry_map(new_sha) +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 @@ -694,7 +719,6 @@ def get_flow_entry_map(commit_sha: bytes) -> dict[str, bytes]: class BranchPreviewForm(StyledForm): - def __init__(self, previewing: bool, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -709,6 +733,7 @@ def add_button(desc: str, label: str | object) -> None: if previewing: add_button("end_preview", _("End preview")) + add_button("fetch", _("Fetch")) add_button("fetch_preview", _("Fetch and preview")) add_button("preview", _("Preview")) @@ -747,6 +772,7 @@ def update_course_from_branch( command = None for cmd in [ + CourseRevisionCommand.fetch, CourseRevisionCommand.fetch_preview, CourseRevisionCommand.preview, CourseRevisionCommand.end_preview]: @@ -767,17 +793,16 @@ def update_course_from_branch( participation.save() messages.add_message(request, messages.INFO, _("Preview ended.")) - else: - if command == CourseRevisionCommand.fetch_preview: - client, remote_path = \ - get_dulwich_client_and_remote_path_from_course(course) - fetch_pack_result = client.fetch(remote_path, repo) - assert isinstance( - fetch_pack_result, dulwich.client.FetchPackResult) - transfer_remote_refs(repo, fetch_pack_result) - messages.add_message(request, messages.SUCCESS, - _("Fetch successful.")) - + 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, @@ -787,36 +812,7 @@ def update_course_from_branch( else: new_sha = refs[branch_ref] - 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)) - 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: "), - "
        %s
      ") - % ("".join( - "
    • %s: %s
    • " - % (w.location, w.text) - for w in warnings))) - + if validate_course(request, course, repo, new_sha): participation.preview_git_commit_sha = \ new_sha.decode() participation.save()