Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
94c28c9
chore: Adds sandbox requirements for verawood
farhaanbukhsh Apr 30, 2026
75522f7
chore: update release.py with the release name
farhaanbukhsh Apr 30, 2026
8adbabb
fix: make mfe_config_api tests portable across release branches (#38497)
mgwozdz-unicon May 1, 2026
f5cf35a
chore: Update lti-consumer-xblock
feanil Apr 30, 2026
49ce294
fix: freeze time to avoid jwt expiration (#38492)
wgu-taylor-payne Apr 30, 2026
02e5a44
fix: properly update studio search index when tags are deleted (#3847…
mgwozdz-unicon May 4, 2026
f151fb1
fix: make studio reindex more robust, provide better logging (#38498)
bradenmacdonald May 5, 2026
0a1f787
feat: use extended profile model in the account settings
BryanttV Jul 31, 2025
9486e53
refactor: streamline extended profile handling in account settings
BryanttV Apr 6, 2026
af6caba
test: update unit tests according new changes
BryanttV Apr 6, 2026
9b4b263
fix: satisfy ruff and pylint rules
BryanttV Apr 21, 2026
d79b136
refactor: enhance error handling in extended profile updates
BryanttV Apr 21, 2026
c5d86d3
feat: Upgrade Python dependency openedx-authz (#38491)
github-actions[bot] Apr 30, 2026
d5499d3
chore: update readme for verawood dependency (#38509)
farhaanbukhsh May 5, 2026
be3fc12
fix: update to edxval to 4.0.1
ormsbee May 5, 2026
9138c0b
feat: translate STUDIO_BASE_URL to cmsBaseUrl in frontend site config
arbrandes May 6, 2026
0eeda4b
feat: translate PARAGON_THEME_URLS into frontend-base theme config
arbrandes May 7, 2026
8ecb80c
build: Test on the head of the release branches. (#38596)
feanil May 8, 2026
7c39fe7
feat: add ordering, exam_type, and username to instructor API v2 endp…
brianjbuck-wgu Apr 30, 2026
d9ae29e
fix: Overwrite allowances in special exams instead of creating a new …
brianjbuck-wgu May 1, 2026
d97ae97
fix: Add exam_name and ready_to_resume to Inst Dash Special Exams tab…
brianjbuck-wgu May 4, 2026
680cd1b
fix: Return 403 when masqueraded user accesses instructor dashboard (…
brianjbuck-wgu May 8, 2026
27c1db8
feat: add generate exception certificates modal (#38547)
wgu-jesse-stewart May 8, 2026
ecbb48d
fix: adjust gradebook URL to correct value (#38479)
dwong2708 Apr 30, 2026
f25bbc4
fix: require Django staff to call set_course_mode_price endpoint
feanil May 27, 2026
fb24c34
feat: backport filter for instructor dash tabs (#38663)
holaontiveros Jun 2, 2026
f5b26e3
fix: django admin wasn't showing details of content libraries (#38578…
MaferMazu Jun 3, 2026
50af17b
fix: implement OAuth nonce replay protection in LTI provider
feanil Jun 1, 2026
7da480d
style: fix pylint and ruff issues in LTI nonce replay test
feanil Jun 4, 2026
98f6a07
fix: backport several important library fixes to Verawood (#38695)
bradenmacdonald Jun 9, 2026
ad55b04
feat: Upgrade Python dependency lti-consumer-xblock
navinkarkera Jun 10, 2026
c9831c2
fix: prevent SSRF in the Studio video download endpoint
feanil Jun 9, 2026
b1277ef
fix: fix enrollment counts for audit courses in Instructor Dashboard …
brianjbuck-wgu Jun 2, 2026
367cdc0
fix: use a bulk context when pasting containers into a content librar…
bradenmacdonald Jun 25, 2026
693add3
[Backport] feat: add platform glob scope support and some fixes (#38788)
BryanttV Jun 25, 2026
779cde9
feat: add ora reminder notification (#38298)
AhtishamShahid Jun 24, 2026
261008a
Merge pull request #38828 from AhtishamShahid/ahtisham/backport-ora-r…
bmtcril Jul 2, 2026
929815f
fix: ensure navigation sidebar serves fresh data after course publish…
wgu-taylor-payne Jul 6, 2026
f8c0987
fix: bump vendored pdf.js to 5.7.284
feanil Jun 18, 2026
ceb6a18
fix: Cannot rerun courses - authz role assignment expects rerun to al…
rodmgwgu Jul 8, 2026
a05e696
feat: Upgrade Python dependency openedx-authz (#38857)
github-actions[bot] Jul 9, 2026
67ac46a
feat: pdf block enabled by default (#38870)
Kelketek Jul 10, 2026
8f18724
feat: scope immediate notification email batching per user (#38866)
AhtishamShahid Jul 14, 2026
f75e22b
fix: allow periods in block_ids (#38884)
ormsbee Jul 14, 2026
0a675f5
Merge pull request #38886 from openedx/ahtisham/backport-38866-verawood
AhtishamShahid Jul 14, 2026
b18a0ee
fix: multiple fixes from openedx-core 1.0.3
ormsbee Jul 14, 2026
5a43341
feat: add show_email_preferences to v3 configurations API (#38875)
awais-ansari Jul 14, 2026
872fd12
fix: keep unmigrated legacy roles working under the authz authoring f…
mariajgrimaldi Jul 16, 2026
553df2b
fix: add scroll to modal content when fullscreen (#38859) (#38893)
navinkarkera Jul 16, 2026
fffafe2
fix: only auto-submit SSO registration form when provider returns an …
ayesha-waris Jul 17, 2026
2af03d2
fix: escape user input in notification content templates
feanil Jul 7, 2026
8422334
fix: replace string-prefix check in safe_extractall with commonpath
feanil Jun 17, 2026
7922fa4
fix: ComponentLinks were not deleted when parent container was delete…
bradenmacdonald Jul 17, 2026
e88d924
fix: CSRF fix for xqueue interface
ormsbee Jul 17, 2026
006fa2b
chore: upgrade openedx-authz==1.21.0 (#38897) (#38900)
BryanttV Jul 20, 2026
92696c4
fix: display number and display org added to course common v2 seriali…
jacobo-dominguez-wgu Jul 20, 2026
9e67d14
fix: gate support course-team GET on can-manage authorization
feanil Jul 13, 2026
2bb9439
Notes created in a CCX are stored and retrieved from the master course
scottrish Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
push:
branches:
- master
- release/*

concurrency:
# We only need to be running tests for the latest commit on each PR
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ database.sqlite
courseware/static/js/mathjax/*
flushdb.sh
build
# Vendored pdf.js needs a build/ subdir whose name happens to collide with the
# generic 'build' ignore above.
!/common/static/js/vendor/pdfjs/build/
/src/
\#*\#
.env/
Expand Down
17 changes: 17 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,23 @@ running at the given ports.
- localhost:1997
- ACCOUNT_MICROFRONTEND_URL

Security Deployment Requirements
********************************

Some platform features require a **shared** Django cache backend (Redis or
Memcached) to function correctly across multiple LMS nodes:

* **LTI Provider** — OAuth nonce replay protection stores seen nonces in the
Django ``default`` cache. A per-process backend (e.g. ``LocMemCache``) will
not detect replays that arrive on a different node. See
`lms/djangoapps/lti_provider/README.rst`_ for details.

Tutor-based deployments satisfy this requirement automatically. For bare-metal
or custom deployments, verify that ``CACHES['default']`` points at a shared
Redis or Memcached instance before enabling these features.

.. _lms/djangoapps/lti_provider/README.rst: lms/djangoapps/lti_provider/README.rst

License
*******

Expand Down
12 changes: 6 additions & 6 deletions cms/djangoapps/contentstore/asset_storage_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@
from openedx_filters.content_authoring.filters import LMSPageURLRequested
from pymongo import ASCENDING, DESCENDING

from common.djangoapps.student.roles import enable_authz_course_authoring
from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.contentserver.caching import del_cached_content
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.toggles import enable_authz_course_authoring
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.contentstore.content import StaticContent # pylint: disable=wrong-import-order
from xmodule.contentstore.django import contentstore # pylint: disable=wrong-import-order
from xmodule.exceptions import NotFoundError # pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # pylint: disable=wrong-import-order

from .exceptions import AssetNotFoundException, AssetSizeTooLargeException
from .utils import get_files_uploads_url, get_response_format, request_response_format_is_json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from rest_framework import serializers

from cms.djangoapps.contentstore import toggles
from openedx.core import toggles as core_toggles
from common.djangoapps.student.roles import enable_authz_course_authoring


class CourseWaffleFlagsSerializer(serializers.Serializer):
Expand Down Expand Up @@ -221,4 +221,4 @@ def get_enable_authz_course_authoring(self, obj):
Method to get the authz.enable_course_authoring waffle flag
"""
course_key = self.get_course_key()
return core_toggles.enable_authz_course_authoring(course_key)
return enable_authz_course_authoring(course_key)
110 changes: 109 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v1/views/tests/test_videos.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""
Unit tests for course settings views.
"""
from unittest.mock import patch
from datetime import datetime
from unittest.mock import MagicMock, patch

import ddt
import pytz
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import reverse
from edx_toggles.toggles import WaffleSwitch
from edx_toggles.toggles.testutils import override_waffle_switch
from edxval.api import (
create_profile,
create_video,
get_3rd_party_transcription_plans,
get_transcript_credentials_state_for_org,
get_transcript_preferences,
)
from rest_framework import status
from rest_framework.test import APIClient

from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import reverse_course_url
Expand Down Expand Up @@ -135,3 +140,106 @@ def test_VideoTranscriptEnabledFlag_enabled(self):
response = self.client.get(self.url)
self.assertIn("is_ai_translations_enabled", response.data) # noqa: PT009
self.assertTrue(response.data["is_ai_translations_enabled"]) # noqa: PT009


class VideoDownloadViewTest(CourseTestCase):
"""
Tests for VideoDownloadView.

The download endpoint fetches each requested ``files[].url`` server-side and
returns the bytes inside a zip. Those URLs must therefore be restricted to
the course's own video URLs, otherwise the endpoint is an SSRF primitive
(see GHSA-fpf9-9rpr-jvrx).
"""

ALLOWED_URL = "http://example.com/profile1/test.mp4"
# An internal address an attacker might try to reach via SSRF.
SSRF_URL = "http://169.254.169.254/latest/meta-data/"

def setUp(self):
super().setUp()
# reverse() with only course_id resolves to the download route (the
# usage route with the same name additionally requires edx_video_id).
self.url = reverse(
"cms.djangoapps.contentstore:v1:video_usage",
kwargs={"course_id": self.course.id},
)
self.api_client = APIClient()
self.api_client.force_authenticate(user=self.user)
create_profile("profile1")
create_video({
"edx_video_id": "test-video",
"client_video_id": "test.mp4",
"duration": 42.0,
"status": "file_complete",
"courses": [str(self.course.id)],
"created": datetime.now(pytz.utc),
"encoded_videos": [
{
"profile": "profile1",
"url": self.ALLOWED_URL,
"file_size": 1600,
"bitrate": 100,
},
],
})

@patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get")
def test_download_allowed_url(self, mock_get):
"""A URL that belongs to the course's videos is fetched and zipped."""
mock_get.return_value = MagicMock(
content=b"video-bytes",
headers={"Content-Type": "video/mp4"},
)
response = self.api_client.put(
self.url,
data={"files": [{"url": self.ALLOWED_URL, "name": "test.mp4"}]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
mock_get.assert_called_once_with(self.ALLOWED_URL, allow_redirects=True)

@patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get")
def test_rejects_url_not_belonging_to_course(self, mock_get):
"""
A URL that is not one of the course's video URLs is rejected before any
server-side request is made (SSRF protection).
"""
response = self.api_client.put(
self.url,
data={"files": [{"url": self.SSRF_URL, "name": "evil.txt"}]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009
mock_get.assert_not_called()

@patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get")
def test_rejects_when_any_url_is_disallowed(self, mock_get):
"""
A request mixing an allowed URL with a disallowed one is rejected
outright, without fetching the allowed URL either.
"""
response = self.api_client.put(
self.url,
data={"files": [
{"url": self.ALLOWED_URL, "name": "test.mp4"},
{"url": self.SSRF_URL, "name": "evil.txt"},
]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # noqa: PT009
mock_get.assert_not_called()

@patch("cms.djangoapps.contentstore.video_storage_handlers.requests.get")
def test_non_staff_user_denied(self, mock_get):
"""A user without studio read access cannot reach the fetch path."""
__, nonstaff_user = self.create_non_staff_authed_user_client()
client = APIClient()
client.force_authenticate(user=nonstaff_user)
response = client.put(
self.url,
data={"files": [{"url": self.ALLOWED_URL, "name": "test.mp4"}]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # noqa: PT009
mock_get.assert_not_called()
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/serializers/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ class CourseCommonSerializerV2(serializers.Serializer):
lms_link = serializers.SerializerMethodField()
cms_link = serializers.SerializerMethodField()
number = serializers.CharField()
display_number = serializers.CharField(source='display_number_with_default')
org = serializers.CharField()
display_org = serializers.CharField(source='display_org_with_default')
rerun_link = serializers.SerializerMethodField()
run = serializers.CharField(source='id.run')
url = serializers.SerializerMethodField()
Expand Down
18 changes: 14 additions & 4 deletions cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ def test_home_page_response(self):
("display_name", self.course.display_name),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
("number", self.active_course.number),
("display_number", self.active_course.display_number_with_default),
("org", self.active_course.org),
("display_org", self.active_course.display_org_with_default),
("rerun_link", f'/course_rerun/{course_id}'),
("run", self.course.id.run),
("url", f'/course/{course_id}'),
Expand All @@ -76,7 +78,9 @@ def test_home_page_response(self):
f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}',
),
("number", self.archived_course.number),
("display_number", self.archived_course.display_number_with_default),
("org", self.archived_course.org),
("display_org", self.archived_course.display_org_with_default),
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
("run", self.archived_course.id.run),
("url", f'/course/{str(self.archived_course.id)}'),
Expand Down Expand Up @@ -110,8 +114,10 @@ def test_active_only_query_if_passed(self):
("display_name", self.course.display_name),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
("number", self.active_course.number),
("display_number", self.active_course.display_number_with_default),
("org", self.active_course.org),
("display_org", self.active_course.display_org_with_default),
("rerun_link", f'/course_rerun/{str(self.course.id)}'),
("run", self.course.id.run),
("url", f'/course/{str(self.course.id)}'),
Expand Down Expand Up @@ -141,7 +147,9 @@ def test_archived_only_query_if_passed(self):
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),
("display_number", self.archived_course.display_number_with_default),
("org", self.archived_course.org),
("display_org", self.archived_course.display_org_with_default),
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
("run", self.archived_course.id.run),
("url", f'/course/{str(self.archived_course.id)}'),
Expand Down Expand Up @@ -171,7 +179,9 @@ def test_search_query_if_passed(self):
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),
("display_number", self.archived_course.display_number_with_default),
("org", self.archived_course.org),
("display_org", self.archived_course.display_org_with_default),
("rerun_link", f'/course_rerun/{str(self.archived_course.id)}'),
("run", self.archived_course.id.run),
("url", f'/course/{str(self.archived_course.id)}'),
Expand Down
35 changes: 26 additions & 9 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,21 @@ def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable
update_library_index.delay(str(library_key), datetime.now(UTC).isoformat())


@receiver(SignalHandler.item_deleted)
def handle_item_deleted(**kwargs):
@receiver(SignalHandler.pre_item_delete)
def handle_item_deleted(**kwargs) -> None:
"""
Receives the item_deleted signal sent by Studio when an XBlock is removed from
the course structure and removes any gating milestone data associated with it or
its descendants.
Receives the pre_item_delete signal sent by Studio when an XBlock is removed
from the course structure and removes any gating milestone and upstream link
data associated with it or its descendants.

We use the "pre" signal because once the actual "item_deleted" signal is
sent, it's impossible to fetch the descendants of the item.

NOTE: This partially overlaps with ``delete_upstream_downstream_link_handler``
(below), which also removes ComponentLink / ContainerLink rows on delete but
only for the single deleted block, not its descendants. This handler is the
one responsible for cascading the link deletion to the deleted block's
children. Keep the two in sync if you change either.

Arguments:
kwargs (dict): Contains the content usage key of the item deleted
Expand All @@ -211,14 +220,16 @@ def handle_item_deleted(**kwargs):
try:
deleted_block = modulestore().get_item(usage_key)
except ItemNotFoundError:
log.warning("Unable to load XBlock %s to handle its pre_item_delete signal", str(usage_key), exc_info=True)
# There may be dangling ComponentLink / milestone data.
return
id_list = {deleted_block.location}
id_list = {deleted_block.usage_key}
for block in yield_dynamic_block_descendants(deleted_block, kwargs.get('user_id')):
# Remove prerequisite milestone data
gating_api.remove_prerequisite(block.location)
gating_api.remove_prerequisite(block.usage_key)
# Remove any 'requires' course content milestone relationships
gating_api.set_required_content(course_key, block.location, None, None, None)
id_list.add(block.location)
gating_api.set_required_content(course_key, block.usage_key, None, None, None)
id_list.add(block.usage_key)

ComponentLink.objects.filter(downstream_usage_key__in=id_list).delete()
ContainerLink.objects.filter(downstream_usage_key__in=id_list).delete()
Expand Down Expand Up @@ -275,6 +286,12 @@ def update_upstream_downstream_link_handler(**kwargs):
def delete_upstream_downstream_link_handler(**kwargs):
"""
Delete upstream->downstream link from database on xblock delete.

NOTE: This only removes the link for the single deleted block. Cascading the
deletion to the block's descendants (e.g. the child components of a deleted
container) is handled separately by ``handle_item_deleted`` (above), which
listens to the modulestore ``pre_item_delete`` signal. These two handlers
partially overlap; keep them in sync if you change either.
"""
xblock_info = kwargs.get("xblock_info", None)
if not xblock_info or not isinstance(xblock_info, XBlockData):
Expand Down
10 changes: 9 additions & 1 deletion cms/djangoapps/contentstore/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from cms.djangoapps.contentstore.toggles import enable_course_optimizer_check_prev_run_links
from cms.djangoapps.contentstore.utils import (
IMPORTABLE_FILE_TYPES,
add_instructor,
contains_course_reference,
create_course_info_usage_key,
create_or_update_xblock_upstream_link,
Expand Down Expand Up @@ -188,7 +189,14 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i
update_unit_discussion_state_from_discussion_blocks(destination_course_key, user_id)

# set initial permissions for the user to access the course.
initialize_permissions(destination_course_key, User.objects.get(id=user_id))
# NOTE: add_instructor is called here (after clone_course) because when
# authz.enable_course_authoring is enabled, it cannot be called pre-task
# (CourseOverview doesn't exist yet). This is a temporary workaround until
# openedx/openedx-authz#352 is implemented. Once resolved, add_instructor
# can move back to the pre-task call site unconditionally.
user = User.objects.get(id=user_id)
add_instructor(destination_course_key, user, user)
initialize_permissions(destination_course_key, user)

# update state: Succeeded
CourseRerunState.objects.succeeded(course_key=destination_course_key)
Expand Down
Loading