diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index fdef59eb56b2..eaa59d1f301a 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -10,6 +10,10 @@ sphinx:
python:
install:
+ # Need to install this to set the correct version of setuptools for now
+ # because it is needed by fs
+ # See https://github.com/openedx/openedx-platform/issues/38068 for details.
+ - requirements: "requirements/pip.txt"
- requirements: "requirements/edx/doc.txt"
- method: pip
path: .
diff --git a/Makefile b/Makefile
index 92a2e37b9aac..ed6d1f2b7a41 100644
--- a/Makefile
+++ b/Makefile
@@ -116,7 +116,7 @@ $(COMMON_CONSTRAINTS_TXT):
printf "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@)
compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade
-compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *.in requirements to *.txt
+compile-requirements: pre-requirements ## Re-compile *.in requirements to *.txt
@# Bootstrapping: Rebuild pip and pip-tools first, and then install them
@# so that if there are any failures we'll know now, rather than the next
@# time someone tries to use the outputs.
@@ -139,7 +139,7 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *
export REBUILD=''; \
done
-upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints
+upgrade: $(COMMON_CONSTRAINTS_TXT) ## update the pip requirements files to use the latest releases satisfying our constraints
$(MAKE) compile-requirements COMPILE_OPTS="--upgrade"
upgrade-package: ## update just one package to the latest usable release
diff --git a/cms/djangoapps/cms_user_tasks/signals.py b/cms/djangoapps/cms_user_tasks/signals.py
index 40bfd5781825..e6ddba747d5f 100644
--- a/cms/djangoapps/cms_user_tasks/signals.py
+++ b/cms/djangoapps/cms_user_tasks/signals.py
@@ -12,6 +12,7 @@
from cms.djangoapps.contentstore.toggles import bypass_olx_failure_enabled
from cms.djangoapps.contentstore.utils import course_import_olx_validation_is_enabled
+from openedx.core.djangoapps.content_libraries.api import is_library_backup_task, is_library_restore_task
from .tasks import send_task_complete_email
@@ -64,6 +65,28 @@ def get_olx_validation_from_artifact():
if olx_artifact and not bypass_olx_failure_enabled():
return olx_artifact.text
+ def should_skip_end_of_task_email(task_name) -> bool:
+ """
+ Studio tasks generally send an email when finished, but not always.
+
+ Some tasks can last many minutes, e.g. course import/export. For these
+ tasks, there is a high chance that the user has navigated away and will
+ want to check back in later. Yet email notification is unnecessary and
+ distracting for things like the Library restore task, which is
+ relatively quick and cannot be resumed (i.e. if you navigate away, you
+ have to upload again).
+
+ The task_name passed in will be lowercase.
+ """
+ # We currently have to pattern match on the name to differentiate
+ # between tasks. A better long term solution would be to add a separate
+ # task type identifier field to Django User Tasks.
+ return (
+ is_library_content_update(task_name) or
+ is_library_backup_task(task_name) or
+ is_library_restore_task(task_name)
+ )
+
status = kwargs['status']
# Only send email when the entire task is complete, should only send when
@@ -72,7 +95,7 @@ def get_olx_validation_from_artifact():
task_name = status.name.lower()
# Also suppress emails on library content XBlock updates (too much like spam)
- if is_library_content_update(task_name):
+ if should_skip_end_of_task_email(task_name):
LOGGER.info(f"Suppressing end-of-task email on task {task_name}")
return
diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py
index 4e0a9bbce666..4928d31dc1f2 100644
--- a/cms/djangoapps/contentstore/api/tests/test_validation.py
+++ b/cms/djangoapps/contentstore/api/tests/test_validation.py
@@ -2,9 +2,11 @@
Tests for the course import API views
"""
-
+import factory
from datetime import datetime
+from django.conf import settings
+import ddt
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework import status
@@ -12,10 +14,13 @@
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import StaffFactory
from common.djangoapps.student.tests.factories import UserFactory
+@ddt.ddt
@override_settings(PROCTORING_BACKENDS={'DEFAULT': 'proctortrack', 'proctortrack': {}})
class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase):
"""
@@ -82,39 +87,54 @@ def test_student_fails(self):
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
- def test_staff_succeeds(self):
- self.client.login(username=self.staff.username, password=self.password)
- resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
- expected_data = {
- 'assignments': {
- 'total_number': 1,
- 'total_visible': 1,
- 'assignments_with_dates_before_start': [],
- 'assignments_with_dates_after_end': [],
- 'assignments_with_ora_dates_after_end': [],
- 'assignments_with_ora_dates_before_start': [],
- },
- 'dates': {
- 'has_start_date': True,
- 'has_end_date': False,
- },
- 'updates': {
- 'has_update': True,
- },
- 'certificates': {
- 'is_enabled': False,
- 'is_activated': False,
- 'has_certificate': False,
- },
- 'grades': {
- 'has_grading_policy': False,
- 'sum_of_weights': 1.0,
- },
- 'proctoring': {
- 'needs_proctoring_escalation_email': True,
- 'has_proctoring_escalation_email': True,
- },
- 'is_self_paced': True,
- }
- self.assertDictEqual(resp.data, expected_data)
+ @ddt.data(
+ (False, False),
+ (True, False),
+ (False, True),
+ (True, True),
+ )
+ @ddt.unpack
+ def test_staff_succeeds(self, certs_html_view, with_modes):
+ features = dict(settings.FEATURES, CERTIFICATES_HTML_VIEW=certs_html_view)
+ with override_settings(FEATURES=features):
+ if with_modes:
+ CourseModeFactory.create_batch(
+ 2,
+ course_id=self.course.id,
+ mode_slug=factory.Iterator([CourseMode.AUDIT, CourseMode.VERIFIED]),
+ )
+ self.client.login(username=self.staff.username, password=self.password)
+ resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ expected_data = {
+ 'assignments': {
+ 'total_number': 1,
+ 'total_visible': 1,
+ 'assignments_with_dates_before_start': [],
+ 'assignments_with_dates_after_end': [],
+ 'assignments_with_ora_dates_after_end': [],
+ 'assignments_with_ora_dates_before_start': [],
+ },
+ 'dates': {
+ 'has_start_date': True,
+ 'has_end_date': False,
+ },
+ 'updates': {
+ 'has_update': True,
+ },
+ 'certificates': {
+ 'is_enabled': with_modes,
+ 'is_activated': False,
+ 'has_certificate': False,
+ },
+ 'grades': {
+ 'has_grading_policy': False,
+ 'sum_of_weights': 1.0,
+ },
+ 'proctoring': {
+ 'needs_proctoring_escalation_email': True,
+ 'has_proctoring_escalation_email': True,
+ },
+ 'is_self_paced': True,
+ }
+ self.assertDictEqual(resp.data, expected_data)
diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py
index f5ee218f3e11..a4f2ce3c6119 100644
--- a/cms/djangoapps/contentstore/models.py
+++ b/cms/djangoapps/contentstore/models.py
@@ -7,12 +7,12 @@
from config_models.models import ConfigurationModel
from django.db import models
-from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper
-from django.db.models.fields import IntegerField, TextField, BooleanField
+from django.db.models import Case, Exists, ExpressionWrapper, OuterRef, Q, QuerySet, Value, When
+from django.db.models.fields import BooleanField, IntegerField, TextField
from django.db.models.functions import Coalesce
from django.db.models.lookups import GreaterThan
from django.utils.translation import gettext_lazy as _
-from opaque_keys.edx.django.models import CourseKeyField, ContainerKeyField, UsageKeyField
+from opaque_keys.edx.django.models import ContainerKeyField, CourseKeyField, UsageKeyField
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryContainerLocator
from openedx_learning.api.authoring import get_published_version
@@ -23,7 +23,6 @@
manual_date_time_field,
)
-
logger = logging.getLogger(__name__)
@@ -391,7 +390,7 @@ def filter_links(
cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS),
)
if ready_to_sync is not None:
- result = result.filter(ready_to_sync=ready_to_sync)
+ result = result.filter(Q(ready_to_sync=ready_to_sync) | Q(ready_to_sync_from_children=ready_to_sync))
# Handle top-level parents logic
if use_top_level_parents:
@@ -436,6 +435,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"
),
then=1
),
+ # If upstream block was deleted, set ready_to_sync = True
+ When(
+ Q(upstream_container__publishable_entity__published__version__version_num__isnull=True),
+ then=1
+ ),
default=0,
output_field=models.IntegerField()
)
@@ -457,6 +461,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"
),
then=1
),
+ # If upstream block was deleted, set ready_to_sync = True
+ When(
+ Q(upstream_block__publishable_entity__published__version__version_num__isnull=True),
+ then=1
+ ),
default=0,
output_field=models.IntegerField()
)
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
index bbc45ddf9a37..8ee8cb035478 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
@@ -28,26 +28,11 @@ class LibraryViewSerializer(serializers.Serializer):
org = serializers.CharField()
number = serializers.CharField()
can_edit = serializers.BooleanField()
- is_migrated = serializers.SerializerMethodField()
- migrated_to_title = serializers.CharField(
- source="migrations__target__title",
- required=False
- )
- migrated_to_key = serializers.CharField(
- source="migrations__target__key",
- required=False
- )
- migrated_to_collection_key = serializers.CharField(
- source="migrations__target_collection__key",
- required=False
- )
- migrated_to_collection_title = serializers.CharField(
- source="migrations__target_collection__title",
- required=False
- )
-
- def get_is_migrated(self, obj):
- return "migrations__target__key" in obj
+ is_migrated = serializers.BooleanField()
+ migrated_to_title = serializers.CharField(required=False)
+ migrated_to_key = serializers.CharField(required=False)
+ migrated_to_collection_key = serializers.CharField(required=False)
+ migrated_to_collection_title = serializers.CharField(required=False)
class CourseHomeTabSerializer(serializers.Serializer):
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
index a4e93de9caff..95723020c11f 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
@@ -236,7 +236,7 @@ def get(self, request: Request):
"number": "CPSPR",
"can_edit": true
}
- ], }
+ ],
```
"""
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
index cd7592c46629..72d58fa00dfa 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
@@ -18,7 +18,6 @@
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.modulestore_migrator import api as migrator_api
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
-from cms.djangoapps.modulestore_migrator.tests.factories import ModulestoreSourceFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
@@ -253,8 +252,9 @@ class HomePageLibrariesViewTest(LibraryTestCase):
def setUp(self):
super().setUp()
- # Create an additional legacy library
+ # Create an two additional legacy libaries
self.lib_key_1 = self._create_library(library="lib1")
+ self.lib_key_2 = self._create_library(library="lib2")
self.organization = OrganizationFactory()
# Create a new v2 library
@@ -269,7 +269,6 @@ def setUp(self):
library = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
learning_package = library.learning_package
# Create a migration source for the legacy library
- self.source = ModulestoreSourceFactory(key=self.lib_key_1)
self.url = reverse("cms.djangoapps.contentstore:v1:libraries")
# Create a collection to migrate this library to
collection_key = "test-collection"
@@ -280,20 +279,32 @@ def setUp(self):
created_by=self.user.id,
)
- # Migrate self.lib_key_1 to self.lib_key_v2
+ # Migrate both lib_key_1 and lib_key_2 to v2
+ # Only make lib_key_1 a "forwarding" migration.
migrator_api.start_migration_to_library(
user=self.user,
- source_key=self.source.key,
+ source_key=self.lib_key_1,
target_library_key=self.lib_key_v2,
target_collection_slug=collection_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
+ preserve_url_slugs=True,
+ forward_source_to_target=True,
+ )
+ migrator_api.start_migration_to_library(
+ user=self.user,
+ source_key=self.lib_key_2,
+ target_library_key=self.lib_key_v2,
+ target_collection_slug=collection_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
def test_home_page_libraries_response(self):
- """Check successful response content"""
+ """Check sucessful response content"""
+ self.maxDiff = None
response = self.client.get(self.url)
expected_response = {
@@ -322,6 +333,17 @@ def test_home_page_libraries_response(self):
'migrated_to_collection_key': 'test-collection',
'migrated_to_collection_title': 'Test Collection',
},
+ # Third library was migrated, but not with forwarding.
+ # So, it appears just like the unmigrated library.
+ {
+ 'display_name': 'Test Library',
+ 'library_key': 'library-v1:org+lib2',
+ 'url': '/library/library-v1:org+lib2',
+ 'org': 'org',
+ 'number': 'lib2',
+ 'can_edit': True,
+ 'is_migrated': False,
+ },
]
}
@@ -366,6 +388,15 @@ def test_home_page_libraries_response(self):
'can_edit': True,
'is_migrated': False,
},
+ {
+ 'display_name': 'Test Library',
+ 'library_key': 'library-v1:org+lib2',
+ 'url': '/library/library-v1:org+lib2',
+ 'org': 'org',
+ 'number': 'lib2',
+ 'can_edit': True,
+ 'is_migrated': False,
+ },
],
}
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
index ad10e373cfc8..b33d980732fa 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
@@ -23,7 +23,7 @@
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ImmediateOnCommitMixin
+from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from .. import downstreams as downstreams_views
@@ -32,6 +32,7 @@
URL_PREFIX = '/api/libraries/v2/'
URL_LIB_CREATE = URL_PREFIX
URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/'
+URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/'
URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/'
URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/'
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
@@ -277,6 +278,10 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n
data["slug"] = slug
return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response)
+ def _delete_component(self, block_key, expect_response=200):
+ """ Publish all changes in the specified container + children """
+ return self._api('delete', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response)
+
class SharedErrorTestCases(_BaseDownstreamViewTestMixin):
"""
@@ -1503,3 +1508,109 @@ def test_200_summary(self):
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
}]
self.assertListEqual(data, expected)
+
+
+class GetDownstreamDeletedUpstream(
+ _BaseDownstreamViewTestMixin,
+ ImmediateOnCommitMixin,
+ SharedModuleStoreTestCase,
+):
+ """
+ Test that parent container is marked ready_to_sync when even when the only change is a deleted component under it
+ """
+ def call_api(
+ self,
+ course_id: str | None = None,
+ ready_to_sync: bool | None = None,
+ upstream_key: str | None = None,
+ item_type: str | None = None,
+ use_top_level_parents: bool | None = None,
+ ):
+ data = {}
+ if course_id is not None:
+ data["course_id"] = str(course_id)
+ if ready_to_sync is not None:
+ data["ready_to_sync"] = str(ready_to_sync)
+ if upstream_key is not None:
+ data["upstream_key"] = str(upstream_key)
+ if item_type is not None:
+ data["item_type"] = str(item_type)
+ if use_top_level_parents is not None:
+ data["use_top_level_parents"] = str(use_top_level_parents)
+ return self.client.get("/api/contentstore/v2/downstreams/", data=data)
+
+ def test_delete_component_should_be_ready_to_sync(self):
+ """
+ Test deleting a component from library should mark the entire section container ready to sync
+ """
+ # Create blocks
+ section_id = self._create_container(self.library_id, "section", "section-12", "Section 12")["id"]
+ subsection_id = self._create_container(self.library_id, "subsection", "subsection-12", "Subsection 12")["id"]
+ unit_id = self._create_container(self.library_id, "unit", "unit-12", "Unit 12")["id"]
+ video_id = self._add_block_to_library(self.library_id, "video", "video-bar-13")["id"]
+ section_key = ContainerKey.from_string(section_id)
+ subsection_key = ContainerKey.from_string(subsection_id)
+ unit_key = ContainerKey.from_string(unit_id)
+ video_key = LibraryUsageLocatorV2.from_string(video_id)
+
+ # Set children
+ lib_api.update_container_children(section_key, [subsection_key], None)
+ lib_api.update_container_children(subsection_key, [unit_key], None)
+ lib_api.update_container_children(unit_key, [video_key], None)
+ self._publish_container(unit_id)
+ self._publish_container(subsection_id)
+ self._publish_container(section_id)
+ self._publish_library_block(video_id)
+ course = CourseFactory.create(display_name="Course New")
+ add_users(self.superuser, CourseStaffRole(course.id), self.course_user)
+ chapter = BlockFactory.create(
+ category='chapter', parent=course, upstream=section_id, upstream_version=2,
+ )
+ sequential = BlockFactory.create(
+ category='sequential',
+ parent=chapter,
+ upstream=subsection_id,
+ upstream_version=2,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ vertical = BlockFactory.create(
+ category='vertical',
+ parent=sequential,
+ upstream=unit_id,
+ upstream_version=2,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ BlockFactory.create(
+ category='video',
+ parent=vertical,
+ upstream=video_id,
+ upstream_version=1,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ self._delete_component(video_id)
+ self._publish_container(unit_id)
+ response = self.call_api(course_id=course.id, ready_to_sync=True, use_top_level_parents=True)
+ assert response.status_code == 200
+ data = response.json()['results']
+ assert len(data) == 1
+ date_format = self.now.isoformat().split("+")[0] + 'Z'
+ expected_results = {
+ 'created': date_format,
+ 'downstream_context_key': str(course.id),
+ 'downstream_usage_key': str(chapter.usage_key),
+ 'downstream_customized': [],
+ 'id': 8,
+ 'ready_to_sync': False,
+ 'ready_to_sync_from_children': True,
+ 'top_level_parent_usage_key': None,
+ 'updated': date_format,
+ 'upstream_context_key': self.library_id,
+ 'upstream_context_title': self.library_title,
+ 'upstream_key': section_id,
+ 'upstream_type': 'container',
+ 'upstream_version': 2,
+ 'version_declined': None,
+ 'version_synced': 2,
+ }
+
+ self.assertDictEqual(data[0], expected_results)
diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py
index d256228228cb..990eff83c922 100644
--- a/cms/djangoapps/contentstore/tests/test_course_listing.py
+++ b/cms/djangoapps/contentstore/tests/test_course_listing.py
@@ -21,8 +21,10 @@
get_courses_accessible_to_user
)
from common.djangoapps.course_action_state.models import CourseRerunState
+from common.djangoapps.student.models.user import CourseAccessRole
from common.djangoapps.student.roles import (
CourseInstructorRole,
+ CourseLimitedStaffRole,
CourseStaffRole,
GlobalStaff,
OrgInstructorRole,
@@ -176,6 +178,48 @@ def test_staff_course_listing(self):
with self.assertNumQueries(2):
list(_accessible_courses_summary_iter(self.request))
+ def test_course_limited_staff_course_listing(self):
+ # Setup a new course
+ course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run')
+ CourseFactory.create(
+ org=course_location.org,
+ number=course_location.course,
+ run=course_location.run
+ )
+ course = CourseOverviewFactory.create(id=course_location, org=course_location.org)
+
+ # Add the user as a course_limited_staff on the course
+ CourseLimitedStaffRole(course.id).add_users(self.user)
+ self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user))
+
+ # Fetch accessible courses list & verify their count
+ courses_list_by_staff, __ = get_courses_accessible_to_user(self.request)
+
+ # Limited Course Staff should not be able to list courses in Studio
+ assert len(list(courses_list_by_staff)) == 0
+
+ def test_org_limited_staff_course_listing(self):
+
+ # Setup a new course
+ course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run')
+ CourseFactory.create(
+ org=course_location.org,
+ number=course_location.course,
+ run=course_location.run
+ )
+ course = CourseOverviewFactory.create(id=course_location, org=course_location.org)
+
+ # Add a user as course_limited_staff on the org
+ # This is not possible using the course roles classes but is possible via Django admin so we
+ # insert a row into the model directly to test that scenario.
+ CourseAccessRole.objects.create(user=self.user, org=course_location.org, role=CourseLimitedStaffRole.ROLE)
+
+ # Fetch accessible courses list & verify their count
+ courses_list_by_staff, __ = get_courses_accessible_to_user(self.request)
+
+ # Limited Course Staff should not be able to list courses in Studio
+ assert len(list(courses_list_by_staff)) == 0
+
def test_get_course_list_with_invalid_course_location(self):
"""
Test getting courses with invalid course location (course deleted from modulestore).
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 78e41b7b1813..5506d8c33e41 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -57,7 +57,8 @@
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
-from cms.djangoapps.modulestore_migrator.api import get_migration_info
+from cms.djangoapps.modulestore_migrator import api as migrator_api
+from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from common.djangoapps.course_modes.models import CourseMode
@@ -1578,13 +1579,12 @@ def request_response_format_is_json(request, response_format):
def get_library_context(request, request_is_json=False):
"""
- Utils is used to get context of course home library tab.
- It is used for both DRF and django views.
+ Utils is used to get context of course home library tab. Returned in DRF view.
"""
from cms.djangoapps.contentstore.views.course import (
_accessible_libraries_iter,
- _format_library_for_view,
_get_course_creator_status,
+ format_library_for_view,
get_allowed_organizations,
get_allowed_organizations_for_libraries,
user_can_create_organizations,
@@ -1596,21 +1596,25 @@ def get_library_context(request, request_is_json=False):
user_can_create_library,
)
+ is_migrated: bool | None # None means: do not filter on is_migrated
+ if (is_migrated_param := request.GET.get('is_migrated')) is not None:
+ is_migrated = BooleanField().to_internal_value(is_migrated_param)
+ else:
+ is_migrated = None
libraries = list(_accessible_libraries_iter(request.user) if libraries_v1_enabled() else [])
- library_keys = [lib.location.library_key for lib in libraries]
- migration_info = get_migration_info(library_keys)
- is_migrated_filter = request.GET.get('is_migrated', None)
+ migration_info: dict[LibraryLocator, ModulestoreMigration | None] = {
+ lib.id: migrator_api.get_forwarding(lib.id)
+ for lib in libraries
+ }
data = {
'libraries': [
- _format_library_for_view(
+ format_library_for_view(
lib,
request,
- migrated_to=migration_info.get(lib.location.library_key)
+ migration=migration_info[lib.id],
)
for lib in libraries
- if is_migrated_filter is None or (
- BooleanField().to_internal_value(is_migrated_filter) == (lib.location.library_key in migration_info)
- )
+ if is_migrated is None or is_migrated == bool(migration_info[lib.id])
]
}
@@ -1719,8 +1723,7 @@ def format_in_process_course_view(uca):
def get_home_context(request, no_course=False):
"""
- Utils is used to get context of course home.
- It is used for both DRF and django views.
+ Utils is used to get context of course home. Returned by DRF view.
"""
from cms.djangoapps.contentstore.views.course import (
diff --git a/cms/djangoapps/contentstore/views/certificate_manager.py b/cms/djangoapps/contentstore/views/certificate_manager.py
index 429950477fdd..081afdcc0dd7 100644
--- a/cms/djangoapps/contentstore/views/certificate_manager.py
+++ b/cms/djangoapps/contentstore/views/certificate_manager.py
@@ -121,7 +121,7 @@ def is_activated(course):
along with the certificates.
"""
is_active = False
- certificates = None
+ certificates = []
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
certificates = CertificateManager.get_certificates(course)
# we are assuming only one certificate in certificates collection.
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 453e30e0aad0..681ea5f9fe9b 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -7,7 +7,7 @@
import random
import re
import string
-from typing import Dict, NamedTuple, Optional
+from typing import Dict
import django.utils
from ccx_keys.locator import CCXLocator
@@ -44,6 +44,7 @@
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
+from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
@@ -61,6 +62,7 @@
GlobalStaff,
UserBasedRole,
OrgStaffRole,
+ strict_role_checking,
)
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from common.djangoapps.util.string_utils import _has_non_ascii_characters
@@ -536,7 +538,9 @@ def filter_ccx(course_access):
return not isinstance(course_access.course_id, CCXLocator)
instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role()
- staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
+ with strict_role_checking():
+ staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
+
all_courses = list(filter(filter_ccx, instructor_courses | staff_courses))
courses_list = []
course_keys = {}
@@ -671,11 +675,18 @@ def library_listing(request):
)
-def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple]):
+def format_library_for_view(library, request, migration: ModulestoreMigration | None):
"""
Return a dict of the data which the view requires for each library
"""
-
+ migration_info = {}
+ if migration:
+ migration_info = {
+ 'migrated_to_key': migration.target_key,
+ 'migrated_to_title': migration.target_title,
+ 'migrated_to_collection_key': migration.target_collection_slug,
+ 'migrated_to_collection_title': migration.target_collection_title,
+ }
return {
'display_name': library.display_name,
'library_key': str(library.location.library_key),
@@ -683,7 +694,8 @@ def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple]
'org': library.display_org_with_default,
'number': library.display_number_with_default,
'can_edit': has_studio_write_access(request.user, library.location.library_key),
- **(migrated_to._asdict() if migrated_to is not None else {}),
+ 'is_migrated': migration is not None,
+ **migration_info,
}
@@ -1840,12 +1852,20 @@ def get_allowed_organizations_for_libraries(user):
"""
Helper method for returning the list of organizations for which the user is allowed to create libraries.
"""
+ organizations_set = set()
+
+ # This allows org-level staff to create libraries. We should re-evaluate
+ # whether this is necessary and try to normalize course and library creation
+ # authorization behavior.
if settings.FEATURES.get('ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES', False):
- return get_organizations_for_non_course_creators(user)
- elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
- return get_organizations(user)
- else:
- return []
+ organizations_set.update(get_organizations_for_non_course_creators(user))
+
+ # This allows people in the course creator group for an org to create
+ # libraries, which mimics course behavior.
+ if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ organizations_set.update(get_organizations(user))
+
+ return sorted(organizations_set)
def user_can_create_organizations(user):
diff --git a/cms/djangoapps/contentstore/views/tests/test_organizations.py b/cms/djangoapps/contentstore/views/tests/test_organizations.py
index cf3a376f3461..d231e3adc507 100644
--- a/cms/djangoapps/contentstore/views/tests/test_organizations.py
+++ b/cms/djangoapps/contentstore/views/tests/test_organizations.py
@@ -3,12 +3,18 @@
import json
+from django.conf import settings
from django.test import TestCase
+from django.test.utils import override_settings
from django.urls import reverse
from organizations.api import add_organization
+from cms.djangoapps.course_creators.models import CourseCreator
+from common.djangoapps.student.roles import OrgStaffRole
from common.djangoapps.student.tests.factories import UserFactory
+from ..course import get_allowed_organizations_for_libraries
+
class TestOrganizationListing(TestCase):
"""Verify Organization listing behavior."""
@@ -32,3 +38,96 @@ def test_organization_list(self):
self.assertEqual(response.status_code, 200)
org_names = json.loads(response.content.decode('utf-8'))
self.assertEqual(org_names, self.org_short_names)
+
+
+class TestOrganizationsForLibraries(TestCase):
+ """
+ Verify who is allowed to create Libraries.
+
+ This uses some low-level implementation details to set up course creator and
+ org staff data, which should be replaced by API calls.
+
+ The behavior of this call depends on two FEATURES toggles:
+
+ * ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES
+ * ENABLE_CREATOR_GROUP
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.library_author = UserFactory(is_staff=False)
+ cls.org_short_names = ["OrgStaffOrg", "CreatorOrg", "RandomOrg"]
+ cls.orgs = {}
+ for index, short_name in enumerate(cls.org_short_names):
+ cls.orgs[short_name] = add_organization(organization_data={
+ 'name': 'Test Organization %s' % index,
+ 'short_name': short_name,
+ 'description': 'Testing Organization %s Description' % index,
+ })
+
+ # Our user is an org staff for OrgStaffOrg
+ OrgStaffRole("OrgStaffOrg").add_users(cls.library_author)
+
+ # Our user is also a CourseCreator in CreatorOrg
+ creator = CourseCreator.objects.create(
+ user=cls.library_author,
+ state=CourseCreator.GRANTED,
+ all_organizations=False,
+ )
+ # The following is because course_creators app logic assumes that all
+ # updates to CourseCreator go through the CourseCreatorAdmin.
+ # Specifically, CourseCreatorAdmin.save_model() attaches the current
+ # request.user to the model instance's .admin field, and then the
+ # course_creator_organizations_changed_callback() signal handler assumes
+ # creator.admin is present. I think that code could use some judicious
+ # refactoring, but I'm just writing this test as part of a last-minute
+ # Ulmo bug fix, and I don't want to add risk by refactoring something as
+ # critical-path as course_creators as part of this work.
+ creator.admin = UserFactory(is_staff=True)
+ creator.organizations.add(
+ cls.orgs["CreatorOrg"]['id']
+ )
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': False,
+ 'ENABLE_CREATOR_GROUP': False,
+ }
+ )
+ def test_both_toggles_disabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == []
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': True,
+ 'ENABLE_CREATOR_GROUP': True,
+ }
+ )
+ def test_both_toggles_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["CreatorOrg", "OrgStaffOrg"]
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': True,
+ 'ENABLE_CREATOR_GROUP': False,
+ }
+ )
+ def test_org_staff_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["OrgStaffOrg"]
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': False,
+ 'ENABLE_CREATOR_GROUP': True,
+ }
+ )
+ def test_creator_group_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["CreatorOrg"]
diff --git a/cms/djangoapps/modulestore_migrator/admin.py b/cms/djangoapps/modulestore_migrator/admin.py
index 8eef778531ac..c9a5c90256fd 100644
--- a/cms/djangoapps/modulestore_migrator/admin.py
+++ b/cms/djangoapps/modulestore_migrator/admin.py
@@ -147,8 +147,8 @@ def start_migration_task(
source_key=source.key,
target_library_key=target_library_key,
target_collection_slug=target_collection_slug,
- composition_level=form.cleaned_data['composition_level'],
- repeat_handling_strategy=form.cleaned_data['repeat_handling_strategy'],
+ composition_level=CompositionLevel(form.cleaned_data['composition_level']),
+ repeat_handling_strategy=RepeatHandlingStrategy(form.cleaned_data['repeat_handling_strategy']),
preserve_url_slugs=form.cleaned_data['preserve_url_slugs'],
forward_source_to_target=form.cleaned_data['forward_to_target'],
)
@@ -178,6 +178,7 @@ class ModulestoreBlockMigrationInline(admin.TabularInline):
"source",
"target",
"change_log_record",
+ "unsupported_reason",
)
list_display = ("id", *readonly_fields)
diff --git a/cms/djangoapps/modulestore_migrator/api.py b/cms/djangoapps/modulestore_migrator/api.py
deleted file mode 100644
index e16d3061c9d8..000000000000
--- a/cms/djangoapps/modulestore_migrator/api.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""
-API for migration from modulestore to learning core
-"""
-from celery.result import AsyncResult
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey, LearningContextKey, UsageKey
-from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2, LibraryUsageLocatorV2
-from openedx_learning.api.authoring import get_collection
-from openedx_learning.api.authoring_models import Component
-from user_tasks.models import UserTaskStatus
-
-from openedx.core.djangoapps.content_libraries.api import get_library, library_component_usage_key
-from openedx.core.types.user import AuthUser
-
-from . import tasks
-from .models import ModulestoreBlockMigration, ModulestoreSource
-
-__all__ = (
- "start_migration_to_library",
- "start_bulk_migration_to_library",
- "is_successfully_migrated",
- "get_migration_info",
- "get_target_block_usage_keys",
-)
-
-
-def start_migration_to_library(
- *,
- user: AuthUser,
- source_key: LearningContextKey,
- target_library_key: LibraryLocatorV2,
- target_collection_slug: str | None = None,
- composition_level: str,
- repeat_handling_strategy: str,
- preserve_url_slugs: bool,
- forward_source_to_target: bool,
-) -> AsyncResult:
- """
- Import a course or legacy library into a V2 library (or, a collection within a V2 library).
- """
- source, _ = ModulestoreSource.objects.get_or_create(key=source_key)
- target_library = get_library(target_library_key)
- # get_library ensures that the library is connected to a learning package.
- target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
- target_collection_id = None
-
- if target_collection_slug:
- target_collection_id = get_collection(target_package_id, target_collection_slug).id
-
- return tasks.migrate_from_modulestore.delay(
- user_id=user.id,
- source_pk=source.id,
- target_library_key=str(target_library_key),
- target_collection_pk=target_collection_id,
- composition_level=composition_level,
- repeat_handling_strategy=repeat_handling_strategy,
- preserve_url_slugs=preserve_url_slugs,
- forward_source_to_target=forward_source_to_target,
- )
-
-
-def start_bulk_migration_to_library(
- *,
- user: AuthUser,
- source_key_list: list[LearningContextKey],
- target_library_key: LibraryLocatorV2,
- target_collection_slug_list: list[str | None] | None = None,
- create_collections: bool = False,
- composition_level: str,
- repeat_handling_strategy: str,
- preserve_url_slugs: bool,
- forward_source_to_target: bool,
-) -> AsyncResult:
- """
- Import a list of courses or legacy libraries into a V2 library (or, a collections within a V2 library).
- """
- target_library = get_library(target_library_key)
- # get_library ensures that the library is connected to a learning package.
- target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
-
- sources_pks: list[int] = []
- for source_key in source_key_list:
- source, _ = ModulestoreSource.objects.get_or_create(key=source_key)
- sources_pks.append(source.id)
-
- target_collection_pks: list[int | None] = []
- if target_collection_slug_list:
- for target_collection_slug in target_collection_slug_list:
- if target_collection_slug:
- target_collection_id = get_collection(target_package_id, target_collection_slug).id
- target_collection_pks.append(target_collection_id)
- else:
- target_collection_pks.append(None)
-
- return tasks.bulk_migrate_from_modulestore.delay(
- user_id=user.id,
- sources_pks=sources_pks,
- target_library_key=str(target_library_key),
- target_collection_pks=target_collection_pks,
- create_collections=create_collections,
- composition_level=composition_level,
- repeat_handling_strategy=repeat_handling_strategy,
- preserve_url_slugs=preserve_url_slugs,
- forward_source_to_target=forward_source_to_target,
- )
-
-
-def is_successfully_migrated(
- source_key: CourseKey | LibraryLocator,
- source_version: str | None = None,
-) -> bool:
- """
- Check if the source course/library has been migrated successfully.
- """
- filters = {"task_status__state": UserTaskStatus.SUCCEEDED}
- if source_version is not None:
- filters["source_version"] = source_version
- return ModulestoreSource.objects.get_or_create(key=str(source_key))[0].migrations.filter(**filters).exists()
-
-
-def get_migration_info(source_keys: list[CourseKey | LibraryLocator]) -> dict:
- """
- Check if the source course/library has been migrated successfully and return target info
- """
- return {
- info.key: info
- for info in ModulestoreSource.objects.filter(
- migrations__task_status__state=UserTaskStatus.SUCCEEDED,
- migrations__is_failed=False,
- key__in=source_keys,
- )
- .values_list(
- 'migrations__target__key',
- 'migrations__target__title',
- 'migrations__target_collection__key',
- 'migrations__target_collection__title',
- 'key',
- named=True,
- )
- }
-
-
-def get_target_block_usage_keys(source_key: CourseKey | LibraryLocator) -> dict[UsageKey, LibraryUsageLocatorV2 | None]:
- """
- For given source_key, get a map of legacy block key and its new location in migrated v2 library.
- """
- query_set = ModulestoreBlockMigration.objects.filter(overall_migration__source__key=source_key).select_related(
- 'source', 'target__component__component_type', 'target__learning_package'
- )
-
- def construct_usage_key(lib_key_str: str, component: Component) -> LibraryUsageLocatorV2 | None:
- try:
- lib_key = LibraryLocatorV2.from_string(lib_key_str)
- except InvalidKeyError:
- return None
- return library_component_usage_key(lib_key, component)
-
- # Use LibraryUsageLocatorV2 and construct usage key
- return {
- obj.source.key: construct_usage_key(obj.target.learning_package.key, obj.target.component)
- for obj in query_set
- if obj.source.key is not None
- }
diff --git a/cms/djangoapps/modulestore_migrator/api/__init__.py b/cms/djangoapps/modulestore_migrator/api/__init__.py
new file mode 100644
index 000000000000..f480d92bf092
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/api/__init__.py
@@ -0,0 +1,9 @@
+
+"""
+This is the public API for the modulestore_migrator.
+"""
+
+# These wildcard imports are okay because these api modules declare __all__.
+# pylint: disable=wildcard-import
+from .read_api import *
+from .write_api import *
diff --git a/cms/djangoapps/modulestore_migrator/api/read_api.py b/cms/djangoapps/modulestore_migrator/api/read_api.py
new file mode 100644
index 000000000000..893ef416d091
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/api/read_api.py
@@ -0,0 +1,244 @@
+"""
+API for reading information about previous migrations
+"""
+from __future__ import annotations
+
+import typing as t
+from uuid import UUID
+
+from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import (
+ LibraryLocatorV2, LibraryUsageLocatorV2, LibraryContainerLocator
+)
+from openedx_learning.api.authoring import get_draft_version
+from openedx_learning.api.authoring_models import (
+ PublishableEntityVersion, PublishableEntity, DraftChangeLogRecord
+)
+
+from openedx.core.djangoapps.content_libraries.api import (
+ library_component_usage_key, library_container_locator
+)
+
+from ..data import (
+ SourceContextKey, ModulestoreMigration, ModulestoreBlockMigrationResult,
+ ModulestoreBlockMigrationSuccess, ModulestoreBlockMigrationFailure
+)
+from .. import models
+
+
+__all__ = (
+ 'get_forwarding',
+ 'is_forwarded',
+ 'get_forwarding_for_blocks',
+ 'get_migrations',
+ 'get_migration_blocks',
+)
+
+
+def get_forwarding_for_blocks(source_keys: t.Iterable[UsageKey]) -> dict[UsageKey, ModulestoreBlockMigrationSuccess]:
+ """
+ Authoritatively determine how some Modulestore blocks have been migrated to Learning Core.
+
+ Returns a mapping from source usage keys to block migration data objects. Each block migration object
+ holds the target usage key and title. If a source key is missing from the mapping, then it has not
+ been authoritatively migrated.
+ """
+ sources = models.ModulestoreBlockSource.objects.filter(
+ key__in=[str(sk) for sk in source_keys]
+ ).select_related(
+ "forwarded__target__learning_package",
+ # For building component key
+ "forwarded__target__component__component_type",
+ # For building container key
+ "forwarded__target__container__section",
+ "forwarded__target__container__subsection",
+ "forwarded__target__container__unit",
+ # For determining title and version
+ "forwarded__change_log_record__new_version",
+ )
+ result = {}
+ for source in sources:
+ if source.forwarded and source.forwarded.target:
+ result[source.key] = _block_migration_success(
+ source_key=source.key,
+ target=source.forwarded.target,
+ change_log_record=source.forwarded.change_log_record,
+ )
+ return result
+
+
+def is_forwarded(source_key: SourceContextKey) -> bool:
+ """
+ Has this course or legacy library been authoratively migrated to Learning Core,
+ such that references to the source course/library should be forwarded to the target library?
+ """
+ return get_forwarding(source_key) is not None
+
+
+def get_forwarding(source_key: SourceContextKey) -> ModulestoreMigration | None:
+ """
+ Authoritatively determine how some Modulestore course or legacy library has been migrated to Learning Core.
+
+ If no such successful migration exists, returns None.
+
+ Note: This function may return None for a course or legacy lib that *has* been migrated 1+ times.
+ This just means that those migrations were non-forwarding. In user parlance, that is,
+ they have been "imported" but not truly "migrated".
+ """
+ try:
+ source = models.ModulestoreSource.objects.select_related(
+ # The following are used in _migration:
+ "forwarded__source",
+ "forwarded__target",
+ "forwarded__task_status",
+ "forwarded__target_collection",
+ ).get(
+ key=str(source_key)
+ )
+ except models.ModulestoreSource.DoesNotExist:
+ return None
+ if not source.forwarded:
+ return None
+ if source.forwarded.is_failed:
+ return None
+ return _migration(source.forwarded)
+
+
+def get_migrations(
+ source_key: SourceContextKey | None = None,
+ *,
+ target_key: LibraryLocatorV2 | None = None,
+ target_collection_slug: str | None = None,
+ task_uuid: UUID | None = None,
+ is_failed: bool | None = None,
+) -> t.Generator[ModulestoreMigration]:
+ """
+ Given some criteria, get all modulestore->LearningCore migrations.
+
+ Returns an iterable, ordered from NEWEST to OLDEST.
+
+ Please note: If you provide no filters, this will return an iterable across the whole
+ ModulestoreMigration table. Please paginate thoughtfully if you do that.
+ """
+ migrations = models.ModulestoreMigration.objects.all().select_related(
+ "source",
+ "target",
+ "target_collection",
+ "task_status",
+ )
+ if source_key:
+ migrations = migrations.filter(source__key=source_key)
+ if target_key:
+ migrations = migrations.filter(target__key=str(target_key))
+ if target_collection_slug:
+ migrations = migrations.filter(target_collection__key=target_collection_slug)
+ if task_uuid:
+ migrations = migrations.filter(task_status__uuid=str(task_uuid))
+ if is_failed is not None:
+ migrations = migrations.filter(is_failed=is_failed)
+ return (
+ _migration(migration)
+ for migration in migrations.order_by("-id") # primary key is a proxy for newness
+ )
+
+
+def get_migration_blocks(migration_pk: int) -> dict[UsageKey, ModulestoreBlockMigrationResult]:
+ """
+ Get details about the migrations of each individual block within a course/lib migration.
+ """
+ return {
+ block_migration.source.key: _block_migration_result(block_migration)
+ for block_migration in models.ModulestoreBlockMigration.objects.filter(
+ overall_migration_id=migration_pk
+ ).select_related(
+ "source",
+ "target__learning_package",
+ # For building component key
+ "target__component__component_type",
+ # For building container key.
+ # (Hard-coding these exact 3 container types here is not a good pattern, but it's what is needed
+ # here in order to avoid additional SELECTs while determining the container type).
+ "target__container__section",
+ "target__container__subsection",
+ "target__container__unit",
+ # For determining title and version
+ "change_log_record__new_version",
+ )
+ }
+
+
+def _migration(m: models.ModulestoreMigration) -> ModulestoreMigration:
+ """
+ Build a migration dataclass from the database row
+ """
+ return ModulestoreMigration(
+ pk=m.id,
+ source_key=m.source.key,
+ target_key=LibraryLocatorV2.from_string(m.target.key),
+ target_title=m.target.title,
+ target_collection_slug=(m.target_collection.key if m.target_collection else None),
+ target_collection_title=(m.target_collection.title if m.target_collection else None),
+ is_failed=m.is_failed,
+ task_uuid=m.task_status.uuid,
+ )
+
+
+def _block_migration_result(m: models.ModulestoreBlockMigration) -> ModulestoreBlockMigrationResult:
+ """
+ Build an instance of the migration result (successs/failure) dataclass from a database row
+ """
+ if m.target:
+ return _block_migration_success(
+ source_key=m.source.key,
+ target=m.target,
+ change_log_record=m.change_log_record,
+ )
+ return ModulestoreBlockMigrationFailure(
+ source_key=m.source.key,
+ unsupported_reason=(m.unsupported_reason or ""),
+ )
+
+
+def _block_migration_success(
+ source_key: UsageKey,
+ target: PublishableEntity,
+ change_log_record: DraftChangeLogRecord | None,
+) -> ModulestoreBlockMigrationSuccess:
+ """
+ Build an instance of the migration success dataclass
+ """
+ target_library_key = LibraryLocatorV2.from_string(target.learning_package.key)
+ target_key: LibraryUsageLocatorV2 | LibraryContainerLocator
+ if hasattr(target, "component"):
+ target_key = library_component_usage_key(target_library_key, target.component)
+ elif hasattr(target, "container"):
+ target_key = library_container_locator(target_library_key, target.container)
+ else:
+ raise ValueError(f"Entity is neither a container nor component: {target}")
+ # We expect that any successful BlockMigration (that is, one where `target is not None`)
+ # will also have a `change_log_record` with a non-None `new_version`. However, the data model
+ # does not guarantee that `change_log_record` nor `change_log_record.new_version` are non-
+ # None. So, just in case some bug in the modulestore_migrator or some manual modification of
+ # the database leads us to a situation where `target` is set but `change_log_record.new_version`
+ # is not, we have fallback behavior:
+ # * For target_title, use the latest draft's title, which is good enough, because the
+ # title is just there to help users.
+ # * For target_version_num, just use None, because we don't want downstream code to make decisions
+ # about syncing, etc based on incorrect version info.
+ target_version: PublishableEntityVersion | None = (
+ change_log_record.new_version if change_log_record else None
+ )
+ if target_version:
+ target_title = target_version.title
+ target_version_num = target_version.version_num
+ else:
+ latest_draft = get_draft_version(target)
+ target_title = latest_draft.title if latest_draft else ""
+ target_version_num = None
+ return ModulestoreBlockMigrationSuccess(
+ source_key=source_key,
+ target_entity_pk=target.id,
+ target_key=target_key,
+ target_title=target_title,
+ target_version_num=target_version_num,
+ )
diff --git a/cms/djangoapps/modulestore_migrator/api/write_api.py b/cms/djangoapps/modulestore_migrator/api/write_api.py
new file mode 100644
index 000000000000..4bef6c952767
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/api/write_api.py
@@ -0,0 +1,94 @@
+"""
+API for kicking off new migrations
+"""
+from __future__ import annotations
+
+from celery.result import AsyncResult
+from opaque_keys.edx.locator import LibraryLocatorV2
+from openedx_learning.api.authoring import get_collection
+
+from openedx.core.types.user import AuthUser
+from openedx.core.djangoapps.content_libraries.api import get_library
+
+from ..data import SourceContextKey, CompositionLevel, RepeatHandlingStrategy
+from .. import tasks, models
+
+
+__all__ = (
+ 'start_migration_to_library',
+ 'start_bulk_migration_to_library'
+)
+
+
+def start_migration_to_library(
+ *,
+ user: AuthUser,
+ source_key: SourceContextKey,
+ target_library_key: LibraryLocatorV2,
+ target_collection_slug: str | None = None,
+ create_collection: bool = False,
+ composition_level: CompositionLevel,
+ repeat_handling_strategy: RepeatHandlingStrategy,
+ preserve_url_slugs: bool,
+ forward_source_to_target: bool | None
+) -> AsyncResult:
+ """
+ Import a course or legacy library into a V2 library (or, a collection within a V2 library).
+ """
+ return start_bulk_migration_to_library(
+ user=user,
+ source_key_list=[source_key],
+ target_library_key=target_library_key,
+ target_collection_slug_list=[target_collection_slug],
+ create_collections=create_collection,
+ composition_level=composition_level,
+ repeat_handling_strategy=repeat_handling_strategy,
+ preserve_url_slugs=preserve_url_slugs,
+ forward_source_to_target=forward_source_to_target,
+ )
+
+
+def start_bulk_migration_to_library(
+ *,
+ user: AuthUser,
+ source_key_list: list[SourceContextKey],
+ target_library_key: LibraryLocatorV2,
+ target_collection_slug_list: list[str | None] | None = None,
+ create_collections: bool = False,
+ composition_level: CompositionLevel,
+ repeat_handling_strategy: RepeatHandlingStrategy,
+ preserve_url_slugs: bool,
+ forward_source_to_target: bool | None,
+) -> AsyncResult:
+ """
+ Import a list of courses or legacy libraries into a V2 library (or, a collections within a V2 library).
+ """
+ target_library = get_library(target_library_key)
+ # get_library ensures that the library is connected to a learning package.
+ target_package_id: int = target_library.learning_package_id # type: ignore[assignment]
+
+ sources_pks: list[int] = []
+ for source_key in source_key_list:
+ source, _ = models.ModulestoreSource.objects.get_or_create(key=str(source_key))
+ sources_pks.append(source.id)
+
+ target_collection_pks: list[int | None] = []
+ if target_collection_slug_list:
+ for target_collection_slug in target_collection_slug_list:
+ if target_collection_slug:
+ target_collection_id = get_collection(target_package_id, target_collection_slug).id
+ target_collection_pks.append(target_collection_id)
+ else:
+ target_collection_pks.append(None)
+
+ return tasks.bulk_migrate_from_modulestore.delay(
+ user_id=user.id,
+ sources_pks=sources_pks,
+ target_library_key=str(target_library_key),
+ target_collection_pks=target_collection_pks,
+ create_collections=create_collections,
+ composition_level=composition_level.value,
+ repeat_handling_strategy=repeat_handling_strategy.value,
+ preserve_url_slugs=preserve_url_slugs,
+ forward_source_to_target=forward_source_to_target,
+ )
diff --git a/cms/djangoapps/modulestore_migrator/data.py b/cms/djangoapps/modulestore_migrator/data.py
index e42649557d67..529d4c78ad40 100644
--- a/cms/djangoapps/modulestore_migrator/data.py
+++ b/cms/djangoapps/modulestore_migrator/data.py
@@ -1,9 +1,23 @@
"""
Value objects
"""
+
from __future__ import annotations
+import typing as t
+from dataclasses import dataclass
from enum import Enum
+from uuid import UUID
+
+from django.utils.translation import gettext_lazy as _
+from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import (
+ CourseLocator,
+ LibraryContainerLocator,
+ LibraryLocator,
+ LibraryLocatorV2,
+ LibraryUsageLocatorV2,
+)
from openedx.core.djangoapps.content_libraries.api import ContainerType
@@ -70,3 +84,51 @@ def default(cls) -> RepeatHandlingStrategy:
Returns the default repeat handling strategy.
"""
return cls.Skip
+
+
+SourceContextKey: t.TypeAlias = CourseLocator | LibraryLocator
+
+
+@dataclass(frozen=True)
+class ModulestoreMigration:
+ """
+ Metadata on a migration of a course or legacy library to a v2 library in learning core.
+ """
+ pk: int
+ source_key: SourceContextKey
+ target_key: LibraryLocatorV2
+ target_title: str
+ target_collection_slug: str | None
+ target_collection_title: str | None
+ is_failed: bool
+ task_uuid: UUID # the UserTask which executed this migration
+
+
+@dataclass(frozen=True)
+class ModulestoreBlockMigrationResult:
+ """
+ Base class for a modulestore block that was part of an attempted migration to learning core.
+ """
+ source_key: UsageKey
+ is_failed: t.ClassVar[bool]
+
+
+@dataclass(frozen=True)
+class ModulestoreBlockMigrationSuccess(ModulestoreBlockMigrationResult):
+ """
+ Info on a modulestore block which has been successfully migrated into an LC entity
+ """
+ target_entity_pk: int
+ target_key: LibraryUsageLocatorV2 | LibraryContainerLocator
+ target_title: str
+ target_version_num: int | None
+ is_failed: t.ClassVar[bool] = False
+
+
+@dataclass(frozen=True)
+class ModulestoreBlockMigrationFailure(ModulestoreBlockMigrationResult):
+ """
+ Info on a modulestore block which failed to be migrated into LC
+ """
+ unsupported_reason: str
+ is_failed: t.ClassVar[bool] = True
diff --git a/cms/djangoapps/modulestore_migrator/migrations/0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason.py b/cms/djangoapps/modulestore_migrator/migrations/0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason.py
new file mode 100644
index 000000000000..f043e208dc35
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/migrations/0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason.py
@@ -0,0 +1,32 @@
+# Generated by Django 5.2.7 on 2025-11-26 06:35
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('modulestore_migrator', '0003_modulestoremigration_is_failed'),
+ ('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='modulestoreblockmigration',
+ name='target',
+ field=models.ForeignKey(
+ blank=True,
+ help_text='The target entity of this block migration, set to null if it fails to migrate',
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to='oel_publishing.publishableentity',
+ ),
+ ),
+ migrations.AddField(
+ model_name='modulestoreblockmigration',
+ name='unsupported_reason',
+ field=models.TextField(
+ blank=True, help_text='Reason if the block is unsupported and target is set to null', null=True
+ ),
+ ),
+ ]
diff --git a/cms/djangoapps/modulestore_migrator/migrations/0006_alter_modulestoreblocksource_forwarded_and_more.py b/cms/djangoapps/modulestore_migrator/migrations/0006_alter_modulestoreblocksource_forwarded_and_more.py
new file mode 100644
index 000000000000..a8ee5a0eb673
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/migrations/0006_alter_modulestoreblocksource_forwarded_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.9 on 2025-12-14 15:33
+
+import django.db.models.deletion
+import opaque_keys.edx.django.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('modulestore_migrator', '0004_alter_modulestoreblockmigration_target_squashed_0005_modulestoreblockmigration_unsupported_reason'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='modulestoreblocksource',
+ name='forwarded',
+ field=models.OneToOneField(help_text='If set, the system will forward references of this block source over to the target of this block migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='modulestore_migrator.modulestoreblockmigration'),
+ ),
+ migrations.AlterField(
+ model_name='modulestoreblocksource',
+ name='key',
+ field=opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key of the XBlock that has been imported.', max_length=255, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='modulestoresource',
+ name='forwarded',
+ field=models.OneToOneField(blank=True, help_text='If set, the system will forward references of this source over to the target of this migration', null=True, on_delete=django.db.models.deletion.SET_NULL, to='modulestore_migrator.modulestoremigration'),
+ ),
+ ]
diff --git a/cms/djangoapps/modulestore_migrator/models.py b/cms/djangoapps/modulestore_migrator/models.py
index 810333fc9be9..11f614c2ed03 100644
--- a/cms/djangoapps/modulestore_migrator/models.py
+++ b/cms/djangoapps/modulestore_migrator/models.py
@@ -6,16 +6,19 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
-from user_tasks.models import UserTaskStatus
-
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import (
LearningContextKeyField,
UsageKeyField,
)
from openedx_learning.api.authoring_models import (
- LearningPackage, PublishableEntity, Collection, DraftChangeLog, DraftChangeLogRecord
+ Collection,
+ DraftChangeLog,
+ DraftChangeLogRecord,
+ LearningPackage,
+ PublishableEntity,
)
+from user_tasks.models import UserTaskStatus
from .data import CompositionLevel, RepeatHandlingStrategy
@@ -25,6 +28,23 @@
class ModulestoreSource(models.Model):
"""
A legacy learning context (course or library) which can be a source of a migration.
+
+ One source can be associated with multiple (successful or unsuccessful) ModulestoreMigrations.
+ If a source has been migrated multiple times, then at most one of them can be considered the
+ "official" or "authoritative" migration; this is indicated by setting the `forwarded` field to
+ that ModulestoreMigration object.
+
+ Note that `forwarded` can be NULL even when 1+ migrations have happened for this source. This just
+ means that none of them were authoritative. In other words, they were all "imports"/"copies" rather
+ than true "migrations".
+
+ In practice, as of Ulmo:
+ * The `forwarded` field is used to decide how to update legacy library_content references.
+ * When using the Libraries Migration UI in Studio, `forwarded` is always set to the first
+ successful ModulestoreMigration.
+ * When using the REST API directly, the default is to use the same behavior as the UI, but
+ clients can also explicitly specify the `forward_source_to_target` boolean param in order to
+ control whether `forwarded` is set to any given migration.
"""
key = LearningContextKeyField(
max_length=255,
@@ -37,7 +57,6 @@ class ModulestoreSource(models.Model):
blank=True,
on_delete=models.SET_NULL,
help_text=_('If set, the system will forward references of this source over to the target of this migration'),
- related_name="forwards",
)
def __str__(self):
@@ -160,6 +179,9 @@ def __repr__(self):
class ModulestoreBlockSource(TimeStampedModel):
"""
A legacy block usage (in a course or library) which can be a source of a block migration.
+
+ The semantics of `forwarded` directly mirror those of `ModulestoreSource.forwarded`. Please see
+ that class's docstring for details.
"""
overall_source = models.ForeignKey(
ModulestoreSource,
@@ -168,6 +190,7 @@ class ModulestoreBlockSource(TimeStampedModel):
)
key = UsageKeyField(
max_length=255,
+ unique=True,
help_text=_('Original usage key of the XBlock that has been imported.'),
)
forwarded = models.OneToOneField(
@@ -175,11 +198,10 @@ class ModulestoreBlockSource(TimeStampedModel):
null=True,
on_delete=models.SET_NULL,
help_text=_(
- 'If set, the system will forward references of this block source over to the target of this block migration'
+ 'If set, the system will forward references of this block source over to the '
+ 'target of this block migration'
),
- related_name="forwards",
)
- unique_together = [("overall_source", "key")]
def __str__(self):
return f"{self.__class__.__name__}('{self.key}')"
@@ -210,6 +232,9 @@ class ModulestoreBlockMigration(TimeStampedModel):
target = models.ForeignKey(
PublishableEntity,
on_delete=models.CASCADE,
+ help_text=_('The target entity of this block migration, set to null if it fails to migrate'),
+ null=True,
+ blank=True,
)
change_log_record = models.OneToOneField(
DraftChangeLogRecord,
@@ -218,10 +243,16 @@ class ModulestoreBlockMigration(TimeStampedModel):
null=True,
on_delete=models.SET_NULL,
)
+ unsupported_reason = models.TextField(
+ null=True,
+ blank=True,
+ help_text=_('Reason if the block is unsupported and target is set to null'),
+ )
class Meta:
unique_together = [
('overall_migration', 'source'),
+ # By default defining a unique index on a nullable column will only enforce unicity of non-null values.
('overall_migration', 'target'),
]
diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py
index 73180791191f..643d94d2250c 100644
--- a/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py
+++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py
@@ -5,11 +5,25 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import LearningContextKey
from opaque_keys.edx.locator import LibraryLocatorV2
+from openedx_learning.api.authoring_models import Collection
from rest_framework import serializers
+from user_tasks.models import UserTaskStatus
from user_tasks.serializers import StatusSerializer
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
-from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration
+from cms.djangoapps.modulestore_migrator.models import (
+ ModulestoreMigration,
+ ModulestoreSource,
+)
+
+
+class LibraryMigrationCollectionSerializer(serializers.ModelSerializer):
+ """
+ Serializer for the target collection of a library migration.
+ """
+ class Meta:
+ model = Collection
+ fields = ["key", "title"]
class ModulestoreMigrationSerializer(serializers.Serializer):
@@ -40,7 +54,7 @@ class ModulestoreMigrationSerializer(serializers.Serializer):
preserve_url_slugs = serializers.BooleanField(
help_text="If true, current slugs will be preserved.",
required=False,
- default=True,
+ default=False,
)
target_collection_slug = serializers.CharField(
help_text="The target collection slug within the library to import into. Optional.",
@@ -48,10 +62,20 @@ class ModulestoreMigrationSerializer(serializers.Serializer):
allow_blank=True,
default=None,
)
+ create_collection = serializers.BooleanField(
+ help_text=(
+ "If true and `target_collection_slug` is not set, "
+ "create the collections in the library where the import will be made"
+ ),
+ required=False,
+ default=False,
+ )
+ target_collection = LibraryMigrationCollectionSerializer(required=False)
forward_source_to_target = serializers.BooleanField(
help_text="Forward references of this block source over to the target of this block migration.",
required=False,
- default=False,
+ allow_null=True,
+ default=None, # Note: "None" means "unspecified"
)
is_failed = serializers.BooleanField(
help_text="It is true if this migration is failed",
@@ -173,3 +197,95 @@ def get_fields(self):
fields = super().get_fields()
fields.pop('name', None)
return fields
+
+
+class MigrationInfoSerializer(serializers.Serializer):
+ """
+ Serializer for the migration info
+ """
+
+ source_key = serializers.CharField()
+ target_key = serializers.CharField()
+ target_title = serializers.CharField()
+ target_collection_key = serializers.CharField(
+ source="target_collection_slug",
+ allow_null=True
+ )
+ target_collection_title = serializers.CharField(
+ allow_null=True
+ )
+
+
+class MigrationInfoResponseSerializer(serializers.Serializer):
+ """
+ Serializer for the migrations info view response
+ """
+ def to_representation(self, instance):
+ return {
+ str(key): MigrationInfoSerializer(value, many=True).data
+ for key, value in instance.items()
+ }
+
+
+class LibraryMigrationCourseSourceSerializer(serializers.ModelSerializer):
+ """
+ Serializer for the source course of a library migration.
+ """
+ display_name = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ModulestoreSource
+ fields = ['key', 'display_name']
+
+ def get_display_name(self, obj):
+ """
+ Return the display name of the source course
+ """
+ return self.context["course_names"].get(str(obj.key), None)
+
+
+class LibraryMigrationCourseSerializer(serializers.ModelSerializer):
+ """
+ Serializer for the course or legacylibrary migrations to V2 library.
+ """
+ task_uuid = serializers.UUIDField(source='task_status.uuid', read_only=True)
+ source = LibraryMigrationCourseSourceSerializer() # type: ignore[assignment]
+ target_collection = LibraryMigrationCollectionSerializer(required=False)
+ state = serializers.SerializerMethodField()
+ progress = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ModulestoreMigration
+ fields = [
+ 'task_uuid',
+ 'source',
+ 'target_collection',
+ 'state',
+ 'progress',
+ ]
+
+ def get_state(self, obj: ModulestoreMigration):
+ """
+ Return the state of the migration.
+ """
+ if obj.is_failed or obj.task_status.state in [UserTaskStatus.FAILED, UserTaskStatus.CANCELED]:
+ return UserTaskStatus.FAILED
+ elif obj.task_status.state == UserTaskStatus.SUCCEEDED:
+ return UserTaskStatus.SUCCEEDED
+
+ return UserTaskStatus.IN_PROGRESS
+
+ def get_progress(self, obj: ModulestoreMigration):
+ """
+ Return the progress of the migration.
+ """
+ return obj.task_status.completed_steps / obj.task_status.total_steps
+
+
+class BlockMigrationInfoSerializer(serializers.Serializer):
+ """
+ Serializer for the block migration info.
+ """
+ source_key = serializers.CharField()
+ target_key = serializers.CharField(allow_null=True)
+ unsupported_reason = serializers.CharField(allow_null=True)
diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py
index 7f66dc5f6dd6..4d2c92b6af04 100644
--- a/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py
+++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/urls.py
@@ -1,9 +1,12 @@
"""
Course to Library Import API v1 URLs.
"""
-
from rest_framework.routers import SimpleRouter
-from .views import MigrationViewSet, BulkMigrationViewSet
+
+from .views import (
+ BulkMigrationViewSet,
+ MigrationViewSet,
+)
ROUTER = SimpleRouter()
ROUTER.register(r'migrations', MigrationViewSet, basename='migrations')
diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py
index f2b231c5c1b2..f84f87a23c85 100644
--- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py
+++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py
@@ -6,22 +6,27 @@
import edx_api_doc_tools as apidocs
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
-from rest_framework.permissions import IsAdminUser
-from rest_framework.response import Response
from rest_framework import status
+from rest_framework.decorators import action
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
from user_tasks.models import UserTaskStatus
from user_tasks.views import StatusViewSet
-from cms.djangoapps.modulestore_migrator.api import start_migration_to_library, start_bulk_migration_to_library
+from cms.djangoapps.modulestore_migrator import api as migrator_api
+from common.djangoapps.student import auth
+from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
+from ...data import (
+ CompositionLevel, RepeatHandlingStrategy,
+)
from .serializers import (
- StatusWithModulestoreMigrationsSerializer,
- ModulestoreMigrationSerializer,
BulkModulestoreMigrationSerializer,
+ ModulestoreMigrationSerializer,
+ StatusWithModulestoreMigrationsSerializer,
)
-
log = logging.getLogger(__name__)
@@ -51,21 +56,11 @@
See `POST /api/modulestore_migrator/v1/migrations` for details on its schema.
""",
)
-@apidocs.schema_for(
- "cancel",
- """
- Cancel a particular migration or bulk-migration task.
-
- The response is a migration task status object.
- See `POST /api/modulestore_migrator/v1/migrations` for details on its schema.
- """,
-)
class MigrationViewSet(StatusViewSet):
"""
JSON HTTP API to create and check on ModuleStore-to-Learning-Core migration tasks.
"""
- permission_classes = (IsAdminUser,)
authentication_classes = (
BearerAuthenticationAllowInactiveUser,
JwtAuthentication,
@@ -77,14 +72,39 @@ class MigrationViewSet(StatusViewSet):
# Instead, users can POST to /cancel to cancel running tasks.
http_method_names = ["get", "post"]
+ lookup_field = "uuid"
+
def get_queryset(self):
"""
Override the default queryset to filter by the migration event and user.
"""
return StatusViewSet.queryset.filter(
- migrations__isnull=False, user=self.request.user
+ migrations__isnull=False,
+ # The filter for `user` here is essentially the auth strategy for the /list and /retreive
+ # endpoints. Basically: you can view migrations if and only if you started them.
+ # Future devs: If you ever refactor this view to remove the user filter, be sure to enforce
+ # permissions some other way.
+ user=self.request.user
).distinct().order_by("-created")
+ @apidocs.schema()
+ @action(detail=True, methods=['post'])
+ def cancel(self, request, *args, **kwargs):
+ """
+ Cancel a particular migration or bulk-migration task.
+
+ The response is a migration task status object.
+ See `POST /api/modulestore_migrator/v1/migrations` for details on its schema.
+
+ This endpoint is currently reserved for site-wide administrators.
+ """
+ # TODO: This should check some sort of "allowed to cancel/migrations" permission
+ # rather than directly looking at the GlobalStaff role.
+ # https://github.com/openedx/edx-platform/issues/37791
+ if not request.user.is_staff:
+ raise PermissionDenied("Only site administrators can cancel migration tasks.")
+ return super().cancel(request, *args, **kwargs)
+
@apidocs.schema(
body=ModulestoreMigrationSerializer,
responses={
@@ -94,28 +114,29 @@ def get_queryset(self):
)
def create(self, request, *args, **kwargs):
"""
- Transfer content from course or legacy library into a content library.
-
- This begins a migration task to copy content from a ModuleStore-based **source** context
- to Learning Core-based **target** context. The valid **source** contexts are:
-
- * A course.
- * A legacy content library.
-
- The valid **target** contexts are:
+ Begin a transfer of content from course or legacy library into a content library.
- * A content library.
- * A collection within a content library.
+ Required parameters:
+ * A **source** key, which identifies the course or legacy library containing the items be migrated.
+ * A **target** key, which identifies the content library to which items will be migrated.
- Other options:
+ Optional parameters:
+ * The **target_collection_slug**, which identifies an *existing* collection within the target that
+ should hold the migrated items. If not specified, items will be added to the target library without
+ any collection, unless:
+ * If this source was previously migrated to a collection and the **repeat_handling_strategy** (described
+ below) is not set to *fork*, then that same collection will be re-used.
+ * If **create_collection** is specified as *true*, then the items will be added to a new collection, with
+ a name and slug based on the source's title, but not conflicting with any existing collection.
* The **composition_level** (*component*, *unit*, *subsection*, *section*) indicates the highest level of
hierarchy to be transferred. Default is *component*. To maximally preserve the source structure,
specify *section*.
* The **repeat_handling_strategy** specifies how the system should handle source items which have
- previously been migrated to the target. Specify *skip* to prefer the existing target item, specify
- *update* to update the existing target item with the latest source content, or specify *fork* to create
- a new target item with the source content. Default is *skip*.
+ previously been migrated to the target.
+ * Specify *skip* to prefer the existing target item. This is the default.
+ * Specify *update* to update the existing target item with the latest source content.
+ * Specify *fork* to create a new target item with the source content.
* Specify **preserve_url_slugs** as *true* in order to use the source-provided block IDs
(a.k.a. "URL slugs", "url_names"). Otherwise, the system will use each source item's title
to auto-generate an ID in the target context.
@@ -130,12 +151,17 @@ def create(self, request, *args, **kwargs):
content library.
* **Example**: Specify *false* if you are copying course content into a content library, but do not
want to persist a link between the source source content and destination library contenet.
+ * **Defaults** to *false* if the source has already been mapped to a target by a successful migration,
+ and defaults to *true* if not. In other words, by default, establish the mapping only if it wouldn't
+ override an existing mapping.
Example request:
```json
{
"source": "course-v1:MyOrganization+MyCourse+MyRun",
- "target": "lib:MyOrganization:MyUlmoLibrary",
+ "target": "lib-collection:MyOrganization:MyUlmoLibrary",
+ "target_collection_slug": "MyCollection",
+ "create_collection": true,
"composition_level": "unit",
"repeat_handling_strategy": "update",
"preserve_url_slugs": true
@@ -183,25 +209,32 @@ def create(self, request, *args, **kwargs):
]
}
```
+
+ This API requires that the requester have author access on both the source and target.
"""
serializer_data = ModulestoreMigrationSerializer(data=request.data)
serializer_data.is_valid(raise_exception=True)
validated_data = serializer_data.validated_data
-
- task = start_migration_to_library(
+ if not auth.has_studio_write_access(request.user, validated_data['source']):
+ raise PermissionDenied("Requester is not an author on the source.")
+ lib_api.require_permission_for_library_key(
+ validated_data['target'],
+ request.user,
+ lib_api.permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
+ )
+ task = migrator_api.start_migration_to_library(
user=request.user,
source_key=validated_data['source'],
target_library_key=validated_data['target'],
target_collection_slug=validated_data['target_collection_slug'],
- composition_level=validated_data['composition_level'],
- repeat_handling_strategy=validated_data['repeat_handling_strategy'],
+ composition_level=CompositionLevel(validated_data['composition_level']),
+ create_collection=validated_data['create_collection'],
+ repeat_handling_strategy=RepeatHandlingStrategy(validated_data['repeat_handling_strategy']),
preserve_url_slugs=validated_data['preserve_url_slugs'],
forward_source_to_target=validated_data['forward_source_to_target'],
)
-
task_status = UserTaskStatus.objects.get(task_id=task.id)
serializer = self.get_serializer(task_status)
-
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -210,7 +243,6 @@ class BulkMigrationViewSet(StatusViewSet):
JSON HTTP API to bulk-create ModuleStore-to-Learning-Core migration tasks.
"""
- permission_classes = (IsAdminUser,)
authentication_classes = (
BearerAuthenticationAllowInactiveUser,
JwtAuthentication,
@@ -295,19 +327,30 @@ def create(self, request, *args, **kwargs):
]
}
```
+
+ This API requires that the requester have author access on both the source and target.
"""
serializer_data = BulkModulestoreMigrationSerializer(data=request.data)
serializer_data.is_valid(raise_exception=True)
validated_data = serializer_data.validated_data
-
- task = start_bulk_migration_to_library(
+ for source_key in validated_data['sources']:
+ if not auth.has_studio_write_access(request.user, source_key):
+ raise PermissionDenied(
+ f"Requester is not an author on the source: {source_key}. No migrations performed."
+ )
+ lib_api.require_permission_for_library_key(
+ validated_data['target'],
+ request.user,
+ lib_api.permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
+ )
+ task = migrator_api.start_bulk_migration_to_library(
user=request.user,
source_key_list=validated_data['sources'],
target_library_key=validated_data['target'],
target_collection_slug_list=validated_data['target_collection_slug_list'],
create_collections=validated_data['create_collections'],
- composition_level=validated_data['composition_level'],
- repeat_handling_strategy=validated_data['repeat_handling_strategy'],
+ composition_level=CompositionLevel(validated_data['composition_level']),
+ repeat_handling_strategy=RepeatHandlingStrategy(validated_data['repeat_handling_strategy']),
preserve_url_slugs=validated_data['preserve_url_slugs'],
forward_source_to_target=validated_data['forward_source_to_target'],
)
diff --git a/cms/djangoapps/modulestore_migrator/tasks.py b/cms/djangoapps/modulestore_migrator/tasks.py
index 040501185339..e0b0b0fe2ed2 100644
--- a/cms/djangoapps/modulestore_migrator/tasks.py
+++ b/cms/djangoapps/modulestore_migrator/tasks.py
@@ -9,24 +9,26 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
-from itertools import groupby
+from gettext import ngettext
from celery import shared_task
from celery.utils.log import get_task_logger
from django.core.exceptions import ObjectDoesNotExist
-from django.utils.text import slugify
from django.db import transaction
+from django.utils.text import slugify
+from django.utils.translation import gettext_lazy as _
from edx_django_utils.monitoring import set_code_owner_attribute_from_module
from lxml import etree
from lxml.etree import _ElementTree as XmlTree
from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import CourseKey, UsageKey
+from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import (
+ BlockUsageLocator,
CourseLocator,
LibraryContainerLocator,
LibraryLocator,
LibraryLocatorV2,
- LibraryUsageLocatorV2
+ LibraryUsageLocatorV2,
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import (
@@ -35,23 +37,24 @@
ComponentType,
LearningPackage,
PublishableEntity,
- PublishableEntityVersion
+ PublishableEntityVersion,
)
from user_tasks.tasks import UserTask, UserTaskStatus
from xblock.core import XBlock
-from django.utils.translation import gettext_lazy as _
+from xblock.plugin import PluginMissingError
from common.djangoapps.split_modulestore_django.models import SplitModulestoreCourseIndex
-from common.djangoapps.util.date_utils import strftime_localized, DEFAULT_DATE_TIME_FORMAT
+from common.djangoapps.util.date_utils import DEFAULT_DATE_TIME_FORMAT, strftime_localized
from openedx.core.djangoapps.content_libraries import api as libraries_api
from openedx.core.djangoapps.content_libraries.api import ContainerType, get_library
from openedx.core.djangoapps.content_staging import api as staging_api
from xmodule.modulestore import exceptions as modulestore_exceptions
from xmodule.modulestore.django import modulestore
+from . import models, data
from .constants import CONTENT_STAGING_PURPOSE_TEMPLATE
-from .data import CompositionLevel, RepeatHandlingStrategy
-from .models import ModulestoreBlockMigration, ModulestoreBlockSource, ModulestoreMigration, ModulestoreSource
+from .data import CompositionLevel, RepeatHandlingStrategy, SourceContextKey
+from .api.read_api import get_migrations, get_migration_blocks
log = get_task_logger(__name__)
@@ -78,20 +81,6 @@ class MigrationStep(Enum):
BULK_MIGRATION_PREFIX = 'Migrating legacy content'
-class _MigrationTask(UserTask):
- """
- Base class for migrate_to_modulestore
- """
-
- @staticmethod
- def calculate_total_steps(arguments_dict):
- """
- Get number of in-progress steps in importing process, as shown in the UI.
- """
- # We subtract the BULK_MIGRATION_PREFIX
- return len(list(MigrationStep)) - 1
-
-
class _BulkMigrationTask(UserTask):
"""
Base class for bulk_migrate_from_modulestore
@@ -125,12 +114,15 @@ class _MigrationContext:
"""
Context for the migration process.
"""
- existing_source_to_target_keys: dict[ # Note: It's intended to be mutable to reflect changes during migration.
- UsageKey, list[PublishableEntity]
- ]
+ # Fields that get mutated as we migrate blocks
+ used_component_keys: set[LibraryUsageLocatorV2]
+ used_container_slugs: set[str]
+
+ # Fields that remain constant
+ previous_block_migrations: dict[UsageKey, data.ModulestoreBlockMigrationResult]
target_package_id: int
target_library_key: LibraryLocatorV2
- source_context_key: CourseKey # Note: This includes legacy LibraryLocators, which are sneakily CourseKeys.
+ source_context_key: SourceContextKey
content_by_filename: dict[str, int]
composition_level: CompositionLevel
repeat_handling_strategy: RepeatHandlingStrategy
@@ -138,37 +130,6 @@ class _MigrationContext:
created_by: int
created_at: datetime
- def is_already_migrated(self, source_key: UsageKey) -> bool:
- return source_key in self.existing_source_to_target_keys
-
- def get_existing_target(self, source_key: UsageKey) -> PublishableEntity:
- """
- Get the target entity for a given source key.
-
- If the source key is already migrated, return the FIRST target entity.
- If the source key is not found, raise a KeyError.
- """
- if source_key not in self.existing_source_to_target_keys:
- raise KeyError(f"Source key {source_key} not found in existing source to target keys")
-
- # NOTE: This is a list of PublishableEntities, but we always return the first one.
- return self.existing_source_to_target_keys[source_key][0]
-
- def add_migration(self, source_key: UsageKey, target: PublishableEntity) -> None:
- """Update the context with a new migration (keeps it current)"""
- if source_key not in self.existing_source_to_target_keys:
- self.existing_source_to_target_keys[source_key] = [target]
- else:
- self.existing_source_to_target_keys[source_key].append(target)
-
- def get_existing_target_entity_keys(self, base_key: str) -> set[str]:
- return set(
- publishable_entity.key
- for publishable_entity_list in self.existing_source_to_target_keys.values()
- for publishable_entity in publishable_entity_list
- if publishable_entity.key.startswith(base_key)
- )
-
@property
def should_skip_strategy(self) -> bool:
"""
@@ -196,10 +157,11 @@ class _MigrationSourceData:
"""
Data related to a ModulestoreSource
"""
- source: ModulestoreSource
- source_root_usage_key: UsageKey
+ source: models.ModulestoreSource
+ source_root_usage_key: BlockUsageLocator
source_version: str | None
- migration: ModulestoreMigration
+ migration: models.ModulestoreMigration
+ previous_migration: data.ModulestoreMigration | None
def _validate_input(
@@ -208,6 +170,7 @@ def _validate_input(
repeat_handling_strategy: str,
preserve_url_slugs: bool,
composition_level: str,
+ target_library_key: LibraryLocatorV2,
target_package: LearningPackage,
target_collection: Collection | None,
) -> _MigrationSourceData | None:
@@ -215,7 +178,7 @@ def _validate_input(
Validates and build the source data related to `source_pk`
"""
try:
- source = ModulestoreSource.objects.get(pk=source_pk)
+ source = models.ModulestoreSource.objects.get(pk=source_pk)
except (ObjectDoesNotExist) as exc:
status.fail(str(exc))
return None
@@ -235,7 +198,17 @@ def _validate_input(
)
return None
- migration = ModulestoreMigration.objects.create(
+ # Find the latest successful migration that occurred, if any.
+ # We're careful to do this before creating the new ModulestoreMigration object,
+ # otherwise we would just end up grabbing that by one accident.
+ # ( mypy gets confused by how use next(...) here )
+ previous_migration = next( # type: ignore[call-overload]
+ get_migrations(
+ source.key, target_key=target_library_key, is_failed=False
+ ),
+ None, # default
+ )
+ migration = models.ModulestoreMigration.objects.create(
source=source,
source_version=source_version,
composition_level=composition_level,
@@ -245,17 +218,17 @@ def _validate_input(
target_collection=target_collection,
task_status=status,
)
-
return _MigrationSourceData(
source=source,
source_root_usage_key=source_root_usage_key,
source_version=source_version,
migration=migration,
+ previous_migration=previous_migration,
)
def _cancel_old_tasks(
- source_list: list[ModulestoreSource],
+ source_list: list[models.ModulestoreSource],
status: UserTaskStatus,
target_package: LearningPackage,
migration_ids_to_exclude: list[int],
@@ -264,7 +237,7 @@ def _cancel_old_tasks(
Cancel all migration tasks related to the user and the source list
"""
# In order to prevent a user from accidentally starting a bunch of identical import tasks...
- migrations_to_cancel = ModulestoreMigration.objects.filter(
+ migrations_to_cancel = models.ModulestoreMigration.objects.filter(
# get all Migration tasks by this user with the same sources and target
task_status__user=status.user,
source__in=source_list,
@@ -302,7 +275,7 @@ def _load_xblock(
return xblock
-def _import_assets(migration: ModulestoreMigration) -> dict[str, int]:
+def _import_assets(migration: models.ModulestoreMigration) -> dict[str, int]:
"""
Import the assets of the staged content to the migration target
"""
@@ -333,7 +306,6 @@ def _import_assets(migration: ModulestoreMigration) -> dict[str, int]:
def _import_structure(
- migration: ModulestoreMigration,
source_data: _MigrationSourceData,
target_library: libraries_api.ContentLibraryMetadata,
content_by_filename: dict[str, int],
@@ -368,21 +340,25 @@ def _import_structure(
represents the mapping between the legacy root node and its newly created
Learning Core equivalent.
"""
- # "key" is locally unique across all PublishableEntities within
- # a given LearningPackage.
- # We use this mapping to ensure that we don't create duplicate
- # PublishableEntities during the migration process for a given LearningPackage.
- existing_source_to_target_keys: dict[UsageKey, list[PublishableEntity]] = {}
- modulestore_blocks = (
- ModulestoreBlockMigration.objects.filter(overall_migration__target=migration.target.id).order_by("source__key")
- )
- existing_source_to_target_keys = {
- source_key: list(block.target for block in group) for source_key, group in groupby(
- modulestore_blocks, key=lambda x: x.source.key)
- }
-
+ migration = source_data.migration
migration_context = _MigrationContext(
- existing_source_to_target_keys=existing_source_to_target_keys,
+ used_component_keys=set(
+ LibraryUsageLocatorV2(target_library.key, block_type, block_id) # type: ignore[abstract]
+ for block_type, block_id
+ in authoring_api.get_components(migration.target.pk).values_list(
+ "component_type__name", "local_key"
+ )
+ ),
+ used_container_slugs=set(
+ authoring_api.get_containers(
+ migration.target.pk
+ ).values_list("publishable_entity__key", flat=True)
+ ),
+ previous_block_migrations=(
+ get_migration_blocks(source_data.previous_migration.pk)
+ if source_data.previous_migration
+ else {}
+ ),
target_package_id=migration.target.pk,
target_library_key=target_library.key,
source_context_key=source_data.source_root_usage_key.course_key,
@@ -393,7 +369,6 @@ def _import_structure(
created_by=status.user_id,
created_at=datetime.now(timezone.utc),
)
-
with authoring_api.bulk_draft_changes_for(migration.target.id) as change_log:
root_migrated_node = _migrate_node(
context=migration_context,
@@ -403,11 +378,11 @@ def _import_structure(
return change_log, root_migrated_node
-def _forwarding_content(source_data: _MigrationSourceData) -> None:
+def _forward_content(source_data: _MigrationSourceData) -> None:
"""
Forwarding legacy content to migrated content
"""
- block_migrations = ModulestoreBlockMigration.objects.filter(overall_migration=source_data.migration)
+ block_migrations = models.ModulestoreBlockMigration.objects.filter(overall_migration=source_data.migration)
block_sources_to_block_migrations = {
block_migration.source: block_migration for block_migration in block_migrations
}
@@ -419,7 +394,7 @@ def _forwarding_content(source_data: _MigrationSourceData) -> None:
source_data.source.save()
-def _populate_collection(user_id: int, migration: ModulestoreMigration) -> None:
+def _populate_collection(user_id: int, migration: models.ModulestoreMigration) -> None:
"""
Assigning imported items to the specified collection in the migration
"""
@@ -427,7 +402,7 @@ def _populate_collection(user_id: int, migration: ModulestoreMigration) -> None:
return
block_target_pks: list[int] = list(
- ModulestoreBlockMigration.objects.filter(
+ models.ModulestoreBlockMigration.objects.filter(
overall_migration=migration
).values_list('target_id', flat=True)
)
@@ -450,7 +425,7 @@ def _create_collection(library_key: LibraryLocatorV2, title: str) -> Collection:
The same is true for the title.
"""
key = slugify(title)
- collection = None
+ collection: Collection | None = None
attempt = 0
created_at = strftime_localized(datetime.now(timezone.utc), DEFAULT_DATE_TIME_FORMAT)
description = f"{_('This collection contains content migrated from a legacy library on')}: {created_at}"
@@ -465,7 +440,7 @@ def _create_collection(library_key: LibraryLocatorV2, title: str) -> Collection:
title=f"{title}{f'_{attempt}' if attempt > 0 else ''}",
description=description,
)
- except libraries_api.LibraryCollectionAlreadyExists as e:
+ except libraries_api.LibraryCollectionAlreadyExists:
attempt += 1
return collection
@@ -477,186 +452,12 @@ def _set_migrations_to_fail(source_data_list: list[_MigrationSourceData]):
for source_data in source_data_list:
source_data.migration.is_failed = True
- ModulestoreMigration.objects.bulk_update(
+ models.ModulestoreMigration.objects.bulk_update(
[x.migration for x in source_data_list],
["is_failed"],
)
-@shared_task(base=_MigrationTask, bind=True)
-# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin
-# does stack inspection and can't handle additional decorators.
-def migrate_from_modulestore(
- self: _MigrationTask,
- *,
- user_id: int,
- source_pk: int,
- target_library_key: str,
- target_collection_pk: int | None,
- repeat_handling_strategy: str,
- preserve_url_slugs: bool,
- composition_level: str,
- forward_source_to_target: bool,
-) -> None:
- """
- Import a single course or legacy library from modulestore into a V2 legacy library.
-
- This task performs the end-to-end migration for one legacy source (course or library),
- including staging, parsing OLX, importing assets and structure, and assigning the
- migrated content to the specified target library and collection.
-
- A new `UserTaskStatus` entry is created for each invocation of this task, meaning
- that each migration runs independently with its own progress tracking and final
- success or failure state.
-
- If the migration encounters an unrecoverable error at any step (for example, invalid
- OLX, missing assets, or database constraints), the task is marked as **failed** and
- the partial results are rolled back as necessary. The migration state can be queried
- through the REST API endpoint `/api/modulestore_migrator/v1/migrations//`.
-
- Args:
- self (_MigrationTask):
- The Celery task instance that wraps the user task logic.
- user_id (int):
- The ID of the user initiating the migration.
- source_pk (int):
- Primary key of the modulestore source to migrate.
- target_library_key (str):
- Key of the target V2 library that will receive the imported content.
- target_collection_pk (int | None):
- Optional ID of a target collection to which imported content will be assigned.
- repeat_handling_strategy (str):
- Strategy for handling repeated imports (e.g., "skip", "update").
- preserve_url_slugs (bool):
- Whether to preserve original XBlock URL slugs during import.
- composition_level (str):
- The structural level to migrate (e.g., component, unit, or section).
- forward_source_to_target (bool):
- Whether to forward legacy content references to the migrated content after import.
-
- See Also:
- - `bulk_migrate_from_modulestore`: Multi-source batch migration equivalent.
- - API docs: `/api/cms/v1/migrations/` for REST behavior and responses.
- """
-
- # pylint: disable=too-many-statements
- # This is a large function, but breaking it up futher would probably not
- # make it any easier to understand.
-
- set_code_owner_attribute_from_module(__name__)
- status: UserTaskStatus = self.status
-
- # Validating input
- status.set_state(MigrationStep.VALIDATING_INPUT.value)
- try:
- target_library = get_library(LibraryLocatorV2.from_string(target_library_key))
- if target_library.learning_package_id is None:
- raise ValueError("Target library has no associated learning package.")
-
- target_package = LearningPackage.objects.get(pk=target_library.learning_package_id)
- target_collection = Collection.objects.get(pk=target_collection_pk) if target_collection_pk else None
- except (ObjectDoesNotExist, InvalidKeyError) as exc:
- status.fail(str(exc))
- return
-
- source_data = _validate_input(
- status,
- source_pk,
- repeat_handling_strategy,
- preserve_url_slugs,
- composition_level,
- target_package,
- target_collection,
- )
- if source_data is None:
- # Fail
- return
-
- migration = source_data.migration
- status.increment_completed_steps()
-
- try:
- # Cancelling old tasks
- status.set_state(MigrationStep.CANCELLING_OLD.value)
- _cancel_old_tasks([source_data.source], status, target_package, [migration.id])
- status.increment_completed_steps()
-
- # Loading `legacy_root`
- status.set_state(MigrationStep.LOADING)
- legacy_root = _load_xblock(status, source_data.source_root_usage_key)
- if legacy_root is None:
- # Fail
- _set_migrations_to_fail([source_data])
- return
- status.increment_completed_steps()
-
- # Staging legacy block
- status.set_state(MigrationStep.STAGING.value)
- staged_content = staging_api.stage_xblock_temporarily(
- block=legacy_root,
- user_id=status.user.pk,
- purpose=CONTENT_STAGING_PURPOSE_TEMPLATE.format(source_key=source_pk),
- )
- migration.staged_content = staged_content
- status.increment_completed_steps()
-
- # Parsing OLX
- status.set_state(MigrationStep.PARSING.value)
- parser = etree.XMLParser(strip_cdata=False)
- try:
- root_node = etree.fromstring(staged_content.olx, parser=parser)
- except etree.ParseError as exc:
- status.fail(f"Failed to parse source OLX (from staged content with id = {staged_content.id}): {exc}")
- _set_migrations_to_fail([source_data])
- return
- status.increment_completed_steps()
-
- # Importing assets of the legacy block
- status.set_state(MigrationStep.IMPORTING_ASSETS.value)
- content_by_filename = _import_assets(migration)
- status.increment_completed_steps()
-
- # Importing structure of the legacy block
- status.set_state(MigrationStep.IMPORTING_STRUCTURE.value)
- change_log, root_migrated_node = _import_structure(
- migration,
- source_data,
- target_library,
- content_by_filename,
- root_node,
- status,
- )
- migration.change_log = change_log
- status.increment_completed_steps()
-
- status.set_state(MigrationStep.UNSTAGING.value)
- staged_content.delete()
- status.increment_completed_steps()
-
- _create_migration_artifacts_incrementally(
- root_migrated_node=root_migrated_node,
- source=source_data.source,
- migration=migration,
- status=status,
- )
- status.increment_completed_steps()
-
- # Forwarding legacy content to migrated content
- status.set_state(MigrationStep.FORWARDING.value)
- if forward_source_to_target:
- _forwarding_content(source_data)
- status.increment_completed_steps()
-
- # Populating the collection
- status.set_state(MigrationStep.POPULATING_COLLECTION.value)
- if target_collection:
- _populate_collection(user_id, migration)
- status.increment_completed_steps()
- except Exception as exc: # pylint: disable=broad-exception-caught
- _set_migrations_to_fail([source_data])
- status.fail(str(exc))
-
-
@shared_task(base=_BulkMigrationTask, bind=True)
# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin
# does stack inspection and can't handle additional decorators.
@@ -671,19 +472,14 @@ def bulk_migrate_from_modulestore(
repeat_handling_strategy: str,
preserve_url_slugs: bool,
composition_level: str,
- forward_source_to_target: bool,
+ forward_source_to_target: bool | None,
) -> None:
"""
Import multiple legacy courses or libraries into a single V2 library.
- This task performs the same logical steps as `migrate_from_modulestore`, but allows
- batching several migrations together under **one single user task** (`UserTaskStatus`).
-
- Unlike running `migrate_from_modulestore` in a loop (which would create multiple
- independent Celery tasks and separate statuses), the bulk migration maintains
- **one unified status record** that tracks progress across all included sources.
- This simplifies monitoring, since the client only needs to observe one task state.
-
+ The bulk migration maintains **one unified status record** that tracks progress across
+ all included sources. This simplifies monitoring, since the client only needs to observe
+ one task state.
Each source item (course or library) still creates its own `ModulestoreMigration`
database record, but all of them share the same parent task (`UserTaskStatus`).
If any sub-migration fails (for example, due to invalid OLX or missing assets),
@@ -708,8 +504,10 @@ def bulk_migrate_from_modulestore(
Whether to preserve existing XBlock URL slugs during import.
composition_level (str):
Composition level at which content should be imported (e.g. course, section).
- forward_source_to_target (bool):
+ forward_source_to_target (bool | None)
Whether to forward legacy content to its migrated equivalent after import.
+ If unspecified (None), then forward legacy content for a source if and only
+ if it's that source's first migration.
See Also:
- `migrate_from_modulestore`: Single-source migration equivalent.
@@ -752,13 +550,13 @@ def bulk_migrate_from_modulestore(
repeat_handling_strategy,
preserve_url_slugs,
composition_level,
+ target_library_locator,
target_package,
target_collection_list[i] if target_collection_list else None,
)
if source_data is None:
# Fail
return
-
source_data_list.append(source_data)
status.increment_completed_steps()
@@ -825,14 +623,14 @@ def bulk_migrate_from_modulestore(
f"{MigrationStep.IMPORTING_STRUCTURE.value}"
)
change_log, root_migrated_node = _import_structure(
- source_data.migration,
- source_data,
- target_library,
- content_by_filename,
- root_node,
- status,
+ source_data=source_data,
+ target_library=target_library,
+ content_by_filename=content_by_filename,
+ root_node=root_node,
+ status=status,
)
source_data.migration.change_log = change_log
+ source_data.migration.save() # @@TODO keep or nah?
status.increment_completed_steps()
status.set_state(
@@ -849,7 +647,8 @@ def bulk_migrate_from_modulestore(
source_pk=source_pk,
)
status.increment_completed_steps()
- except: # pylint: disable=bare-except
+ except Exception as _exc: # pylint: disable=broad-exception-caught
+ log.exception("Failed: {source_data.migration}")
# Mark this library as failed, migration of other libraries can continue
# If this case occurs and the migration ends without any further issues,
# the bulk migration status is success,
@@ -858,73 +657,60 @@ def bulk_migrate_from_modulestore(
# Forwarding legacy content to migrated content
status.set_state(MigrationStep.FORWARDING.value)
- if forward_source_to_target:
- for source_data in source_data_list:
- if not source_data.migration.is_failed:
- _forwarding_content(source_data)
+ for source_data in source_data_list:
+ if forward_source_to_target is False:
+ continue # Explicitly requested not to forward.
+ if forward_source_to_target is None and source_data.source.forwarded:
+ # Unspecified whether or not to forward.
+ # So, forward iff there was no previous existing successful migration with forwarding.
+ continue
+ if source_data.migration.is_failed:
+ # Don't forward failed migrations.
+ continue
+ _forward_content(source_data)
status.increment_completed_steps()
# Populating collections
status.set_state(MigrationStep.POPULATING_COLLECTION.value)
-
- # Used to check if the source has a previous migration in a V2 library collection
- # It is placed here to avoid the circular import
- from .api import get_migration_info
for i, source_data in enumerate(source_data_list):
migration = source_data.migration
if migration.is_failed:
continue
-
- title = legacy_root_list[i].display_name
+ if migration.target_collection is None and not create_collections:
+ continue
if migration.target_collection is None:
- if not create_collections:
- continue
-
- source_key = source_data.source.key
-
- if migration.repeat_handling_strategy == RepeatHandlingStrategy.Fork.value:
- # Create a new collection when it is Fork
- migration.target_collection = _create_collection(target_library_locator, title)
- else:
- # It is Skip or Update
- # We need to verify if there is a previous migration with collection
- # TODO: This only fetches the latest migration, if different migrations have been done
- # on different V2 libraries, this could break the logic.
- previous_migration = get_migration_info([source_key])
- if (
- source_key in previous_migration
- and previous_migration[source_key].migrations__target_collection__key
- ):
- # Has previous migration with collection
- try:
- # Get the previous collection
- previous_collection = authoring_api.get_collection(
- target_package.id,
- previous_migration[source_key].migrations__target_collection__key,
- )
-
- migration.target_collection = previous_collection
- except Collection.DoesNotExist:
- # The collection no longer exists or is being migrated to a different library.
- # In that case, create a new collection independent of strategy
- migration.target_collection = _create_collection(target_library_locator, title)
- else:
- # Create collection and save in migration
- migration.target_collection = _create_collection(target_library_locator, title)
-
+ existing_collection_to_use: Collection | None = None
+ # For Fork strategy: Create an new collection every time.
+ # For Update and Skip strategies: Update an existing collection if possible.
+ if migration.repeat_handling_strategy != RepeatHandlingStrategy.Fork.value:
+ if source_data.previous_migration:
+ if previous_collection_slug := source_data.previous_migration.target_collection_slug:
+ try:
+ existing_collection_to_use = authoring_api.get_collection(
+ target_package.id, previous_collection_slug
+ )
+ except Collection.DoesNotExist:
+ # Collection no longer exists.
+ pass
+ migration.target_collection = (
+ existing_collection_to_use or
+ _create_collection(library_key=target_library_locator, title=legacy_root_list[i].display_name)
+ )
_populate_collection(user_id, migration)
-
- ModulestoreMigration.objects.bulk_update(
+ models.ModulestoreMigration.objects.bulk_update(
[x.migration for x in source_data_list],
["target_collection", "is_failed"],
)
status.increment_completed_steps()
except Exception as exc: # pylint: disable=broad-exception-caught
# If there is an exception in this block, all migrations fail.
- _set_migrations_to_fail(source_data_list)
+ log.exception("Modulestore migrations failed")
status.fail(str(exc))
+SourceToTarget = tuple[UsageKey, PublishableEntityVersion | None, str | None]
+
+
@dataclass(frozen=True)
class _MigratedNode:
"""
@@ -934,10 +720,10 @@ class _MigratedNode:
This happens, particularly, if the node is above the requested composition level
but has descendents which are at or below that level.
"""
- source_to_target: tuple[UsageKey, PublishableEntityVersion] | None
+ source_to_target: SourceToTarget | None
children: list[_MigratedNode]
- def all_source_to_target_pairs(self) -> t.Iterable[tuple[UsageKey, PublishableEntityVersion]]:
+ def all_source_to_target_pairs(self) -> t.Iterable[SourceToTarget]:
"""
Get all source_key->target_ver pairs via a pre-order traversal.
"""
@@ -995,13 +781,13 @@ def _migrate_node(
)
for source_node_child in source_node.getchildren()
]
- source_to_target: tuple[UsageKey, PublishableEntityVersion] | None = None
+ source_to_target: SourceToTarget | None = None
if should_migrate_node:
source_olx = etree.tostring(source_node).decode('utf-8')
if source_block_id := source_node.get('url_name'):
source_key: UsageKey = context.source_context_key.make_usage_key(source_node.tag, source_block_id)
title = source_node.get('display_name', source_block_id)
- target_entity_version = (
+ target_entity_version, reason = (
_migrate_container(
context=context,
source_key=source_key,
@@ -1010,7 +796,7 @@ def _migrate_node(
children=[
migrated_child.source_to_target[1]
for migrated_child in migrated_children if
- migrated_child.source_to_target
+ migrated_child.source_to_target and migrated_child.source_to_target[1]
],
)
if container_type else
@@ -1021,9 +807,18 @@ def _migrate_node(
title=title,
)
)
- if target_entity_version:
- source_to_target = (source_key, target_entity_version)
- context.add_migration(source_key, target_entity_version.entity)
+ if container_type is None and target_entity_version is None and reason is not None:
+ # Currently, components with children are not supported
+ children_length = len(source_node.getchildren())
+ if children_length:
+ reason += (
+ ngettext(
+ ' It has {count} children block.',
+ ' It has {count} children blocks.',
+ children_length,
+ )
+ ).format(count=children_length)
+ source_to_target = (source_key, target_entity_version, reason)
else:
log.warning(
f"Cannot migrate node from {context.source_context_key} to {context.target_library_key} "
@@ -1039,12 +834,14 @@ def _migrate_container(
container_type: ContainerType,
title: str,
children: list[PublishableEntityVersion],
-) -> PublishableEntityVersion:
+) -> tuple[PublishableEntityVersion, str | None]:
"""
Create, update, or replace a container in a library based on a source key and children.
(We assume that the destination is a library rather than some other future kind of learning
- package, but let's keep than an internal assumption.)
+ package, but let's keep than an internal assumption.)
+ For now this returns None value for unsupported_reason as second value of tuple as we
+ don't have any concrete condition where a container cannot be imported/migrated.
"""
target_key = _get_distinct_target_container_key(
context,
@@ -1076,8 +873,9 @@ def _migrate_container(
return PublishableEntityVersion.objects.get(
entity_id=container.container_pk,
version_num=container.draft_version_num,
- )
- return authoring_api.create_next_container_version(
+ ), None
+
+ container_publishable_entity_version = authoring_api.create_next_container_version(
container.container_pk,
title=title,
entity_rows=[
@@ -1089,6 +887,18 @@ def _migrate_container(
container_version_cls=container_type.container_model_classes[1],
).publishable_entity_version
+ # Publish the container
+ # Call post publish events synchronously to avoid
+ # an error when calling `wait_for_post_publish_events`
+ # inside a celery task.
+ libraries_api.publish_container_changes(
+ container.container_key,
+ context.created_by,
+ call_post_publish_events_sync=True,
+ )
+ context.used_container_slugs.add(container.container_key.container_id)
+ return container_publishable_entity_version, None
+
def _migrate_component(
*,
@@ -1096,7 +906,7 @@ def _migrate_component(
source_key: UsageKey,
olx: str,
title: str,
-) -> PublishableEntityVersion | None:
+) -> tuple[PublishableEntityVersion | None, str | None]:
"""
Create, update, or replace a component in a library based on a source key and OLX.
@@ -1129,7 +939,10 @@ def _migrate_component(
)
except libraries_api.IncompatibleTypesError as e:
log.error(f"Error validating block for library {context.target_library_key}: {e}")
- return None
+ return None, str(e)
+ except PluginMissingError as e:
+ log.error(f"Block type not supported in {context.target_library_key}: {e}")
+ return None, f"Invalid block type: {e}"
component = authoring_api.create_component(
context.target_package_id,
component_type=component_type,
@@ -1140,7 +953,7 @@ def _migrate_component(
# Component existed and we do not replace it and it is not deleted previously
if component_existed and not component_deleted and context.should_skip_strategy:
- return component.versioning.draft.publishable_entity_version
+ return component.versioning.draft.publishable_entity_version, None
# If component existed and was deleted or we have to replace the current version
# Create the new component version for it
@@ -1153,7 +966,14 @@ def _migrate_component(
authoring_api.create_component_version_content(
component_version.pk, content_pk, key=new_path
)
- return component_version.publishable_entity_version
+
+ # Publish the component
+ libraries_api.publish_component_changes(target_key, context.created_by)
+ context.used_component_keys.add(target_key)
+ return component_version.publishable_entity_version, None
+
+
+_MAX_UNIQUE_SLUG_ATTEMPTS = 1000
def _get_distinct_target_container_key(
@@ -1163,39 +983,36 @@ def _get_distinct_target_container_key(
title: str,
) -> LibraryContainerLocator:
"""
- Find a unique key for block_id by appending a unique identifier if necessary.
-
- Args:
- context (_MigrationContext): The migration context.
- source_key (UsageKey): The source key.
- container_type (ContainerType): The container type.
- title (str): The title.
-
- Returns:
- LibraryContainerLocator: The target container key.
+ Figure out the appropriate target container for this structural block.
"""
- # Check if we already processed this block and we are not forking. If we are forking, we will
- # want a new target key.
- if context.is_already_migrated(source_key) and not context.should_fork_strategy:
- existing_version = context.get_existing_target(source_key)
-
- return LibraryContainerLocator(
- context.target_library_key,
- container_type.value,
- existing_version.key
- )
+ # If we're not forking, then check if this block was part of our past migration.
+ # (If we are forking, we will always want a new target key).
+ if not context.should_fork_strategy:
+ if previous_block_migration := context.previous_block_migrations.get(source_key):
+ if isinstance(previous_block_migration, data.ModulestoreBlockMigrationSuccess):
+ if isinstance(previous_block_migration.target_key, LibraryContainerLocator):
+ return previous_block_migration.target_key
# Generate new unique block ID
base_slug = (
source_key.block_id
if context.preserve_url_slugs
else (slugify(title) or source_key.block_id)
)
- unique_slug = _find_unique_slug(context, base_slug)
-
- return LibraryContainerLocator(
- context.target_library_key,
- container_type.value,
- unique_slug
+ # Use base base slug if available
+ if base_slug not in context.used_container_slugs:
+ return LibraryContainerLocator(
+ context.target_library_key, container_type.value, base_slug
+ )
+ # Try numbered variations until we find one that doesn't exist
+ for i in range(1, _MAX_UNIQUE_SLUG_ATTEMPTS + 1):
+ candidate_slug = f"{base_slug}_{i}"
+ if candidate_slug not in context.used_container_slugs:
+ return LibraryContainerLocator(
+ context.target_library_key, container_type.value, candidate_slug
+ )
+ # It would be extremely unlikely for us to run out of attempts
+ raise RuntimeError(
+ f"Unable to find unique slug after {_MAX_UNIQUE_SLUG_ATTEMPTS} attempts for base: {base_slug}"
)
@@ -1206,97 +1023,43 @@ def _get_distinct_target_usage_key(
title: str,
) -> LibraryUsageLocatorV2:
"""
- Find a unique key for block_id by appending a unique identifier if necessary.
-
- Args:
- context: The migration context
- source_key: The original usage key from the source
- component_type: The component type string
- olx: The OLX content of the component
-
- Returns:
- A unique LibraryUsageLocatorV2 for the target
-
- Raises:
- ValueError: If source_key is invalid
+ Figure out the appropriate target component for this block.
"""
- # Check if we already processed this block and we are not forking. If we are forking, we will
- # want a new target key.
- if context.is_already_migrated(source_key) and not context.should_fork_strategy:
- log.debug(f"Block {source_key} already exists, reusing first existing target")
- existing_target = context.get_existing_target(source_key)
- block_id = existing_target.component.local_key
-
- # mypy thinks LibraryUsageLocatorV2 is abstract. It's not.
- return LibraryUsageLocatorV2( # type: ignore[abstract]
- context.target_library_key,
- source_key.block_type,
- block_id
- )
-
+ # If we're not forking, then check if this block was part of our past migration.
+ # (If we are forking, we will always want a new target key).
+ if not context.should_fork_strategy:
+ if previous_block_migration := context.previous_block_migrations.get(source_key):
+ if isinstance(previous_block_migration, data.ModulestoreBlockMigrationSuccess):
+ if isinstance(previous_block_migration.target_key, LibraryUsageLocatorV2):
+ return previous_block_migration.target_key
# Generate new unique block ID
base_slug = (
source_key.block_id
if context.preserve_url_slugs
else (slugify(title) or source_key.block_id)
)
- unique_slug = _find_unique_slug(context, base_slug, component_type)
-
- # mypy thinks LibraryUsageLocatorV2 is abstract. It's not.
- return LibraryUsageLocatorV2( # type: ignore[abstract]
- context.target_library_key,
- source_key.block_type,
- unique_slug
+ # Use base base slug if available
+ base_key = LibraryUsageLocatorV2( # type: ignore[abstract]
+ context.target_library_key, component_type.name, base_slug
)
-
-
-def _find_unique_slug(
- context: _MigrationContext,
- base_slug: str,
- component_type: ComponentType | None = None,
- max_attempts: int = 1000
-) -> str:
- """
- Find a unique slug by appending incrementing numbers if necessary.
- Using batch querying to avoid multiple database roundtrips.
-
- Args:
- component_type: The component type to check against
- base_slug: The base slug to make unique
- max_attempts: Maximum number of attempts to prevent infinite loops
-
- Returns:
- A unique slug string
-
- Raises:
- RuntimeError: If unable to find unique slug within max_attempts
- """
- if not component_type:
- base_key = base_slug
- else:
- base_key = f"{component_type}:{base_slug}"
-
- existing_publishable_entity_keys = context.get_existing_target_entity_keys(base_key)
-
- # Check if base slug is available
- if base_key not in existing_publishable_entity_keys:
- return base_slug
-
+ if base_key not in context.used_component_keys:
+ return base_key
# Try numbered variations until we find one that doesn't exist
- for i in range(1, max_attempts + 1):
+ for i in range(1, _MAX_UNIQUE_SLUG_ATTEMPTS + 1):
candidate_slug = f"{base_slug}_{i}"
- candidate_key = f"{component_type}:{candidate_slug}" if component_type else candidate_slug
-
- if candidate_key not in existing_publishable_entity_keys:
- return candidate_slug
-
- raise RuntimeError(f"Unable to find unique slug after {max_attempts} attempts for base: {base_slug}")
+ candidate_key = LibraryUsageLocatorV2( # type: ignore[abstract]
+ context.target_library_key, component_type.name, candidate_slug
+ )
+ if candidate_key not in context.used_component_keys:
+ return candidate_key
+ # It would be extremely unlikely for us to run out of attempts
+ raise RuntimeError(f"Unable to find unique slug after {_MAX_UNIQUE_SLUG_ATTEMPTS} attempts for base: {base_slug}")
def _create_migration_artifacts_incrementally(
root_migrated_node: _MigratedNode,
- source: ModulestoreSource,
- migration: ModulestoreMigration,
+ source: models.ModulestoreSource,
+ migration: models.ModulestoreMigration,
status: UserTaskStatus,
source_pk: int | None = None,
) -> None:
@@ -1307,17 +1070,34 @@ def _create_migration_artifacts_incrementally(
total_nodes = len(nodes)
processed = 0
- for source_usage_key, target_version in root_migrated_node.all_source_to_target_pairs():
- block_source, _ = ModulestoreBlockSource.objects.get_or_create(
+ # Load a mapping from each modified entity's primary key
+ # to the primary key of the changelog record that captures its modification.
+ # This will not include any blocks whose migration failed to create a target entity.
+ entity_pks_to_change_log_record_pks: dict[int, int] = dict(
+ migration.change_log.records.values_list("entity_id", "id")
+ ) if migration.change_log else {}
+
+ for source_usage_key, target_version, unsupported_reason in root_migrated_node.all_source_to_target_pairs():
+ block_source, _ = models.ModulestoreBlockSource.objects.get_or_create(
overall_source=source,
key=source_usage_key
)
-
- ModulestoreBlockMigration.objects.create(
- overall_migration=migration,
- source=block_source,
- target_id=target_version.entity_id,
- )
+ # target_entity_pk should be None iff the block migration failed
+ target_entity_pk: int | None = target_version.entity_id if target_version else None
+
+ change_log_record_pk = entity_pks_to_change_log_record_pks.get(target_entity_pk) if target_entity_pk else None
+ # Only create a migration artifact for this source block if:
+ # (a) we have a record of a change occuring, or
+ # (b) it failed.
+ # If neither a nor b are true, then this source block was skipped.
+ if change_log_record_pk or unsupported_reason:
+ models.ModulestoreBlockMigration.objects.create(
+ overall_migration=migration,
+ source=block_source,
+ target_id=target_entity_pk,
+ change_log_record_id=change_log_record_pk,
+ unsupported_reason=unsupported_reason,
+ )
processed += 1
if processed % 10 == 0 or processed == total_nodes:
diff --git a/cms/djangoapps/modulestore_migrator/tests/test_api.py b/cms/djangoapps/modulestore_migrator/tests/test_api.py
index d208ff85e375..c9e2fc3b587b 100644
--- a/cms/djangoapps/modulestore_migrator/tests/test_api.py
+++ b/cms/djangoapps/modulestore_migrator/tests/test_api.py
@@ -3,11 +3,10 @@
"""
import pytest
-from opaque_keys.edx.locator import LibraryLocatorV2
+from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
from openedx_learning.api import authoring as authoring_api
from organizations.tests.factories import OrganizationFactory
-from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase
from cms.djangoapps.modulestore_migrator import api
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
from cms.djangoapps.modulestore_migrator.models import ModulestoreMigration
@@ -15,32 +14,50 @@
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
+from xmodule.modulestore.tests.utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, LibraryFactory
@pytest.mark.django_db
-class TestModulestoreMigratorAPI(LibraryTestCase):
+class TestModulestoreMigratorAPI(ModuleStoreTestCase):
"""
Test cases for the modulestore migrator API.
"""
def setUp(self):
super().setUp()
-
- self.organization = OrganizationFactory()
- self.lib_key_v2 = LibraryLocatorV2.from_string(
- f"lib:{self.organization.short_name}:test-key"
- )
- lib_api.create_library(
- org=self.organization,
- slug=self.lib_key_v2.slug,
- title="Test Library",
- )
- self.library_v2 = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
- self.learning_package = self.library_v2.learning_package
- self.blocks = []
- for _ in range(3):
- self.blocks.append(self._add_simple_content_block().usage_key)
+ self.user = UserFactory(password=self.user_password, is_staff=True)
+ self.organization = OrganizationFactory(name="My Org", short_name="myorg")
+ self.lib_key_v1 = LibraryLocator.from_string("library-v1:myorg+old")
+ LibraryFactory.create(org="myorg", library="old", display_name="Old Library", modulestore=self.store)
+ self.lib_key_v2_1 = LibraryLocatorV2.from_string("lib:myorg:1")
+ self.lib_key_v2_2 = LibraryLocatorV2.from_string("lib:myorg:2")
+ lib_api.create_library(org=self.organization, slug="1", title="Test Library 1")
+ lib_api.create_library(org=self.organization, slug="2", title="Test Library 2")
+ self.library_v2_1 = lib_api.ContentLibrary.objects.get(slug="1")
+ self.library_v2_2 = lib_api.ContentLibrary.objects.get(slug="2")
+ self.learning_package = self.library_v2_1.learning_package
+ self.learning_package_2 = self.library_v2_2.learning_package
+ self.source_unit_keys = [
+ BlockFactory.create(
+ display_name=f"Unit {c}",
+ category="vertical",
+ location=self.lib_key_v1.make_usage_key("vertical", c),
+ parent_location=self.lib_key_v1.make_usage_key("library", "library"),
+ user_id=self.user.id, publish_item=False,
+ ).usage_key for c in ["X", "Y", "Z"]
+ ]
+ self.source_html_keys = [
+ BlockFactory.create(
+ display_name=f"HTML {c}",
+ category="html",
+ location=self.lib_key_v1.make_usage_key("html", c),
+ parent_location=self.lib_key_v1.make_usage_key("vertical", c),
+ user_id=self.user.id, publish_item=False,
+ ).usage_key for c in ["X", "Y", "Z"]
+ ]
+ # We load this last so that it has an updated list of children.
+ self.lib_v1 = self.store.get_library(self.lib_key_v1)
def test_start_migration_to_library(self):
"""
@@ -52,10 +69,10 @@ def test_start_migration_to_library(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
+ target_library_key=self.library_v2_1.library_key,
target_collection_slug=None,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -81,10 +98,10 @@ def test_start_bulk_migration_to_library(self):
api.start_bulk_migration_to_library(
user=user,
source_key_list=[source.key, source_2.key],
- target_library_key=self.library_v2.library_key,
+ target_library_key=self.library_v2_1.library_key,
target_collection_slug_list=None,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -128,10 +145,10 @@ def test_start_migration_to_library_with_collection(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
+ target_library_key=self.library_v2_1.library_key,
target_collection_slug=collection_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -157,9 +174,9 @@ def test_start_migration_to_library_with_strategy_skip(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -167,7 +184,7 @@ def test_start_migration_to_library_with_strategy_skip(self):
modulestoremigration = ModulestoreMigration.objects.get()
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Skip.value
- migrated_components = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components) == 1
# Update the block, changing its name
@@ -178,9 +195,9 @@ def test_start_migration_to_library_with_strategy_skip(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -189,11 +206,11 @@ def test_start_migration_to_library_with_strategy_skip(self):
assert modulestoremigration is not None
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Skip.value
- migrated_components_fork = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components_fork = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components_fork) == 1
component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[0]
+ self.library_v2_1.library_key, migrated_components_fork[0]
)
assert component.display_name == "Original Block"
@@ -215,9 +232,9 @@ def test_start_migration_to_library_with_strategy_update(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -225,7 +242,7 @@ def test_start_migration_to_library_with_strategy_update(self):
modulestoremigration = ModulestoreMigration.objects.get()
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Skip.value
- migrated_components = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components) == 1
# Update the block, changing its name
@@ -236,9 +253,9 @@ def test_start_migration_to_library_with_strategy_update(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Update.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Update,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -247,11 +264,11 @@ def test_start_migration_to_library_with_strategy_update(self):
assert modulestoremigration is not None
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Update.value
- migrated_components_fork = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components_fork = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components_fork) == 1
component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[0]
+ self.library_v2_1.library_key, migrated_components_fork[0]
)
assert component.display_name == "Updated Block"
@@ -273,9 +290,9 @@ def test_start_migration_to_library_with_strategy_forking(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -283,7 +300,7 @@ def test_start_migration_to_library_with_strategy_forking(self):
modulestoremigration = ModulestoreMigration.objects.get()
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Skip.value
- migrated_components = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components) == 1
# Update the block, changing its name
@@ -294,9 +311,9 @@ def test_start_migration_to_library_with_strategy_forking(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Fork.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Fork,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -305,16 +322,16 @@ def test_start_migration_to_library_with_strategy_forking(self):
assert modulestoremigration is not None
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Fork.value
- migrated_components_fork = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components_fork = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components_fork) == 2
first_component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[0]
+ self.library_v2_1.library_key, migrated_components_fork[0]
)
assert first_component.display_name == "Original Block"
second_component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[1]
+ self.library_v2_1.library_key, migrated_components_fork[1]
)
assert second_component.display_name == "Updated Block"
@@ -326,9 +343,9 @@ def test_start_migration_to_library_with_strategy_forking(self):
api.start_migration_to_library(
user=user,
source_key=source.key,
- target_library_key=self.library_v2.library_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Fork.value,
+ target_library_key=self.library_v2_1.library_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Fork,
preserve_url_slugs=True,
forward_source_to_target=False,
)
@@ -337,74 +354,215 @@ def test_start_migration_to_library_with_strategy_forking(self):
assert modulestoremigration is not None
assert modulestoremigration.repeat_handling_strategy == RepeatHandlingStrategy.Fork.value
- migrated_components_fork = lib_api.get_library_components(self.library_v2.library_key)
+ migrated_components_fork = lib_api.get_library_components(self.library_v2_1.library_key)
assert len(migrated_components_fork) == 3
first_component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[0]
+ self.library_v2_1.library_key, migrated_components_fork[0]
)
assert first_component.display_name == "Original Block"
second_component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[1]
+ self.library_v2_1.library_key, migrated_components_fork[1]
)
assert second_component.display_name == "Updated Block"
third_component = lib_api.LibraryXBlockMetadata.from_component(
- self.library_v2.library_key, migrated_components_fork[2]
+ self.library_v2_1.library_key, migrated_components_fork[2]
)
assert third_component.display_name == "Updated Block Again"
- def test_get_migration_info(self):
+ def test_migration_api_for_various_scenarios(self):
"""
- Test that the API can retrieve migration info.
+ Test that get_migrations, get_block_migrations, forward_context, and forward_block
+ behave as expected throughout a convoluted series of intertwined migrations.
+
+ Also, ensure that each of the aforementioned api functions only performs 1 query each.
"""
+ # pylint: disable=too-many-statements
user = UserFactory()
- collection_key = "test-collection"
+ all_source_usage_keys = {*self.source_html_keys, *self.source_unit_keys}
+ all_source_usage_key_strs = {str(sk) for sk in all_source_usage_keys}
+
+ # In this test, we will be migrating self.lib_v1 a total of 6 times.
+ # We will migrate it to each collection (A, B, and C) twice.
+
+ # Lib 1 has Collection A and Collection B
+ # Lib 2 has Collection C
authoring_api.create_collection(
learning_package_id=self.learning_package.id,
- key=collection_key,
- title="Test Collection",
+ key="test-collection-1a",
+ title="Test Collection A in Lib 1",
+ created_by=user.id,
+ )
+ authoring_api.create_collection(
+ learning_package_id=self.learning_package.id,
+ key="test-collection-1b",
+ title="Test Collection B in Lib 1",
+ created_by=user.id,
+ )
+ authoring_api.create_collection(
+ learning_package_id=self.learning_package_2.id,
+ key="test-collection-2c",
+ title="Test Collection C in Lib 2",
created_by=user.id,
)
+ # No migrations have happened.
+ # Everything should return None / empty.
+ assert not list(api.get_migrations(self.lib_key_v1))
+ assert not api.get_forwarding(source_key=self.lib_key_v1)
+ assert not api.get_forwarding_for_blocks(all_source_usage_keys)
+
+ # FOUR MIGRATIONS!
+ # * Migrate to Lib1.CollA
+ # * Migrate to Lib1.CollB using FORK strategy
+ # * Migrate to Lib1.CollA using UPDATE strategy
+ # * Migrate to Lib2.CollC
+ # Note: None of these are forwarding migrations!
api.start_migration_to_library(
user=user,
- source_key=self.lib_key,
- target_library_key=self.library_v2.library_key,
- target_collection_slug=collection_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_1,
+ target_collection_slug="test-collection-1a",
+ composition_level=CompositionLevel.Unit,
+ # repeat_handling_strategy here is arbitrary, as there will be no repeats.
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
- forward_source_to_target=True,
+ forward_source_to_target=False,
+ )
+ api.start_migration_to_library(
+ user=user,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_1,
+ target_collection_slug="test-collection-1b",
+ composition_level=CompositionLevel.Unit,
+ # this will create a 2nd copy of every block
+ repeat_handling_strategy=RepeatHandlingStrategy.Fork,
+ preserve_url_slugs=True,
+ forward_source_to_target=False,
+ )
+ api.start_migration_to_library(
+ user=user,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_1,
+ target_collection_slug="test-collection-1a",
+ composition_level=CompositionLevel.Unit,
+ # this will update the 2nd copies, but put them in the same collection as the first copies
+ repeat_handling_strategy=RepeatHandlingStrategy.Update,
+ preserve_url_slugs=True,
+ forward_source_to_target=False,
)
+ api.start_migration_to_library(
+ user=user,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_2,
+ target_collection_slug="test-collection-2c",
+ composition_level=CompositionLevel.Unit,
+ # repeat_handling_strategy here is arbitrary, as there will be no repeats.
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
+ preserve_url_slugs=True,
+ forward_source_to_target=False,
+ )
+ # get_migrations returns in reverse chronological order
with self.assertNumQueries(1):
- result = api.get_migration_info([self.lib_key])
- row = result.get(self.lib_key)
- assert row is not None
- assert row.migrations__target__key == str(self.lib_key_v2)
- assert row.migrations__target__title == "Test Library"
- assert row.migrations__target_collection__key == collection_key
- assert row.migrations__target_collection__title == "Test Collection"
-
- def test_get_target_block_usage_keys(self):
- """
- Test that the API can get the list of target block usage keys for a given library.
- """
- user = UserFactory()
-
+ migration_2c_i, migration_1a_ii, migration_1b_i, migration_1a_i = api.get_migrations(self.lib_key_v1)
+ assert not migration_1a_i.is_failed
+ assert not migration_1b_i.is_failed
+ assert not migration_1a_ii.is_failed
+ assert not migration_2c_i.is_failed
+ # Confirm that the metadata came back correctly.
+ assert migration_1a_i.target_key == self.lib_key_v2_1
+ assert migration_1a_i.target_title == "Test Library 1"
+ assert migration_1a_i.target_collection_slug == "test-collection-1a"
+ assert migration_1a_i.target_collection_title == "Test Collection A in Lib 1"
+ assert migration_2c_i.target_key == self.lib_key_v2_2
+ assert migration_2c_i.target_title == "Test Library 2"
+ assert migration_2c_i.target_collection_slug == "test-collection-2c"
+ assert migration_2c_i.target_collection_title == "Test Collection C in Lib 2"
+ # Call get_migration_blocks on each of the four migrations. Convert the mapping
+ # from UsageKey->BlockMigrationResult into str->str just so we can assert things more easily.
+ with self.assertNumQueries(1):
+ mappings_1a_i = {
+ str(sk): str(bm.target_key) for sk, bm in api.get_migration_blocks(migration_1a_i.pk).items()
+ }
+ mappings_1b_i = {
+ str(sk): str(bm.target_key) for sk, bm in api.get_migration_blocks(migration_1b_i.pk).items()
+ }
+ mappings_1a_ii = {
+ str(sk): str(bm.target_key) for sk, bm in api.get_migration_blocks(migration_1a_ii.pk).items()
+ }
+ mappings_2c_i = {
+ str(sk): str(bm.target_key) for sk, bm in api.get_migration_blocks(migration_2c_i.pk).items()
+ }
+ # Each migration should have migrated every source block.
+ assert set(mappings_1a_i.keys()) == all_source_usage_key_strs
+ assert set(mappings_1b_i.keys()) == all_source_usage_key_strs
+ assert set(mappings_1a_ii.keys()) == all_source_usage_key_strs
+ assert set(mappings_2c_i.keys()) == all_source_usage_key_strs
+ # Because the migration to Lib1.CollB used FORK, we expect that there is nothing in
+ # common between it and the prior migration to Lib1.CollA.
+ assert not (set(mappings_1a_i.values()) & set(mappings_1b_i.values()))
+ # Because the second migration to Lib1.CollA used UPDATE, we expect that it
+ # will have all the same mappings as the prior migration to Lib1.CollB.
+ # This is a little countertuitive, since the migrations targeted different collections,
+ # but the rule that the migrator follows is "UPDATE uses the block from the most recent migration".
+ assert mappings_1b_i == mappings_1a_ii
+ # Since forward_source_to_target=False, we have had no authoritative migration yet.
+ assert api.get_forwarding(self.lib_key_v1) is None
+ assert not api.get_forwarding_for_blocks(all_source_usage_keys)
+
+ # ANOTHER MIGRATION!
+ # * Migrate to Lib2.CollC using UPDATE strategy
+ # Note: This *is* a forwarding migration
api.start_migration_to_library(
user=user,
- source_key=self.lib_key,
- target_library_key=self.library_v2.library_key,
- target_collection_slug=None,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_2,
+ target_collection_slug="test-collection-2c",
+ composition_level=CompositionLevel.Unit,
+ repeat_handling_strategy=RepeatHandlingStrategy.Update,
preserve_url_slugs=True,
forward_source_to_target=True,
)
+ migration_2c_ii, _2c_i, _1a_ii, _1b_i, migration_1a_i_reloaded = api.get_migrations(self.lib_key_v1)
+ assert migration_1a_i_reloaded.pk == migration_1a_i.pk
+ assert not migration_2c_ii.is_failed
+ # Our source lib should now forward to Lib2.
+ with self.assertNumQueries(1):
+ forwarded = api.get_forwarding(self.lib_key_v1)
+ assert forwarded.target_key == self.lib_key_v2_2
+ assert forwarded.target_collection_slug == "test-collection-2c"
+ assert forwarded.pk == migration_2c_ii.pk
+ # Our source lib's blocks should now forward to ones in Lib2.
with self.assertNumQueries(1):
- result = api.get_target_block_usage_keys(self.lib_key)
- for key in self.blocks:
- assert result.get(key) is not None
+ forwarded_blocks = api.get_forwarding_for_blocks(all_source_usage_keys)
+ assert forwarded_blocks[self.source_html_keys[1]].target_key.context_key == self.lib_key_v2_2
+ assert forwarded_blocks[self.source_unit_keys[1]].target_key.context_key == self.lib_key_v2_2
+
+ # FINAL MIGRATION!
+ # * Migrate to Lib1.CollB using UPDATE strategy
+ # Note: This *is* a forwarding migration, and should supplant the previous
+ # migration for forwarding purposes.
+ api.start_migration_to_library(
+ user=user,
+ source_key=self.lib_key_v1,
+ target_library_key=self.lib_key_v2_1,
+ target_collection_slug="test-collection-1b",
+ composition_level=CompositionLevel.Unit,
+ repeat_handling_strategy=RepeatHandlingStrategy.Update,
+ preserve_url_slugs=True,
+ forward_source_to_target=True,
+ )
+ migration_1b_ii, _2c_ii, _2c_i, _1a_ii, _1b_i, _1a_i = api.get_migrations(self.lib_key_v1)
+ assert not migration_1b_ii.is_failed
+ # Our source lib should now forward to Lib1.
+ forwarded = api.get_forwarding(self.lib_key_v1)
+ assert forwarded.target_key == self.lib_key_v2_1
+ assert forwarded.target_collection_slug == "test-collection-1b"
+ assert forwarded.pk == migration_1b_ii.pk
+ # Our source lib should now forward to Lib1.
+ forwarded_blocks = api.get_forwarding_for_blocks(all_source_usage_keys)
+ assert forwarded_blocks[self.source_html_keys[1]].target_key.context_key == self.lib_key_v2_1
+ assert forwarded_blocks[self.source_unit_keys[1]].target_key.context_key == self.lib_key_v2_1
diff --git a/cms/djangoapps/modulestore_migrator/tests/test_rest_api.py b/cms/djangoapps/modulestore_migrator/tests/test_rest_api.py
new file mode 100644
index 000000000000..4a7f842902db
--- /dev/null
+++ b/cms/djangoapps/modulestore_migrator/tests/test_rest_api.py
@@ -0,0 +1,822 @@
+"""
+Unit tests for the modulestore_migrator REST API v1 views.
+
+These tests focus on validation, HTTP status codes, and serialization/deserialization.
+Business logic is mocked out.
+"""
+from unittest.mock import MagicMock, patch
+from uuid import uuid4
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from opaque_keys.edx.locator import CourseLocator
+from organizations.tests.factories import OrganizationFactory
+from rest_framework import status
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.test import APIRequestFactory, force_authenticate
+from user_tasks.models import UserTaskStatus
+
+from cms.djangoapps.modulestore_migrator.models import (
+ ModulestoreMigration as ModulestoreMigrationModel,
+ ModulestoreSource,
+)
+from cms.djangoapps.modulestore_migrator.rest_api.v1.views import (
+ BulkMigrationViewSet,
+ MigrationViewSet,
+)
+from openedx.core.djangoapps.content_libraries import api as lib_api
+
+
+User = get_user_model()
+
+
+class TestMigrationViewSetCreate(TestCase):
+ """
+ Test the MigrationViewSet.create() endpoint.
+
+ Focus: validation, return codes, serialization/deserialization.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.factory = APIRequestFactory()
+ self.view = MigrationViewSet.as_view({'post': 'create'})
+
+ # Create test user
+ self.user = User.objects.create_user(
+ username='testuser',
+ email='testuser@test.com',
+ password='password'
+ )
+
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.migrator_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.lib_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.auth')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.UserTaskStatus')
+ def test_create_migration_success_with_minimal_data(
+ self, mock_user_task_status, mock_auth, mock_lib_api, mock_migrator_api
+ ):
+ """
+ Test successful migration creation with minimal required fields.
+
+ Validates:
+ - 201 status code is returned
+ - Response contains expected serialized fields
+ - Request data is properly deserialized
+ - Permission checks are performed for both source and target
+ """
+ mock_auth.has_studio_write_access.return_value = True
+ mock_lib_api.require_permission_for_library_key.return_value = None
+
+ mock_task = MagicMock(autospec=True)
+ mock_task.id = 'test-task-id'
+ mock_migrator_api.start_migration_to_library.return_value = mock_task
+
+ mock_task_status = MagicMock(autospec=True)
+ mock_task_status.uuid = uuid4()
+ mock_task_status.state = 'Pending'
+ mock_task_status.state_text = 'Pending'
+ mock_task_status.completed_steps = 0
+ mock_task_status.total_steps = 10
+ mock_task_status.attempts = 1
+ mock_task_status.created = '2025-01-01T00:00:00Z'
+ mock_task_status.modified = '2025-01-01T00:00:00Z'
+ mock_task_status.artifacts = []
+ mock_task_status.migrations.all.return_value = []
+
+ mock_user_task_status.objects.get.return_value = mock_task_status
+
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_201_CREATED
+
+ assert 'uuid' in response.data
+ assert 'state' in response.data
+ assert 'state_text' in response.data
+ assert 'completed_steps' in response.data
+ assert 'total_steps' in response.data
+ assert 'parameters' in response.data
+
+ mock_auth.has_studio_write_access.assert_called_once()
+ mock_lib_api.require_permission_for_library_key.assert_called_once()
+
+ mock_migrator_api.start_migration_to_library.assert_called_once()
+ call_kwargs = mock_migrator_api.start_migration_to_library.call_args[1]
+ assert call_kwargs['user'] == self.user
+ assert str(call_kwargs['source_key']) == 'course-v1:TestOrg+TestCourse+TestRun'
+ assert str(call_kwargs['target_library_key']) == 'lib:TestOrg:TestLibrary'
+
+ def test_create_migration_invalid_source_key(self):
+ """
+ Test that invalid source key returns 400 Bad Request.
+
+ Validates:
+ - 400 status code is returned
+ - Error message mentions validation failure
+ """
+ request_data = {
+ 'source': 'not-a-valid-key',
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'source' in response.data
+
+ def test_create_migration_invalid_target_key(self):
+ """
+ Test that invalid target library key returns 400 Bad Request.
+
+ Validates:
+ - 400 status code is returned
+ - Error message mentions target validation failure
+ """
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'not-a-valid-library-key',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'target' in response.data
+
+ def test_create_migration_missing_required_fields(self):
+ """
+ Test that missing required fields returns 400 Bad Request.
+
+ Validates:
+ - 400 status code is returned when source is missing
+ - 400 status code is returned when target is missing
+ """
+ request_data = {
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'source' in response.data
+
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'target' in response.data
+
+ def test_create_migration_unauthenticated_user(self):
+ """
+ Test that unauthenticated requests return 401 Unauthorized.
+
+ Validates:
+ - 401 status code for unauthenticated requests
+ """
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ # Note: No force_authenticate call
+
+ response = self.view(request)
+
+ assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
+
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.auth')
+ def test_create_migration_without_source_author_access(self, mock_auth):
+ """
+ Test that users without author access to source cannot create migrations.
+
+ Validates:
+ - 403 Forbidden status code when user lacks author access to source
+ """
+ mock_auth.has_studio_write_access.return_value = False
+
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.lib_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.auth')
+ def test_create_migration_without_target_write_access(self, mock_auth, mock_lib_api):
+ """
+ Test that users without write access to target cannot create migrations.
+
+ Validates:
+ - 403 Forbidden status code when user lacks write access to target library
+ """
+ mock_auth.has_studio_write_access.return_value = True
+ mock_lib_api.require_permission_for_library_key.side_effect = PermissionDenied(
+ "User lacks permission to manage content in this library"
+ )
+
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.migrator_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.lib_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.auth')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.UserTaskStatus')
+ def test_create_migration_with_optional_fields(
+ self, mock_user_task_status, mock_auth, mock_lib_api, mock_migrator_api
+ ):
+ """
+ Test migration creation with all optional fields provided.
+
+ Validates:
+ - Optional fields are properly deserialized
+ - Default values are not used when explicit values provided
+ """
+ mock_auth.has_studio_write_access.return_value = True
+ mock_lib_api.require_permission_for_library_key.return_value = None
+
+ mock_task = MagicMock(autospec=True)
+ mock_task.id = 'test-task-id'
+ mock_migrator_api.start_migration_to_library.return_value = mock_task
+
+ mock_task_status = MagicMock(autospec=True)
+ mock_task_status.uuid = uuid4()
+ mock_task_status.state = 'Pending'
+ mock_task_status.state_text = 'Pending'
+ mock_task_status.completed_steps = 0
+ mock_task_status.total_steps = 10
+ mock_task_status.attempts = 1
+ mock_task_status.created = '2025-01-01T00:00:00Z'
+ mock_task_status.modified = '2025-01-01T00:00:00Z'
+ mock_task_status.artifacts = []
+ mock_task_status.migrations.all.return_value = []
+
+ mock_user_task_status.objects.get.return_value = mock_task_status
+
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ 'target_collection_slug': 'my-collection',
+ 'composition_level': 'unit',
+ 'repeat_handling_strategy': 'update',
+ 'preserve_url_slugs': False,
+ 'forward_source_to_target': True,
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_201_CREATED
+
+ mock_migrator_api.start_migration_to_library.assert_called_once()
+ call_kwargs = mock_migrator_api.start_migration_to_library.call_args[1]
+ assert call_kwargs['target_collection_slug'] == 'my-collection'
+ # CompositionLevel and RepeatHandlingStrategy are enums
+ assert call_kwargs['composition_level'].value == 'unit'
+ assert call_kwargs['repeat_handling_strategy'].value == 'update'
+ assert call_kwargs['preserve_url_slugs'] is False
+ assert call_kwargs['forward_source_to_target'] is True
+
+ def test_create_migration_invalid_composition_level(self):
+ """
+ Test that invalid composition_level returns 400 Bad Request.
+
+ Validates:
+ - 400 status code for invalid enum value
+ """
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ 'composition_level': 'invalid_level',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'composition_level' in response.data
+
+ def test_create_migration_invalid_repeat_handling_strategy(self):
+ """
+ Test that invalid repeat_handling_strategy returns 400 Bad Request.
+
+ Validates:
+ - 400 status code for invalid enum value
+ """
+ request_data = {
+ 'source': 'course-v1:TestOrg+TestCourse+TestRun',
+ 'target': 'lib:TestOrg:TestLibrary',
+ 'repeat_handling_strategy': 'invalid_strategy',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/migrations/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'repeat_handling_strategy' in response.data
+
+
+class TestMigrationViewSetList(TestCase):
+ """
+ Test the MigrationViewSet.list() endpoint.
+
+ Focus: validation, return codes, serialization/deserialization.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.factory = APIRequestFactory()
+ self.view = MigrationViewSet.as_view({'get': 'list'})
+
+ self.user = User.objects.create_user(
+ username='testuser',
+ email='testuser@test.com',
+ password='password'
+ )
+ self.other_user = User.objects.create_user(
+ username='otheruser',
+ email='otheruser@test.com',
+ password='password'
+ )
+
+ def test_list_migrations_success(self):
+ """
+ Test successful listing of migrations for the authenticated user.
+
+ Validates:
+ - 200 status code is returned
+ - Response contains list of migrations
+ - Only user's own migrations are returned (other users' migrations filtered out)
+ """
+ org = OrganizationFactory(short_name="TestOrg", name="Test Org")
+ source_key = CourseLocator.from_string('course-v1:TestOrg+TestCourse+TestRun')
+ source = ModulestoreSource.objects.create(key=str(source_key))
+ target = lib_api.create_library(org=org, slug="TestLib", title="Test Target Lib")
+ user_task_status = UserTaskStatus.objects.create(
+ user=self.user,
+ task_id='user-task-id',
+ task_class='test.Task',
+ name='User Migration',
+ total_steps=10,
+ completed_steps=10,
+ )
+ other_task_status = UserTaskStatus.objects.create(
+ user=self.other_user,
+ task_id='other-task-id',
+ task_class='test.Task',
+ name='Other Migration',
+ total_steps=5,
+ completed_steps=5,
+ )
+ ModulestoreMigrationModel.objects.create(
+ task_status=user_task_status,
+ source=source,
+ target_id=target.learning_package_id,
+ )
+ ModulestoreMigrationModel.objects.create(
+ task_status=other_task_status,
+ source=source,
+ target_id=target.learning_package_id,
+ )
+
+ request = self.factory.get('/api/modulestore_migrator/v1/migrations/')
+ force_authenticate(request, user=self.user)
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.data['results']
+ assert len(results) == 1
+ assert results[0]['uuid'] == str(user_task_status.uuid)
+
+ def test_list_migrations_unauthenticated(self):
+ """
+ Test that unauthenticated requests return 401 Unauthorized.
+
+ Validates:
+ - 401 status code for unauthenticated requests
+ """
+ request = self.factory.get('/api/modulestore_migrator/v1/migrations/')
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+class TestMigrationViewSetRetrieve(TestCase):
+ """
+ Test the MigrationViewSet.retrieve() endpoint.
+
+ Focus: validation, return codes, serialization/deserialization.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.factory = APIRequestFactory()
+ self.view = MigrationViewSet.as_view({'get': 'retrieve'})
+
+ self.user = User.objects.create_user(
+ username='testuser',
+ email='testuser@test.com',
+ password='password'
+ )
+
+ def test_retrieve_migration_success(self):
+ """
+ Test successful retrieval of a specific migration by UUID.
+
+ Validates:
+ - 200 status code is returned
+ - Response contains migration details
+ """
+ org = OrganizationFactory(short_name="TestOrg", name="Test Org")
+ source_key = CourseLocator.from_string('course-v1:TestOrg+TestCourse+TestRun')
+ source = ModulestoreSource.objects.create(key=str(source_key))
+ target = lib_api.create_library(org=org, slug="TestLib", title="Test Target Lib")
+ user_task_status = UserTaskStatus.objects.create(
+ user=self.user,
+ task_id='user-task-id',
+ task_class='test.Task',
+ name='User Migration',
+ total_steps=10,
+ completed_steps=10,
+ )
+ ModulestoreMigrationModel.objects.create(
+ task_status=user_task_status,
+ source=source,
+ target_id=target.learning_package_id,
+ )
+
+ request = self.factory.get(f'/api/modulestore_migrator/v1/migrations/{user_task_status.uuid}/')
+ force_authenticate(request, user=self.user)
+ response = self.view(request, uuid=str(user_task_status.uuid))
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data['uuid'] == str(user_task_status.uuid)
+ assert 'parameters' in response.data
+
+ def test_retrieve_migration_other_user(self):
+ """
+ Test that users cannot retrieve migrations created by other users.
+
+ Validates:
+ - 404 status code when attempting to retrieve another user's migration
+ - Users are isolated to their own migrations
+ """
+ other_user = User.objects.create_user(
+ username='otheruser',
+ email='other@test.com',
+ password='password'
+ )
+ org = OrganizationFactory(short_name="TestOrg", name="Test Org")
+ source_key = CourseLocator.from_string('course-v1:TestOrg+TestCourse+TestRun')
+ source = ModulestoreSource.objects.create(key=str(source_key))
+ target = lib_api.create_library(org=org, slug="TestLib", title="Test Target Lib")
+ other_task_status = UserTaskStatus.objects.create(
+ user=other_user,
+ task_id='other-task-id',
+ task_class='test.Task',
+ name='Other Migration',
+ total_steps=10,
+ completed_steps=10,
+ )
+ ModulestoreMigrationModel.objects.create(
+ task_status=other_task_status,
+ source=source,
+ target_id=target.learning_package_id,
+ )
+
+ request = self.factory.get(f'/api/modulestore_migrator/v1/migrations/{other_task_status.uuid}/')
+ force_authenticate(request, user=self.user)
+ response = self.view(request, uuid=str(other_task_status.uuid))
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_retrieve_migration_unauthenticated(self):
+ """
+ Test that unauthenticated requests return 401 Unauthorized.
+
+ Validates:
+ - 401 status code for unauthenticated requests
+ """
+ task_uuid = uuid4()
+ request = self.factory.get(f'/api/modulestore_migrator/v1/migrations/{task_uuid}/')
+
+ response = self.view(request, uuid=str(task_uuid))
+
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+class TestMigrationViewSetCancel(TestCase):
+ """
+ Test the MigrationViewSet.cancel() endpoint.
+
+ Focus: validation, return codes, authorization.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.factory = APIRequestFactory()
+ self.view = MigrationViewSet.as_view({'post': 'cancel'})
+
+ self.staff_user = User.objects.create_user(
+ username='staffuser',
+ email='staff@test.com',
+ password='password',
+ is_staff=True
+ )
+ self.regular_user = User.objects.create_user(
+ username='regularuser',
+ email='regular@test.com',
+ password='password',
+ is_staff=False
+ )
+
+ def test_cancel_migration_as_staff(self):
+ """
+ Test that staff users can cancel migrations.
+
+ Validates:
+ - Staff users can successfully cancel migrations
+ - UserTaskStatus.cancel is called
+ """
+ org = OrganizationFactory(short_name="TestOrg", name="Test Org")
+ source_key = CourseLocator.from_string('course-v1:TestOrg+TestCourse+TestRun')
+ source = ModulestoreSource.objects.create(key=str(source_key))
+ target = lib_api.create_library(org=org, slug="TestLib", title="Test Target Lib")
+ user_task_status = UserTaskStatus.objects.create(
+ user=self.staff_user,
+ task_id='staff-task-id',
+ task_class='test.Task',
+ name='Staff Migration',
+ total_steps=10,
+ completed_steps=5,
+ )
+ ModulestoreMigrationModel.objects.create(
+ task_status=user_task_status,
+ source=source,
+ target_id=target.learning_package_id,
+ )
+
+ with patch.object(UserTaskStatus, 'cancel') as mock_cancel:
+ request = self.factory.post(
+ f'/api/modulestore_migrator/v1/migrations/{user_task_status.uuid}/cancel/'
+ )
+ force_authenticate(request, user=self.staff_user)
+ response = self.view(request, uuid=str(user_task_status.uuid))
+
+ assert response.status_code == status.HTTP_200_OK
+ mock_cancel.assert_called_once()
+
+ def test_cancel_migration_not_found(self):
+ """
+ Test that attempting to cancel a non-existent migration returns 404.
+
+ Validates:
+ - 404 status code when migration UUID does not exist
+ """
+ nonexistent_uuid = uuid4()
+ request = self.factory.post(
+ f'/api/modulestore_migrator/v1/migrations/{nonexistent_uuid}/cancel/'
+ )
+ force_authenticate(request, user=self.staff_user)
+
+ response = self.view(request, uuid=str(nonexistent_uuid))
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_cancel_migration_as_non_staff(self):
+ """
+ Test that non-staff users cannot cancel migrations.
+
+ Validates:
+ - 403 Forbidden status code for non-staff users
+ """
+ task_uuid = uuid4()
+ request = self.factory.post(
+ f'/api/modulestore_migrator/v1/migrations/{task_uuid}/cancel/'
+ )
+ force_authenticate(request, user=self.regular_user)
+
+ response = self.view(request, uuid=str(task_uuid))
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ def test_cancel_migration_unauthenticated(self):
+ """
+ Test that unauthenticated users cannot cancel migrations.
+ """
+ task_uuid = uuid4()
+ request = self.factory.post(
+ f'/api/modulestore_migrator/v1/migrations/{task_uuid}/cancel/'
+ )
+
+ response = self.view(request, uuid=str(task_uuid))
+
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+class TestBulkMigrationViewSetCreate(TestCase):
+ """
+ Test the BulkMigrationViewSet.create() endpoint.
+
+ Focus: validation, return codes, serialization/deserialization.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.factory = APIRequestFactory()
+ self.view = BulkMigrationViewSet.as_view({'post': 'create'})
+
+ self.user = User.objects.create_user(
+ username='testuser',
+ email='testuser@test.com',
+ password='password'
+ )
+
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.migrator_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.lib_api')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.auth')
+ @patch('cms.djangoapps.modulestore_migrator.rest_api.v1.views.UserTaskStatus')
+ def test_create_bulk_migration_success(
+ self, mock_user_task_status, mock_auth, mock_lib_api, mock_migrator_api
+ ):
+ """
+ Test successful bulk migration creation with multiple sources.
+
+ Validates:
+ - 201 status code is returned
+ - Response contains expected serialized fields
+ - Multiple sources are properly deserialized
+ """
+ mock_auth.has_studio_write_access.return_value = True
+ mock_lib_api.require_permission_for_library_key.return_value = None
+
+ mock_task = MagicMock(autospec=True)
+ mock_task.id = 'test-task-id'
+ mock_migrator_api.start_bulk_migration_to_library.return_value = mock_task
+
+ mock_task_status = MagicMock(autospec=True)
+ mock_task_status.uuid = uuid4()
+ mock_task_status.state = 'Pending'
+ mock_task_status.state_text = 'Pending'
+ mock_task_status.completed_steps = 0
+ mock_task_status.total_steps = 10
+ mock_task_status.attempts = 1
+ mock_task_status.created = '2025-01-01T00:00:00Z'
+ mock_task_status.modified = '2025-01-01T00:00:00Z'
+ mock_task_status.artifacts = []
+ mock_task_status.migrations.all.return_value = []
+
+ mock_user_task_status.objects.get.return_value = mock_task_status
+
+ request_data = {
+ 'sources': [
+ 'course-v1:TestOrg+TestCourse1+Run1',
+ 'course-v1:TestOrg+TestCourse2+Run2'
+ ],
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/bulk_migration/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_201_CREATED
+ assert 'uuid' in response.data
+ assert 'parameters' in response.data
+
+ mock_migrator_api.start_bulk_migration_to_library.assert_called_once()
+ call_kwargs = mock_migrator_api.start_bulk_migration_to_library.call_args[1]
+ assert call_kwargs['source_key_list'] == [
+ CourseLocator.from_string('course-v1:TestOrg+TestCourse1+Run1'),
+ CourseLocator.from_string('course-v1:TestOrg+TestCourse2+Run2'),
+ ]
+ assert call_kwargs['target_collection_slug_list'] is None
+ assert call_kwargs['create_collections'] is False
+ # CompositionLevel and RepeatHandlingStrategy are enums
+ assert call_kwargs['composition_level'].value == 'component'
+ assert call_kwargs['repeat_handling_strategy'].value == 'skip'
+ assert call_kwargs['preserve_url_slugs'] is False
+ assert call_kwargs['forward_source_to_target'] is None
+
+ def test_create_bulk_migration_invalid_source_key(self):
+ """
+ Test that invalid source key in list returns 400 Bad Request.
+
+ Validates:
+ - 400 status code when one or more sources are invalid
+ """
+ request_data = {
+ 'sources': ['not-a-valid-key', 'course-v1:TestOrg+TestCourse+TestRun'],
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/bulk_migration/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'sources' in response.data
+
+ def test_create_bulk_migration_missing_sources(self):
+ """
+ Test that missing sources field returns 400 Bad Request.
+
+ Validates:
+ - 400 status code when sources is missing
+ """
+ request_data = {
+ 'target': 'lib:TestOrg:TestLibrary',
+ }
+ request = self.factory.post(
+ '/api/modulestore_migrator/v1/bulk_migration/',
+ data=request_data,
+ format='json'
+ )
+ force_authenticate(request, user=self.user)
+ response = self.view(request)
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ assert 'sources' in response.data
diff --git a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py
index afd422bf04ef..02ac7c6f2bbc 100644
--- a/cms/djangoapps/modulestore_migrator/tests/test_tasks.py
+++ b/cms/djangoapps/modulestore_migrator/tests/test_tasks.py
@@ -3,44 +3,45 @@
"""
from unittest.mock import Mock, patch
+
import ddt
from django.utils import timezone
from lxml import etree
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
-from openedx_learning.api.authoring_models import Collection, PublishableEntityVersion
from openedx_learning.api import authoring as authoring_api
+from openedx_learning.api.authoring_models import Collection, PublishableEntityVersion
from organizations.tests.factories import OrganizationFactory
from user_tasks.models import UserTaskArtifact
from user_tasks.tasks import UserTaskStatus
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
-from common.djangoapps.student.tests.factories import UserFactory
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
from cms.djangoapps.modulestore_migrator.models import (
ModulestoreMigration,
ModulestoreSource,
)
from cms.djangoapps.modulestore_migrator.tasks import (
+ MigrationStep,
+ _BulkMigrationTask,
_migrate_component,
_migrate_container,
_migrate_node,
_MigratedNode,
_MigrationContext,
- _MigrationTask,
- _BulkMigrationTask,
- migrate_from_modulestore,
bulk_migrate_from_modulestore,
- MigrationStep,
)
+from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory, BlockFactory
+
+from .. import api as migrator_api
@ddt.ddt
class TestMigrateFromModulestore(ModuleStoreTestCase):
"""
- Test the migrate_from_modulestore task
+ Test the bulk_migrate_from_modulestore task
"""
def setUp(self):
@@ -100,6 +101,28 @@ def setUp(self):
title="Test Collection 2",
)
+ def _make_migration_context(self, **kwargs) -> _MigrationContext:
+ """
+ Builds a _MigrationContext object with default values, overridable with kwargs
+ """
+ return _MigrationContext(
+ **{
+ "used_component_keys": set(),
+ "used_container_slugs": set(),
+ "previous_block_migrations": {},
+ "target_package_id": self.learning_package.id,
+ "target_library_key": self.library.library_key,
+ "source_context_key": self.course.id,
+ "content_by_filename": {},
+ "composition_level": CompositionLevel.Unit,
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip,
+ "preserve_url_slugs": True,
+ "created_at": timezone.now(),
+ "created_by": self.user.id,
+ **kwargs,
+ },
+ )
+
def _get_task_status_fail_message(self, status):
"""
Helper method to get the failure message from a UserTaskStatus object.
@@ -113,18 +136,7 @@ def test_migrate_node_wiki_tag(self):
Test _migrate_node ignores wiki tags
"""
wiki_node = etree.fromstring("")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ context = self._make_migration_context()
result = _migrate_node(
context=context,
@@ -143,19 +155,7 @@ def test_migrate_node_course_root(self):
''
""
)
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
+ context = self._make_migration_context()
result = _migrate_node(
context=context,
source_node=course_node,
@@ -175,18 +175,7 @@ def test_migrate_node_library_root(self):
''
""
)
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ context = self._make_migration_context()
result = _migrate_node(
context=context,
source_node=library_node,
@@ -215,19 +204,7 @@ def test_migrate_node_container_composition_level(
container_node = etree.fromstring(
f'<{tag_name} url_name="test_{tag_name}" display_name="Test {tag_name.title()}" />'
)
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=composition_level,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
+ context = self._make_migration_context(composition_level=composition_level)
result = _migrate_node(
context=context,
source_node=container_node,
@@ -235,9 +212,10 @@ def test_migrate_node_container_composition_level(
if should_migrate:
self.assertIsNotNone(result.source_to_target)
- source_key, _ = result.source_to_target
+ source_key, _, reason = result.source_to_target
self.assertEqual(source_key.block_type, tag_name)
self.assertEqual(source_key.block_id, f"test_{tag_name}")
+ self.assertIsNone(reason)
else:
self.assertIsNone(result.source_to_target)
@@ -248,25 +226,40 @@ def test_migrate_node_without_url_name(self):
node_without_url_name = etree.fromstring(
''
)
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
+ context = self._make_migration_context()
+ result = _migrate_node(
+ context=context,
+ source_node=node_without_url_name,
)
+ self.assertIsNone(result.source_to_target)
+ self.assertEqual(len(result.children), 0)
+
+ def test_migrate_node_with_children_components(self):
+ """
+ Test _migrate_node handles nodes with children components
+ """
+ node_without_url_name = etree.fromstring('''
+
+
+
+
+ ''')
+ context = self._make_migration_context()
result = _migrate_node(
context=context,
source_node=node_without_url_name,
)
- self.assertIsNone(result.source_to_target)
+ self.assertEqual(
+ result.source_to_target,
+ (
+ self.course.id.make_usage_key('library_content', 'test_library_content'),
+ None,
+ 'The "library_content" XBlock (ID: "test_library_content") has children, '
+ 'so it not supported in content libraries. It has 2 children blocks.',
+ ),
+ )
self.assertEqual(len(result.children), 0)
def test_migrated_node_all_source_to_target_pairs(self):
@@ -281,11 +274,11 @@ def test_migrated_node_all_source_to_target_pairs(self):
key2 = self.course.id.make_usage_key("problem", "problem2")
key3 = self.course.id.make_usage_key("problem", "problem3")
- child_node = _MigratedNode(source_to_target=(key3, mock_version3), children=[])
+ child_node = _MigratedNode(source_to_target=(key3, mock_version3, None), children=[])
parent_node = _MigratedNode(
- source_to_target=(key1, mock_version1),
+ source_to_target=(key1, mock_version1, None),
children=[
- _MigratedNode(source_to_target=(key2, mock_version2), children=[]),
+ _MigratedNode(source_to_target=(key2, mock_version2, None), children=[]),
child_node,
],
)
@@ -297,27 +290,6 @@ def test_migrated_node_all_source_to_target_pairs(self):
self.assertEqual(pairs[1][0], key2)
self.assertEqual(pairs[2][0], key3)
- def test_migrate_from_modulestore_invalid_source(self):
- """
- Test migrate_from_modulestore with invalid source
- """
- task = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": 999999, # Non-existent source
- "target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- status = UserTaskStatus.objects.get(task_id=task.id)
- self.assertEqual(status.state, UserTaskStatus.FAILED)
- self.assertEqual(self._get_task_status_fail_message(status), "ModulestoreSource matching query does not exist.")
-
def test_bulk_migrate_invalid_sources(self):
"""
Test bulk_migrate_from_modulestore with invalid source
@@ -339,31 +311,6 @@ def test_bulk_migrate_invalid_sources(self):
self.assertEqual(status.state, UserTaskStatus.FAILED)
self.assertEqual(self._get_task_status_fail_message(status), "ModulestoreSource matching query does not exist.")
- def test_migrate_from_modulestore_invalid_collection(self):
- """
- Test migrate_from_modulestore with invalid collection
- """
- source = ModulestoreSource.objects.create(
- key=self.course.id,
- )
-
- task = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": 999999, # Non-existent collection
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- status = UserTaskStatus.objects.get(task_id=task.id)
- self.assertEqual(status.state, UserTaskStatus.FAILED)
- self.assertEqual(self._get_task_status_fail_message(status), "Collection matching query does not exist.")
-
def test_bulk_migrate_invalid_collection(self):
"""
Test bulk_migrate_from_modulestore with invalid collection
@@ -389,14 +336,6 @@ def test_bulk_migrate_invalid_collection(self):
self.assertEqual(status.state, UserTaskStatus.FAILED)
self.assertEqual(self._get_task_status_fail_message(status), "Collection matching query does not exist.")
- def test_migration_task_calculate_total_steps(self):
- """
- Test _MigrationTask.calculate_total_steps returns correct count
- """
- total_steps = _MigrationTask.calculate_total_steps({})
- expected_steps = len(list(MigrationStep)) - 1
- self.assertEqual(total_steps, expected_steps)
-
def test_bulk_migration_task_calculate_total_steps(self):
"""
Test _BulkMigrationTask.calculate_total_steps returns correct count
@@ -413,26 +352,15 @@ def test_migrate_component_success(self):
"""
source_key = self.course.id.make_usage_key("problem", "test_problem")
olx = ''
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- result = _migrate_component(
+ context = self._make_migration_context()
+ result, reason = _migrate_component(
context=context,
source_key=source_key,
olx=olx,
title="test_problem"
)
+ self.assertIsNone(reason)
self.assertIsNotNone(result)
self.assertIsInstance(result, PublishableEntityVersion)
@@ -440,6 +368,35 @@ def test_migrate_component_success(self):
"problem", result.componentversion.component.component_type.name
)
+ # The component is published
+ self.assertFalse(result.componentversion.component.versioning.has_unpublished_changes)
+
+ def test_migrate_component_failure(self):
+ """
+ Test _migrate_component fails to import component with children
+ """
+ source_key = self.course.id.make_usage_key("library_content", "test_library_content")
+ olx = '''
+
+
+
+
+ '''
+ context = self._make_migration_context()
+ result, reason = _migrate_component(
+ context=context,
+ source_key=source_key,
+ olx=olx,
+ title="test_library content"
+ )
+
+ self.assertIsNone(result)
+ self.assertEqual(
+ reason,
+ 'The "library_content" XBlock (ID: "test_library_content") has children,'
+ ' so it not supported in content libraries.',
+ )
+
def test_migrate_component_with_static_content(self):
"""
Test _migrate_component with static file content
@@ -455,19 +412,8 @@ def test_migrate_component_with_static_content(self):
created=timezone.now(),
)
content_by_filename = {"test_image.png": test_content.id}
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename=content_by_filename,
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
- result = _migrate_component(
+ context = self._make_migration_context(content_by_filename=content_by_filename)
+ result, reason = _migrate_component(
context=context,
source_key=source_key,
olx=olx,
@@ -475,6 +421,7 @@ def test_migrate_component_with_static_content(self):
)
self.assertIsNotNone(result)
+ self.assertIsNone(reason)
component_content = result.componentversion.componentversioncontent_set.filter(
key="static/test_image.png"
@@ -482,159 +429,204 @@ def test_migrate_component_with_static_content(self):
self.assertIsNotNone(component_content)
self.assertEqual(component_content.content_id, test_content.id)
- def test_migrate_component_replace_existing_false(self):
+ def test_migrate_skip_repeats(self):
"""
- Test _migrate_component with replace_existing=False returns existing component
+ Test that, when requested, the migration will Skip blocks that have previously been migrated
+
+ Tests with both a container and a component
"""
- source_key = self.course.id.make_usage_key("problem", "existing_problem")
- olx = ''
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ source = ModulestoreSource.objects.create(key=self.course.id)
- first_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=olx,
- title="test_problem"
+ # Create a legacy lib with 2 blocks and migrate it
+ source_html = BlockFactory.create(
+ category="html",
+ display_name="Test HTML for Skip",
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ source_unit = BlockFactory.create(
+ category="vertical",
+ display_name="Test Unit for Skip",
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value, # arbitrary
+ "preserve_url_slugs": True,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
- context.existing_source_to_target_keys[source_key] = [first_result.entity]
-
- second_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx='',
- title="updated_problem"
+ # Update both blocks, and add a new one. Then migrate again.
+ source_html.display_name = "Test HTML for Skip - Source Updated"
+ source_html.save()
+ self.store.update_item(source_html, self.user.id)
+ source_unit.display_name = "Test Unit for Skip - Source Updated"
+ source_unit.save()
+ self.store.update_item(source_unit, self.user.id)
+ source_html_new = BlockFactory.create(
+ category="html",
+ display_name="Test HTML New",
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value, # <-- important
+ "preserve_url_slugs": True,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
- self.assertEqual(first_result.entity_id, second_result.entity_id)
- self.assertEqual(first_result.version_num, second_result.version_num)
+ # The first migration's info includes the initial two blocks.
+ migration_1, migration_0 = list(migrator_api.get_migrations(source_key=source.key))
+ mappings_0 = migrator_api.get_migration_blocks(migration_0.pk)
+ assert set(mappings_0) == {source_html.usage_key, source_unit.usage_key}
+ assert mappings_0[source_html.usage_key].target_title == "Test HTML for Skip"
+ assert mappings_0[source_unit.usage_key].target_title == "Test Unit for Skip"
+
+ # The next migration's info includes the newly-added block,
+ # but not the edited blocks, because we chose Skip.
+ mappings_1 = migrator_api.get_migration_blocks(migration_1.pk)
+ assert set(mappings_1) == {source_html_new.usage_key}
+ assert mappings_1[source_html_new.usage_key].target_title == "Test HTML New"
def test_migrate_component_same_title(self):
"""
- Test _migrate_component for two components with the same title
+ Test a migration with two components of the same title, when updating.
- Using preserve_url_slugs=False to create a new component with
- a different URL slug based on the component's Title.
+ We expect that both blocks will be migrated to target components with usage keys
+ based on the shared title, but disambiguated by a _1 suffix.
"""
- source_key_1 = self.course.id.make_usage_key("problem", "existing_problem_1")
- source_key_2 = self.course.id.make_usage_key("problem", "existing_problem_2")
- olx = ''
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=False,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- first_result = _migrate_component(
- context=context,
- source_key=source_key_1,
- olx=olx,
- title="test_problem"
- )
-
- context.existing_source_to_target_keys[source_key_1] = [first_result.entity]
-
- second_result = _migrate_component(
- context=context,
- source_key=source_key_2,
- olx=olx,
- title="test_problem"
+ source = ModulestoreSource.objects.create(key=self.course.id)
+ source_key_1 = self.course.id.make_usage_key("html", "existing_html_1")
+ source_key_2 = self.course.id.make_usage_key("html", "existing_html_2")
+ BlockFactory.create(
+ category="html",
+ display_name="Test HTML Same Title",
+ location=source_key_1,
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ BlockFactory.create(
+ category="html",
+ display_name="Test HTML Same Title",
+ location=source_key_2,
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
+ "preserve_url_slugs": False,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
+ migrations = list(migrator_api.get_migrations(source_key=source.key))
+ assert len(migrations) == 1
+ mappings = migrator_api.get_migration_blocks(migrations[0].pk)
+ assert (html_migration_1 := mappings.get(source_key_1))
+ assert (block_migration_2 := mappings.get(source_key_2))
+ assert html_migration_1.target_title == "Test HTML Same Title"
+ assert block_migration_2.target_title == "Test HTML Same Title"
+ assert str(html_migration_1.target_key) == "lb:testorg:test-key:html:test-html-same-title"
+ assert str(block_migration_2.target_key) == "lb:testorg:test-key:html:test-html-same-title_1"
- self.assertNotEqual(first_result.entity_id, second_result.entity_id)
- self.assertNotEqual(first_result.entity.key, second_result.entity.key)
-
- def test_migrate_component_replace_existing_true(self):
- """
- Test _migrate_component with replace_existing=True creates new version
+ def test_migrate_update_repeats(self):
"""
- source_key = self.course.id.make_usage_key("problem", "replaceable_problem")
- original_olx = ''
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Update,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ Test that, when requested, the migration will update blocks that have previously been migrated
- first_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=original_olx,
- title="original"
+ Tests with both a container and a component
+ """
+ source = ModulestoreSource.objects.create(key=self.course.id)
+ source_html = BlockFactory.create(
+ category="html",
+ display_name="Test HTML for Update",
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ source_unit = BlockFactory.create(
+ category="vertical",
+ display_name="Test Unit for Update",
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ # (the value of repeat_handling_strategy here doesn't matter for this test)
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
+ "preserve_url_slugs": True,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
-
- context.existing_source_to_target_keys[source_key] = [first_result.entity]
-
- updated_olx = ''
- second_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=updated_olx,
- title="updated"
+ source_html.display_name = "Test HTML for Update - Source Updated"
+ source_html.save()
+ self.store.update_item(source_html, self.user.id)
+ source_unit.display_name = "Test Unit for Update - Source Updated"
+ source_unit.save()
+ self.store.update_item(source_unit, self.user.id)
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Update.value,
+ "preserve_url_slugs": True,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
-
- self.assertEqual(first_result.entity_id, second_result.entity_id)
- self.assertNotEqual(first_result.version_num, second_result.version_num)
-
- def test_migrate_component_different_block_types(self):
- """
- Test _migrate_component with different block types
- """
- block_types = ["problem", "html", "video", "discussion"]
-
- for block_type in block_types:
- source_key = self.course.id.make_usage_key(block_type, f"test_{block_type}")
- olx = f'<{block_type} display_name="Test {block_type.title()}">{block_type}>'
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=olx,
- title="test"
- )
-
- self.assertIsNotNone(result, f"Failed to migrate {block_type}")
-
- self.assertEqual(
- block_type, result.componentversion.component.component_type.name
- )
+ migration_1, migration_0 = list(migrator_api.get_migrations(source_key=source.key))
+ mappings_0 = migrator_api.get_migration_blocks(migration_0.pk)
+ mappings_1 = migrator_api.get_migration_blocks(migration_1.pk)
+ assert (html_migration_0 := mappings_0.get(source_html.usage_key))
+ assert (unit_migration_0 := mappings_0.get(source_unit.usage_key))
+ assert (html_migration_1 := mappings_1.get(source_html.usage_key))
+ assert (unit_migration_1 := mappings_1.get(source_unit.usage_key))
+
+ # The targets of both migrations are the same
+ assert str(html_migration_0.target_key) == "lb:testorg:test-key:html:Test_HTML_for_Update"
+ assert str(html_migration_1.target_key) == "lb:testorg:test-key:html:Test_HTML_for_Update"
+ assert html_migration_0.target_entity_pk == html_migration_1.target_entity_pk
+ assert str(unit_migration_0.target_key) == "lct:testorg:test-key:unit:Test_Unit_for_Update"
+ assert unit_migration_0.target_entity_pk == unit_migration_1.target_entity_pk
+
+ # And because we specified Update, the targets were updated on the 2nd migration
+ assert html_migration_0.target_title == "Test HTML for Update"
+ assert unit_migration_0.target_title == "Test Unit for Update"
+ assert html_migration_1.target_title == "Test HTML for Update - Source Updated"
+ assert unit_migration_1.target_title == "Test Unit for Update - Source Updated"
+ assert html_migration_0.target_version_num == html_migration_1.target_version_num - 1
+ assert unit_migration_0.target_version_num == unit_migration_1.target_version_num - 1
def test_migrate_component_content_filename_not_in_olx(self):
"""
@@ -663,20 +655,11 @@ def test_migrate_component_content_filename_not_in_olx(self):
"referenced.png": referenced_content.id,
"unreferenced.png": unreferenced_content.id,
}
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
+ context = self._make_migration_context(
content_by_filename=content_by_filename,
- composition_level=CompositionLevel.Unit,
repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
)
-
- result = _migrate_component(
+ result, reason = _migrate_component(
context=context,
source_key=source_key,
olx=olx,
@@ -684,6 +667,7 @@ def test_migrate_component_content_filename_not_in_olx(self):
)
self.assertIsNotNone(result)
+ self.assertIsNone(reason)
referenced_content_exists = (
result.componentversion.componentversioncontent_set.filter(
@@ -706,20 +690,8 @@ def test_migrate_component_library_source_key(self):
library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
source_key = library_key.make_usage_key("problem", "library_problem")
olx = ''
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- result = _migrate_component(
+ context = self._make_migration_context()
+ result, reason = _migrate_component(
context=context,
source_key=source_key,
olx=olx,
@@ -727,61 +699,12 @@ def test_migrate_component_library_source_key(self):
)
self.assertIsNotNone(result)
+ self.assertIsNone(reason)
self.assertEqual(
"problem", result.componentversion.component.component_type.name
)
- def test_migrate_component_duplicate_content_integrity_error(self):
- """
- Test _migrate_component handles IntegrityError when content already exists
- """
- source_key = self.course.id.make_usage_key(
- "problem", "test_problem_duplicate_content"
- )
- olx = 'See image: duplicate.png
'
-
- media_type = authoring_api.get_or_create_media_type("image/png")
- test_content = authoring_api.get_or_create_file_content(
- self.learning_package.id,
- media_type.id,
- data=b"test_image_data",
- created=timezone.now(),
- )
- content_by_filename = {"duplicate.png": test_content.id}
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename=content_by_filename,
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Update,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- first_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=olx,
- title="test_problem"
- )
-
- context.existing_source_to_target_keys[source_key] = [first_result.entity]
-
- second_result = _migrate_component(
- context=context,
- source_key=source_key,
- olx=olx,
- title="test_problem"
- )
-
- self.assertIsNotNone(first_result)
- self.assertIsNotNone(second_result)
- self.assertEqual(first_result.entity_id, second_result.entity_id)
-
def test_migrate_container_creates_new_container(self):
"""
Test _migrate_container creates a new container when none exists
@@ -824,20 +747,9 @@ def test_migrate_container_creates_new_container(self):
child_version_1.publishable_entity_version,
child_version_2.publishable_entity_version,
]
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
- result = _migrate_container(
+ result, reason = _migrate_container(
context=context,
source_key=source_key,
container_type=lib_api.ContainerType.Unit,
@@ -845,6 +757,7 @@ def test_migrate_container_creates_new_container(self):
children=children,
)
+ self.assertIsNone(reason)
self.assertIsInstance(result, PublishableEntityVersion)
container_version = result.containerversion
@@ -866,18 +779,7 @@ def test_migrate_container_different_container_types(self):
(lib_api.ContainerType.Subsection, "sequential"),
(lib_api.ContainerType.Section, "chapter"),
]
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
for container_type, block_type in container_types:
with self.subTest(container_type=container_type, block_type=block_type):
@@ -885,7 +787,7 @@ def test_migrate_container_different_container_types(self):
block_type, f"test_{block_type}"
)
- result = _migrate_container(
+ result, reason = _migrate_container(
context=context,
source_key=source_key,
container_type=container_type,
@@ -893,180 +795,70 @@ def test_migrate_container_different_container_types(self):
children=[],
)
+ self.assertIsNone(reason)
self.assertIsNotNone(result)
container_version = result.containerversion
self.assertEqual(container_version.title, f"Test {block_type.title()}")
-
- def test_migrate_container_replace_existing_false(self):
- """
- Test _migrate_container returns existing container when replace_existing=False
- """
- source_key = self.course.id.make_usage_key("vertical", "existing_vertical")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- first_result = _migrate_container(
- context=context,
- source_key=source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Original Title",
- children=[],
- )
-
- context.existing_source_to_target_keys[source_key] = [first_result.entity]
-
- second_result = _migrate_container(
- context=context,
- source_key=source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Updated Title",
- children=[],
- )
-
- self.assertEqual(first_result.entity_id, second_result.entity_id)
- self.assertEqual(first_result.version_num, second_result.version_num)
-
- container_version = second_result.containerversion
- self.assertEqual(container_version.title, "Original Title")
+ # The container is published
+ self.assertFalse(authoring_api.contains_unpublished_changes(container_version.container.pk))
def test_migrate_container_same_title(self):
"""
- Test _migrate_container for two containers with the same title
-
- Using preserve_url_slugs=False to create a new Unit with
- a different URL slug based on the container's Title.
- """
- source_key_1 = self.course.id.make_usage_key("vertical", "human_readable_vertical_1")
- source_key_2 = self.course.id.make_usage_key("vertical", "human_readable_vertical_2")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=False,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- first_result = _migrate_container(
- context=context,
- source_key=source_key_1,
- container_type=lib_api.ContainerType.Unit,
- title="Original Human Readable Title",
- children=[],
- )
+ Test a migration with two containers of the same title and preserve_url_slugs=False
- context.existing_source_to_target_keys[source_key_1] = [first_result.entity]
-
- second_result = _migrate_container(
- context=context,
- source_key=source_key_2,
- container_type=lib_api.ContainerType.Unit,
- title="Original Human Readable Title",
- children=[],
- )
-
- self.assertNotEqual(first_result.entity_id, second_result.entity_id)
- self.assertNotEqual(first_result.entity.key, second_result.entity.key)
- # Make sure the current logic from tasts::_find_unique_slug is used
- self.assertEqual(second_result.entity.key, first_result.entity.key + "_1")
-
- container_version = second_result.containerversion
- self.assertEqual(container_version.title, "Original Human Readable Title")
-
- def test_migrate_container_replace_existing_true(self):
+ We expect that both units will be migrated to target units with container keys
+ based on the shared title, but disambiguated by a _1 suffix.
"""
- Test _migrate_container creates new version when replace_existing=True
- """
- source_key = self.course.id.make_usage_key("vertical", "replaceable_vertical")
-
- child_component = authoring_api.create_component(
- self.learning_package.id,
- component_type=authoring_api.get_or_create_component_type(
- "xblock.v1", "problem"
- ),
- local_key="child_problem",
- created=timezone.now(),
- created_by=self.user.id,
- )
- child_version = authoring_api.create_next_component_version(
- child_component.pk,
- content_to_replace={},
- created=timezone.now(),
- created_by=self.user.id,
- )
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Update,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- first_result = _migrate_container(
- context=context,
- source_key=source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Original Title",
- children=[],
- )
-
- context.existing_source_to_target_keys[source_key] = [first_result.entity]
-
- second_result = _migrate_container(
- context=context,
- source_key=source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Updated Title",
- children=[child_version.publishable_entity_version],
- )
-
- self.assertEqual(first_result.entity_id, second_result.entity_id)
- self.assertNotEqual(first_result.version_num, second_result.version_num)
-
- container_version = second_result.containerversion
- self.assertEqual(container_version.title, "Updated Title")
- self.assertEqual(container_version.entity_list.entitylistrow_set.count(), 1)
-
- def test_migrate_container_with_library_source_key(self):
- """
- Test _migrate_container with library source key
- """
- library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
- source_key = library_key.make_usage_key("vertical", "library_vertical")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
+ source = ModulestoreSource.objects.create(key=self.course.id)
+ source_key_1 = self.course.id.make_usage_key("vertical", "existing_unit_1")
+ source_key_2 = self.course.id.make_usage_key("vertical", "existing_unit_2")
+ BlockFactory.create(
+ category="vertical",
+ display_name="Test Unit Same Title",
+ location=source_key_1,
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ BlockFactory.create(
+ category="html",
+ display_name="Test Unit Same Title",
+ location=source_key_2,
+ parent_location=self.course.usage_key,
+ user_id=self.user.id,
+ publish_item=False
+ )
+ bulk_migrate_from_modulestore.apply_async(
+ kwargs={
+ "user_id": self.user.id,
+ "sources_pks": [source.id],
+ "target_library_key": str(self.lib_key),
+ "target_collection_pks": [],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
+ "preserve_url_slugs": False,
+ "composition_level": CompositionLevel.Unit.value,
+ "forward_source_to_target": False,
+ }
)
+ (migration,) = list(migrator_api.get_migrations(source_key=source.key))
+ mappings = migrator_api.get_migration_blocks(migration.pk)
+ assert (html_migration_1 := mappings.get(source_key_1))
+ assert (block_migration_2 := mappings.get(source_key_2))
+ assert html_migration_1.target_title == "Test Unit Same Title"
+ assert block_migration_2.target_title == "Test Unit Same Title"
+ assert str(html_migration_1.target_key) == "lct:testorg:test-key:unit:test-unit-same-title"
+ assert str(block_migration_2.target_key) == "lct:testorg:test-key:unit:test-unit-same-title_1"
+
+ def test_migrate_container_with_library_source_key(self):
+ """
+ Test _migrate_container with library source key
+ """
+ library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
+ source_key = library_key.make_usage_key("vertical", "library_vertical")
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
- result = _migrate_container(
+ result, _ = _migrate_container(
context=context,
source_key=source_key,
container_type=lib_api.ContainerType.Unit,
@@ -1084,20 +876,8 @@ def test_migrate_container_empty_children_list(self):
Test _migrate_container handles empty children list
"""
source_key = self.course.id.make_usage_key("vertical", "empty_vertical")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- result = _migrate_container(
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
+ result, reason = _migrate_container(
context=context,
source_key=source_key,
container_type=lib_api.ContainerType.Unit,
@@ -1105,6 +885,7 @@ def test_migrate_container_empty_children_list(self):
children=[],
)
+ self.assertIsNone(reason)
self.assertIsNotNone(result)
container_version = result.containerversion
@@ -1115,18 +896,7 @@ def test_migrate_container_preserves_child_order(self):
Test _migrate_container preserves the order of children
"""
source_key = self.course.id.make_usage_key("vertical", "ordered_vertical")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
children = []
for i in range(3):
child_component = authoring_api.create_component(
@@ -1146,7 +916,7 @@ def test_migrate_container_preserves_child_order(self):
)
children.append(child_version.publishable_entity_version)
- result = _migrate_container(
+ result, _ = _migrate_container(
context=context,
source_key=source_key,
container_type=lib_api.ContainerType.Unit,
@@ -1222,20 +992,8 @@ def test_migrate_container_with_mixed_child_types(self):
html_version.publishable_entity_version,
video_version.publishable_entity_version,
]
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- result = _migrate_container(
+ context = self._make_migration_context(repeat_handling_strategy=RepeatHandlingStrategy.Skip)
+ result, _ = _migrate_container(
context=context,
source_key=source_key,
container_type=lib_api.ContainerType.Unit,
@@ -1256,76 +1014,6 @@ def test_migrate_container_with_mixed_child_types(self):
expected_entity_ids = {child.entity_id for child in children}
self.assertEqual(child_entity_ids, expected_entity_ids)
- def test_migrate_container_generates_correct_target_key(self):
- """
- Test _migrate_container generates correct target key from source key
- """
- course_source_key = self.course.id.make_usage_key("vertical", "test_vertical")
- context = _MigrationContext(
- existing_source_to_target_keys={},
- target_package_id=self.learning_package.id,
- target_library_key=self.library.library_key,
- source_context_key=self.course.id,
- content_by_filename={},
- composition_level=CompositionLevel.Unit,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip,
- preserve_url_slugs=True,
- created_at=timezone.now(),
- created_by=self.user.id,
- )
-
- course_result = _migrate_container(
- context=context,
- source_key=course_source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Course Vertical",
- children=[],
- )
- context.add_migration(course_source_key, course_result.entity)
-
- library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
- library_source_key = library_key.make_usage_key("vertical", "test_vertical")
-
- library_result = _migrate_container(
- context=context,
- source_key=library_source_key,
- container_type=lib_api.ContainerType.Unit,
- title="Library Vertical",
- children=[],
- )
-
- self.assertIsNotNone(course_result)
- self.assertIsNotNone(library_result)
- self.assertNotEqual(course_result.entity_id, library_result.entity_id)
-
- def test_migrate_from_modulestore_success_course(self):
- """
- Test successful migration from course to library
- """
- source = ModulestoreSource.objects.create(key=self.course.id)
-
- task = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- status = UserTaskStatus.objects.get(task_id=task.id)
- self.assertEqual(status.state, UserTaskStatus.SUCCEEDED)
-
- migration = ModulestoreMigration.objects.get(
- source=source, target=self.learning_package
- )
- self.assertEqual(migration.composition_level, CompositionLevel.Unit.value)
- self.assertEqual(migration.repeat_handling_strategy, RepeatHandlingStrategy.Skip.value)
-
def test_bulk_migrate_success_courses(self):
"""
Test successful bulk migration from courses to library
@@ -1367,12 +1055,12 @@ def test_migrate_from_modulestore_success_legacy_library(self):
"""
source = ModulestoreSource.objects.create(key=self.legacy_library.location.library_key)
- task = migrate_from_modulestore.apply_async(
+ task = bulk_migrate_from_modulestore.apply_async(
kwargs={
"user_id": self.user.id,
- "source_pk": source.id,
+ "sources_pks": [source.id],
"target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
+ "target_collection_pks": [self.collection.id],
"repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
"preserve_url_slugs": True,
"composition_level": CompositionLevel.Unit.value,
@@ -1518,7 +1206,6 @@ def test_bulk_migrate_use_previous_collection_on_skip_and_update(self, repeat_ha
migrations = ModulestoreMigration.objects.filter(
source=source, target=self.learning_package
)
-
for migration in migrations:
self.assertEqual(migration.composition_level, CompositionLevel.Unit.value)
self.assertEqual(migration.repeat_handling_strategy, repeat_handling_strategy.value)
@@ -1658,50 +1345,19 @@ def test_bulk_migrate_create_a_new_collection_on_fork(self):
self.assertEqual(migrations[1].target_collection.title, f"{self.legacy_library.display_name}_1")
self.assertNotEqual(migrations[1].target_collection.id, previous_collection.id)
- def test_migrate_from_modulestore_library_validation_failure(self):
- """
- Test migration from legacy library fails when modulestore content doesn't exist
- """
- library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
-
- source = ModulestoreSource.objects.create(key=library_key)
-
- task = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": None,
- "repeat_handling_strategy": RepeatHandlingStrategy.Update.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Section.value,
- "forward_source_to_target": True,
- }
- )
-
- status = UserTaskStatus.objects.get(task_id=task.id)
-
- # Should fail at loading step since we don't have real modulestore content
- self.assertEqual(status.state, UserTaskStatus.FAILED)
- self.assertEqual(
- self._get_task_status_fail_message(status),
- "Failed to load source item 'lib-block-v1:TestOrg+TestLibrary+type@library+block@library' "
- "from ModuleStore: library-v1:TestOrg+TestLibrary+branch@library"
- )
-
- def test_migrate_from_modulestore_invalid_source_key_type(self):
+ def test_bulk_migrate_invalid_source_key_type(self):
"""
- Test migration with invalid source key type
+ Test bulk migration with invalid source key type
"""
invalid_key = LibraryLocatorV2.from_string("lib:testorg:invalid")
source = ModulestoreSource.objects.create(key=invalid_key)
- task = migrate_from_modulestore.apply_async(
+ task = bulk_migrate_from_modulestore.apply_async(
kwargs={
"user_id": self.user.id,
- "source_pk": source.id,
+ "sources_pks": [source.id],
"target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
+ "target_collection_pks": [self.collection.id],
"repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
"preserve_url_slugs": True,
"composition_level": CompositionLevel.Unit.value,
@@ -1716,48 +1372,52 @@ def test_migrate_from_modulestore_invalid_source_key_type(self):
f"Not a valid source context key: {invalid_key}. Source key must reference a course or a legacy library."
)
- def test_bulk_migrate_invalid_source_key_type(self):
+ def test_migrate_component_with_fake_block_type(self):
"""
- Test bulk migration with invalid source key type
+ Test _migrate_component with with_fake_block_type
"""
- invalid_key = LibraryLocatorV2.from_string("lib:testorg:invalid")
- source = ModulestoreSource.objects.create(key=invalid_key)
-
- task = bulk_migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "sources_pks": [source.id],
- "target_library_key": str(self.lib_key),
- "target_collection_pks": [self.collection.id],
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
+ source_key = self.course.id.make_usage_key("fake_block", "test_fake_block")
+ olx = ''
+ context = _MigrationContext(
+ used_component_keys=set(),
+ used_container_slugs=set(),
+ previous_block_migrations={},
+ target_package_id=self.learning_package.id,
+ target_library_key=self.library.library_key,
+ source_context_key=self.course.id,
+ content_by_filename={},
+ composition_level=CompositionLevel.Unit,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
+ preserve_url_slugs=True,
+ created_at=timezone.now(),
+ created_by=self.user.id,
)
- status = UserTaskStatus.objects.get(task_id=task.id)
- self.assertEqual(status.state, UserTaskStatus.FAILED)
- self.assertEqual(
- self._get_task_status_fail_message(status),
- f"Not a valid source context key: {invalid_key}. Source key must reference a course or a legacy library."
+ result, reason = _migrate_component(
+ context=context,
+ source_key=source_key,
+ olx=olx,
+ title="test"
)
- def test_migrate_from_modulestore_nonexistent_modulestore_item(self):
+ self.assertIsNone(result)
+ self.assertEqual(reason, "Invalid block type: fake_block")
+
+ def test_bulk_migrate_nonexistent_modulestore_item(self):
"""
- Test migration when modulestore item doesn't exist
+ Test bulk migration when modulestore item doesn't exist
"""
nonexistent_course_key = CourseKey.from_string(
"course-v1:NonExistent+Course+Run"
)
source = ModulestoreSource.objects.create(key=nonexistent_course_key)
- task = migrate_from_modulestore.apply_async(
+ task = bulk_migrate_from_modulestore.apply_async(
kwargs={
"user_id": self.user.id,
- "source_pk": source.id,
+ "sources_pks": [source.id],
"target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
+ "target_collection_pks": [self.collection.id],
"repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
"preserve_url_slugs": True,
"composition_level": CompositionLevel.Unit.value,
@@ -1773,46 +1433,47 @@ def test_migrate_from_modulestore_nonexistent_modulestore_item(self):
"from ModuleStore: course-v1:NonExistent+Course+Run+branch@draft-branch"
)
- def test_bulk_migrate_nonexistent_modulestore_item(self):
+ def test_bulk_migrate_nonexistent_library(self):
"""
- Test bulk migration when modulestore item doesn't exist
+ Test migration from legacy library fails when modulestore content doesn't exist
"""
- nonexistent_course_key = CourseKey.from_string(
- "course-v1:NonExistent+Course+Run"
- )
- source = ModulestoreSource.objects.create(key=nonexistent_course_key)
+ library_key = LibraryLocator(org="TestOrg", library="TestLibrary")
+
+ source = ModulestoreSource.objects.create(key=library_key)
task = bulk_migrate_from_modulestore.apply_async(
kwargs={
"user_id": self.user.id,
"sources_pks": [source.id],
"target_library_key": str(self.lib_key),
- "target_collection_pks": [self.collection.id],
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
+ "target_collection_pks": [None],
+ "repeat_handling_strategy": RepeatHandlingStrategy.Update.value,
"preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
+ "composition_level": CompositionLevel.Section.value,
+ "forward_source_to_target": True,
}
)
status = UserTaskStatus.objects.get(task_id=task.id)
+
+ # Should fail at loading step since we don't have real modulestore content
self.assertEqual(status.state, UserTaskStatus.FAILED)
self.assertEqual(
self._get_task_status_fail_message(status),
- "Failed to load source item 'block-v1:NonExistent+Course+Run+type@course+block@course' "
- "from ModuleStore: course-v1:NonExistent+Course+Run+branch@draft-branch"
+ "Failed to load source item 'lib-block-v1:TestOrg+TestLibrary+type@library+block@library' "
+ "from ModuleStore: library-v1:TestOrg+TestLibrary+branch@library"
)
- def test_migrate_from_modulestore_task_status_progression(self):
+ def test_bulk_migrate_from_modulestore_task_status_progression(self):
"""Test that task status progresses through expected steps"""
source = ModulestoreSource.objects.create(key=self.course.id)
- task = migrate_from_modulestore.apply_async(
+ task = bulk_migrate_from_modulestore.apply_async(
kwargs={
"user_id": self.user.id,
- "source_pk": source.id,
+ "sources_pks": [source.id],
"target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
+ "target_collection_pks": [self.collection.id],
"repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
"preserve_url_slugs": True,
"composition_level": CompositionLevel.Unit.value,
@@ -1830,48 +1491,6 @@ def test_migrate_from_modulestore_task_status_progression(self):
)
self.assertEqual(migration.task_status, status)
- def test_migrate_from_modulestore_multiple_users_no_interference(self):
- """
- Test that migrations by different users don't interfere with each other
- """
- source = ModulestoreSource.objects.create(key=self.course.id)
- other_user = UserFactory()
-
- task1 = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- task2 = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": other_user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- status1 = UserTaskStatus.objects.get(task_id=task1.id)
- status2 = UserTaskStatus.objects.get(task_id=task2.id)
-
- self.assertEqual(status1.user, self.user)
- self.assertEqual(status2.user, other_user)
-
- # The first task should not be cancelled since it's from a different user
- self.assertNotEqual(status1.state, UserTaskStatus.CANCELED)
-
def test_bulk_migrate_multiple_users_no_interference(self):
"""
Test that migrations by different users don't interfere with each other
@@ -1914,35 +1533,6 @@ def test_bulk_migrate_multiple_users_no_interference(self):
# The first task should not be cancelled since it's from a different user
self.assertNotEqual(status1.state, UserTaskStatus.CANCELED)
- @patch("cms.djangoapps.modulestore_migrator.tasks._import_assets")
- def test_migrate_fails_on_import(self, mock_import_assets):
- """
- Test failed migration from legacy library to V2 library
- """
- mock_import_assets.side_effect = Exception("Simulated import error")
- source = ModulestoreSource.objects.create(key=self.legacy_library.location.library_key)
-
- task = migrate_from_modulestore.apply_async(
- kwargs={
- "user_id": self.user.id,
- "source_pk": source.id,
- "target_library_key": str(self.lib_key),
- "target_collection_pk": self.collection.id,
- "repeat_handling_strategy": RepeatHandlingStrategy.Skip.value,
- "preserve_url_slugs": True,
- "composition_level": CompositionLevel.Unit.value,
- "forward_source_to_target": False,
- }
- )
-
- status = UserTaskStatus.objects.get(task_id=task.id)
- self.assertEqual(status.state, UserTaskStatus.FAILED)
-
- migration = ModulestoreMigration.objects.get(
- source=source, target=self.learning_package
- )
- self.assertTrue(migration.is_failed)
-
@patch("cms.djangoapps.modulestore_migrator.tasks._import_assets")
def test_bulk_migrate_fails_on_import(self, mock_import_assets):
"""
diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py
index 8a089aeda75c..b56e0d95684d 100644
--- a/cms/lib/xblock/upstream_sync.py
+++ b/cms/lib/xblock/upstream_sync.py
@@ -87,6 +87,13 @@ class UpstreamLink:
downstream_customized: list[str] | None # List of fields modified in downstream
has_top_level_parent: bool # True if this Upstream link has a top-level parent
+ @property
+ def is_upstream_deleted(self) -> bool:
+ return bool(
+ self.upstream_ref and
+ self.version_available is None
+ )
+
@property
def is_ready_to_sync_individually(self) -> bool:
return bool(
@@ -94,7 +101,7 @@ def is_ready_to_sync_individually(self) -> bool:
self.version_available and
self.version_available > (self.version_synced or 0) and
self.version_available > (self.version_declined or 0)
- )
+ ) or self.is_upstream_deleted
def _check_children_ready_to_sync(self, xblock_downstream: XBlock, return_fast: bool) -> list[dict[str, str]]:
"""
diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py
index e199142fe377..047f0174a062 100644
--- a/common/djangoapps/student/auth.py
+++ b/common/djangoapps/student/auth.py
@@ -24,6 +24,7 @@
OrgInstructorRole,
OrgLibraryUserRole,
OrgStaffRole,
+ strict_role_checking,
)
# Studio permissions:
@@ -115,8 +116,9 @@ def get_user_permissions(user, course_key, org=None, service_variant=None):
return STUDIO_NO_PERMISSIONS
# Staff have all permissions except EDIT_ROLES:
- if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))):
- return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
+ with strict_role_checking():
+ if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))):
+ return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
# Otherwise, for libraries, users can view only:
if course_key and isinstance(course_key, LibraryLocator):
diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py
index c0b88e6318b5..70636e04b68a 100644
--- a/common/djangoapps/student/tests/test_authz.py
+++ b/common/djangoapps/student/tests/test_authz.py
@@ -11,6 +11,7 @@
from django.test import TestCase, override_settings
from opaque_keys.edx.locator import CourseLocator
+from common.djangoapps.student.models.user import CourseAccessRole
from common.djangoapps.student.auth import (
add_users,
has_studio_read_access,
@@ -305,6 +306,23 @@ def test_limited_staff_no_studio_access_cms(self):
assert not has_studio_read_access(self.limited_staff, self.course_key)
assert not has_studio_write_access(self.limited_staff, self.course_key)
+ @override_settings(SERVICE_VARIANT='cms')
+ def test_limited_org_staff_no_studio_access_cms(self):
+ """
+ Verifies that course limited staff have no read and no write access when SERVICE_VARIANT is not 'lms'.
+ """
+ # Add a user as course_limited_staff on the org
+ # This is not possible using the course roles classes but is possible via Django admin so we
+ # insert a row into the model directly to test that scenario.
+ CourseAccessRole.objects.create(
+ user=self.limited_staff,
+ org=self.course_key.org,
+ role=CourseLimitedStaffRole.ROLE,
+ )
+
+ assert not has_studio_read_access(self.limited_staff, self.course_key)
+ assert not has_studio_write_access(self.limited_staff, self.course_key)
+
class CourseOrgGroupTest(TestCase):
"""
diff --git a/common/djangoapps/third_party_auth/samlproviderdata/views.py b/common/djangoapps/third_party_auth/samlproviderdata/views.py
index b5d044bd0498..92d409aca8a5 100644
--- a/common/djangoapps/third_party_auth/samlproviderdata/views.py
+++ b/common/djangoapps/third_party_auth/samlproviderdata/views.py
@@ -15,6 +15,7 @@
from rest_framework.response import Response
from common.djangoapps.third_party_auth.utils import (
+ SAMLMetadataURLError,
convert_saml_slug_provider_id,
create_or_update_bulk_saml_provider_data,
fetch_metadata_xml,
@@ -121,7 +122,7 @@ def sync_provider_data(self, request):
# part 1: fetch information from remote metadata based on metadataUrl in samlproviderconfig
try:
xml = fetch_metadata_xml(metadata_url)
- except (SSLError, MissingSchema, HTTPError) as ex:
+ except (SSLError, MissingSchema, HTTPError, SAMLMetadataURLError) as ex:
msg = f'Could not verify provider metadata url. Exc type: {type(ex).__name__}'
log.warning(msg)
return Response(msg, status.HTTP_406_NOT_ACCEPTABLE)
diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py
index 5778db4b7252..20c27870b821 100644
--- a/common/djangoapps/third_party_auth/tasks.py
+++ b/common/djangoapps/third_party_auth/tasks.py
@@ -16,8 +16,10 @@
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig
from common.djangoapps.third_party_auth.utils import (
MetadataParseError,
+ SAMLMetadataURLError,
create_or_update_bulk_saml_provider_data,
parse_metadata_xml,
+ validate_saml_metadata_url,
)
log = logging.getLogger(__name__)
@@ -74,10 +76,9 @@ def fetch_saml_metadata():
failure_messages = [] # We return the length of this array for num_failed
for url, entity_ids in url_map.items():
try:
+ validate_saml_metadata_url(url)
log.info("Fetching %s", url)
- if not url.lower().startswith('https'):
- log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)
- response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError
+ response = requests.get(url, verify=True, timeout=30) # May raise HTTPError or SSLError or ConnectionError
response.raise_for_status() # May raise an HTTPError
try:
@@ -96,13 +97,15 @@ def fetch_saml_metadata():
num_updated += 1
else:
log.info(f"→ Updated existing SAMLProviderData. Nothing has changed for entityID {entity_id}")
- except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError) as error:
+ except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException,
+ MetadataParseError, SAMLMetadataURLError) as error:
# Catch and process exception in case of errors during fetching and processing saml metadata.
# Here is a description of each exception.
# SSLError is raised in case of errors caused by SSL (e.g. SSL cer verification failure etc.)
# HTTPError is raised in case of unexpected status code (e.g. 500 error etc.)
# RequestException is the base exception for any request related error that "requests" lib raises.
# MetadataParseError is raised if there is error in the fetched meta data (e.g. missing @entityID etc.)
+ # SAMLMetadataURLError is raised if the URL fails security validation.
log.exception(str(error))
failure_messages.append(
diff --git a/common/djangoapps/third_party_auth/tests/test_utils.py b/common/djangoapps/third_party_auth/tests/test_utils.py
index c1b3fd98b545..b08d7033dd09 100644
--- a/common/djangoapps/third_party_auth/tests/test_utils.py
+++ b/common/djangoapps/third_party_auth/tests/test_utils.py
@@ -6,18 +6,22 @@
from unittest.mock import MagicMock
import ddt
+import pytest
+from django.test import override_settings
from lxml import etree
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.third_party_auth.tests.testutil import TestCase
from common.djangoapps.third_party_auth.utils import (
+ SAMLMetadataURLError,
+ convert_saml_slug_provider_id,
get_associated_user_by_email_response,
get_user_from_email,
is_enterprise_customer_user,
is_oauth_provider,
parse_metadata_xml,
user_exists,
- convert_saml_slug_provider_id,
+ validate_saml_metadata_url,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.features.enterprise_support.tests.factories import (
@@ -216,3 +220,63 @@ def test_parse_metadata_with_use_attribute_missing(self):
public_keys, sso_url, _ = parse_metadata_xml(xml, entity_id)
assert public_keys == ['abc+hkIuUktxkg=']
assert sso_url == 'https://idp/SSOService.php'
+
+
+@ddt.ddt
+class TestValidateSAMLMetadataURL(TestCase):
+ """
+ Tests for validate_saml_metadata_url — the SSRF-prevention validator.
+ """
+
+ @ddt.data(
+ 'https://idp.example.com/metadata',
+ 'https://1.1.1.1/metadata',
+ )
+ def test_valid_urls_pass(self, url):
+ # Should not raise
+ validate_saml_metadata_url(url)
+
+ @ddt.data(
+ ('http://idp.example.com/metadata', 'must use HTTPS'),
+ ('ftp://idp.example.com/metadata', 'must use HTTPS'),
+ ('https://', 'no hostname'),
+ )
+ @ddt.unpack
+ def test_invalid_scheme_or_missing_hostname(self, url, match):
+ with pytest.raises(SAMLMetadataURLError, match=match):
+ validate_saml_metadata_url(url)
+
+ @ddt.data(
+ # Loopback
+ ('https://127.0.0.1/metadata', False),
+ ('https://127.0.0.1/metadata', True),
+ # Link-local (includes cloud metadata endpoints like 169.254.169.254)
+ ('https://169.254.169.254/metadata', False),
+ ('https://169.254.169.254/metadata', True),
+ )
+ @ddt.unpack
+ def test_always_blocked_regardless_of_setting(self, url, allow_private):
+ with override_settings(SAML_METADATA_URL_ALLOW_PRIVATE_IPS=allow_private):
+ with pytest.raises(SAMLMetadataURLError, match='blocked address'):
+ validate_saml_metadata_url(url)
+
+ @ddt.data(
+ 'https://10.0.0.1/metadata',
+ 'https://172.16.0.1/metadata',
+ 'https://192.168.1.1/metadata',
+ 'https://[fc00::1]/metadata',
+ )
+ @override_settings(SAML_METADATA_URL_ALLOW_PRIVATE_IPS=False)
+ def test_private_ip_blocked_by_default(self, url):
+ with pytest.raises(SAMLMetadataURLError, match='private address'):
+ validate_saml_metadata_url(url)
+
+ @ddt.data(
+ 'https://10.0.0.1/metadata',
+ 'https://172.16.0.1/metadata',
+ 'https://192.168.1.1/metadata',
+ )
+ @override_settings(SAML_METADATA_URL_ALLOW_PRIVATE_IPS=True)
+ def test_private_ip_allowed_with_setting(self, url):
+ # Should not raise when private IPs are explicitly allowed
+ validate_saml_metadata_url(url)
diff --git a/common/djangoapps/third_party_auth/utils.py b/common/djangoapps/third_party_auth/utils.py
index 720c52ea5beb..31a6c52a3161 100644
--- a/common/djangoapps/third_party_auth/utils.py
+++ b/common/djangoapps/third_party_auth/utils.py
@@ -3,12 +3,15 @@
"""
import datetime
+import ipaddress
import logging
+from urllib.parse import urlparse
from uuid import UUID
import dateutil.parser
import pytz
import requests
+from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.utils.timezone import now
from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser
@@ -36,16 +39,60 @@ class MetadataParseError(Exception):
pass # lint-amnesty, pylint: disable=unnecessary-pass
+class SAMLMetadataURLError(Exception):
+ """ The SAML metadata URL failed security validation """
+ pass # lint-amnesty, pylint: disable=unnecessary-pass
+
+
+def validate_saml_metadata_url(url):
+ """
+ Validate that a SAML metadata URL is safe to fetch.
+
+ Enforces HTTPS and blocks requests to loopback, link-local, and reserved
+ IP addresses. RFC 1918 private ranges are blocked by default but can be
+ allowed via SAML_METADATA_URL_ALLOW_PRIVATE_IPS for deployments where the
+ IdP lives on the same private network as the Open edX server.
+
+ Note: validation is IP-based and only applies when the URL contains a
+ literal IP address. Hostname-based URLs are not resolved here — operators
+ should enforce network-level egress filtering (e.g. firewall rules or a
+ dedicated egress proxy) as a complementary control to guard against
+ DNS-based bypasses.
+
+ Raises SAMLMetadataURLError if the URL fails any check.
+ """
+ parsed = urlparse(url)
+ if parsed.scheme != 'https':
+ raise SAMLMetadataURLError(f"SAML metadata URL must use HTTPS, got: {parsed.scheme!r}")
+ if not parsed.hostname:
+ raise SAMLMetadataURLError("SAML metadata URL has no hostname")
+
+ try:
+ addr = ipaddress.ip_address(parsed.hostname)
+ except ValueError:
+ # Not a literal IP — hostname-based, allow it through
+ return
+
+ if addr.is_loopback or addr.is_link_local or addr.is_reserved:
+ raise SAMLMetadataURLError(f"SAML metadata URL resolves to a blocked address: {addr}")
+
+ allow_private = getattr(settings, 'SAML_METADATA_URL_ALLOW_PRIVATE_IPS', False)
+ if not allow_private and addr.is_private:
+ raise SAMLMetadataURLError(
+ f"SAML metadata URL resolves to a private address: {addr}. "
+ "Set SAML_METADATA_URL_ALLOW_PRIVATE_IPS=True to allow this."
+ )
+
+
def fetch_metadata_xml(url):
"""
Fetches IDP metadata from provider url
Returns: xml document
"""
+ validate_saml_metadata_url(url)
try:
log.info("Fetching %s", url)
- if not url.lower().startswith('https'):
- log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)
- response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError
+ response = requests.get(url, verify=True, timeout=30) # May raise HTTPError or SSLError or ConnectionError
response.raise_for_status() # May raise an HTTPError
try:
@@ -55,13 +102,15 @@ def fetch_metadata_xml(url):
raise
# TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that
return xml
- except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError) as error:
+ except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException,
+ MetadataParseError, SAMLMetadataURLError) as error:
# Catch and process exception in case of errors during fetching and processing saml metadata.
# Here is a description of each exception.
# SSLError is raised in case of errors caused by SSL (e.g. SSL cer verification failure etc.)
# HTTPError is raised in case of unexpected status code (e.g. 500 error etc.)
# RequestException is the base exception for any request related error that "requests" lib raises.
# MetadataParseError is raised if there is error in the fetched meta data (e.g. missing @entityID etc.)
+ # SAMLMetadataURLError is raised if the URL fails security validation.
log.exception(str(error), exc_info=error)
raise error
except etree.XMLSyntaxError as error:
diff --git a/lms/djangoapps/course_blocks/transformers/library_content.py b/lms/djangoapps/course_blocks/transformers/library_content.py
index 10ef8c2138b6..3252738e1372 100644
--- a/lms/djangoapps/course_blocks/transformers/library_content.py
+++ b/lms/djangoapps/course_blocks/transformers/library_content.py
@@ -1,5 +1,8 @@
"""
-Content Library Transformer.
+Item Bank Transformer.
+
+Transformers for handling item bank blocks (library_content, itembank, etc.)
+that use ItemBankMixin for randomized content selection.
"""
@@ -7,6 +10,7 @@
import logging
from eventtracking import tracker
+from xblock.core import XBlock
from common.djangoapps.track import contexts
from lms.djangoapps.courseware.models import StudentModule
@@ -14,7 +18,7 @@
BlockStructureTransformer,
FilteringTransformerMixin
)
-from xmodule.library_content_block import LegacyLibraryContentBlock # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.item_bank_block import ItemBankMixin # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from ..utils import get_student_module_as_dict
@@ -25,10 +29,13 @@
class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransformer):
"""
A transformer that manipulates the block structure by removing all
- blocks within a library_content block to which a user should not
- have access.
+ blocks within item bank blocks (library_content, itembank, etc.)
+ to which a user should not have access.
+
+ This transformer works with any XBlock that inherits from ItemBankMixin,
+ filtering children based on the selection logic defined by each block type.
- Staff users are not to be exempted from library content pathways.
+ Staff users are not to be exempted from item bank pathways.
"""
WRITE_VERSION = 1
READ_VERSION = 1
@@ -61,10 +68,10 @@ def summarize_block(usage_key):
"original_usage_version": str(orig_version) if orig_version else None,
}
- # For each block check if block is library_content.
- # If library_content add children array to content_library_children field
+ # For each block check if block uses ItemBankMixin (e.g., library_content, itembank).
+ # If so add block analytics summary for each of its children.
for block_key in block_structure.topological_traversal(
- filter_func=lambda block_key: block_key.block_type == 'library_content',
+ filter_func=lambda block_key: issubclass(XBlock.load_class(block_key.block_type), ItemBankMixin),
yield_descendants_of_unyielded=True,
):
xblock = block_structure.get_xblock(block_key)
@@ -76,7 +83,9 @@ def transform_block_filters(self, usage_info, block_structure):
all_library_children = set()
all_selected_children = set()
for block_key in block_structure:
- if block_key.block_type != 'library_content':
+ block_class = XBlock.load_class(block_key.block_type)
+
+ if block_class is None or not issubclass(block_class, ItemBankMixin):
continue
library_children = block_structure.get_children(block_key)
if library_children:
@@ -98,7 +107,12 @@ def transform_block_filters(self, usage_info, block_structure):
# Update selected
previous_count = len(selected)
- block_keys = LegacyLibraryContentBlock.make_selection(selected, library_children, max_count)
+ # Get the cached block class to call make_selection
+ block_class = XBlock.load_class(block_key.block_type)
+ if block_class is None:
+ logger.error('Failed to load block class for %s', block_key)
+ continue
+ block_keys = block_class.make_selection(selected, library_children, max_count)
selected = block_keys['selected']
# Save back any changes
@@ -128,7 +142,7 @@ def check_child_removal(block_key):
"""
Return True if selected block should be removed.
- Block is removed if it is part of library_content, but has
+ Block is removed if it is a child of an item bank block, but has
not been selected for current user.
"""
if block_key not in all_library_children:
@@ -156,6 +170,12 @@ def format_block_keys(keys):
json_result.append(info)
return json_result
+ # Get the cached block class to call publish_selected_children_events
+ block_class = XBlock.load_class(location.block_type)
+ if block_class is None:
+ logger.error('Failed to load block class for publishing events: %s', location)
+ return
+
def publish_event(event_name, result, **kwargs):
"""
Helper function to publish an event for analytics purposes
@@ -170,11 +190,12 @@ def publish_event(event_name, result, **kwargs):
context = contexts.course_context_from_course_id(location.course_key)
if user_id:
context['user_id'] = user_id
- full_event_name = f"edx.librarycontentblock.content.{event_name}"
+ event_prefix = block_class.get_selected_event_prefix()
+ full_event_name = f"{event_prefix}.{event_name}"
with tracker.get_tracker().context(full_event_name, context):
tracker.emit(full_event_name, event_data)
- LegacyLibraryContentBlock.publish_selected_children_events(
+ block_class.publish_selected_children_events(
block_keys,
format_block_keys,
publish_event,
@@ -184,12 +205,14 @@ def publish_event(event_name, result, **kwargs):
class ContentLibraryOrderTransformer(BlockStructureTransformer):
"""
A transformer that manipulates the block structure by modifying the order of the
- selected blocks within a library_content block to match the order of the selections
- made by the ContentLibraryTransformer or the corresponding XBlock. So this transformer
- requires the selections for the randomized content block to be already
- made either by the ContentLibraryTransformer or the XBlock.
+ selected blocks within item bank blocks (library_content, itembank, etc.)
+ to match the order of the selections made by the ContentLibraryTransformer or the
+ corresponding XBlock. This transformer requires the selections for the item bank block
+ to be already made either by the ContentLibraryTransformer or the XBlock.
+
+ This transformer works with any XBlock that inherits from ItemBankMixin.
- Staff users are *not* exempted from library content pathways.
+ Staff users are *not* exempted from item bank pathways.
"""
WRITE_VERSION = 1
READ_VERSION = 1
@@ -217,7 +240,8 @@ def transform(self, usage_info, block_structure):
to match the order of the selections made and stored in the XBlock 'selected' field.
"""
for block_key in block_structure:
- if block_key.block_type != 'library_content':
+ block_class = XBlock.load_class(block_key.block_type)
+ if block_class is None or not issubclass(block_class, ItemBankMixin):
continue
library_children = block_structure.get_children(block_key)
@@ -228,7 +252,7 @@ def transform(self, usage_info, block_structure):
current_selected_blocks = {item[1] for item in state_dict.get('selected', [])}
# As the selections should have already been made by the ContentLibraryTransformer,
- # the current children of the library_content block should be the same as the stored
+ # the current children of the item bank block should be the same as the stored
# selections. If they aren't, some other transformer that ran before this transformer
# has modified those blocks (for example, content gating may have affected this). So do not
# transform the order in that case.
diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py
index 5a4d7a0de11a..37cf7edd5658 100644
--- a/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py
+++ b/lms/djangoapps/course_blocks/transformers/tests/test_library_content.py
@@ -4,11 +4,13 @@
from unittest import mock
+from ddt import data, ddt
+
+import openedx.core.djangoapps.content.block_structure.api as bs_api
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory
from openedx.core.djangoapps.content.block_structure.api import clear_course_from_cache
from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers
-import openedx.core.djangoapps.content.block_structure.api as bs_api
from ...api import get_course_blocks
from ..library_content import ContentLibraryOrderTransformer, ContentLibraryTransformer
from .helpers import CourseStructureTestCase
@@ -26,6 +28,7 @@ def __init__(self, state):
self.state = state
+@ddt
class ContentLibraryTransformerTestCase(CourseStructureTestCase):
"""
ContentLibraryTransformer Test
@@ -37,9 +40,14 @@ def setUp(self):
Setup course structure and create user for content library transformer test.
"""
super().setUp()
+ self._initialize_course_hierarchy()
+ def _initialize_course_hierarchy(self, block_type='library_content'):
+ """
+ Initialize course hierarchy with the given block type.
+ """
# Build course.
- self.course_hierarchy = self.get_course_hierarchy()
+ self.course_hierarchy = self.get_course_hierarchy(block_type)
self.blocks = self.build_course(self.course_hierarchy)
self.course = self.blocks['course']
# Do this manually because publish signals are not fired by default in tests.
@@ -49,14 +57,14 @@ def setUp(self):
# Enroll user in course.
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
- def get_course_hierarchy(self):
+ def get_course_hierarchy(self, block_type='library_content'):
"""
Get a course hierarchy to test with.
"""
return [{
'org': 'ContentLibraryTransformer',
'course': 'CL101F',
- 'run': 'test_run',
+ 'run': f'test_run_{block_type}',
'#type': 'course',
'#ref': 'course',
'#children': [
@@ -73,8 +81,8 @@ def get_course_hierarchy(self):
'#ref': 'vertical1',
'#children': [
{
- '#type': 'library_content',
- '#ref': 'library_content1',
+ '#type': block_type,
+ '#ref': f'{block_type}1',
'#children': [
{
'metadata': {'display_name': "CL Vertical 2"},
@@ -111,13 +119,18 @@ def get_course_hierarchy(self):
]
}]
- def test_content_library(self):
+ @data('library_content', 'itembank')
+ def test_content_library(self, block_type):
"""
Test when course has content library section.
First test user can't see any content library section,
and after that mock response from MySQL db.
Check user can see mocked sections in content library.
"""
+ # Re-initialize if testing with a different block type
+ if block_type != 'library_content':
+ self._initialize_course_hierarchy(block_type)
+
raw_block_structure = get_course_blocks(
self.user,
self.course.location,
@@ -136,7 +149,7 @@ def test_content_library(self):
# Should dynamically assign a block to student
trans_keys = set(trans_block_structure.get_block_keys())
block_key_set = self.get_block_key_set(
- self.blocks, 'course', 'chapter1', 'lesson1', 'vertical1', 'library_content1'
+ self.blocks, 'course', 'chapter1', 'lesson1', 'vertical1', f'{block_type}1'
)
for key in block_key_set:
assert key in trans_keys
@@ -160,11 +173,12 @@ def test_content_library(self):
assert set(trans_block_structure.get_block_keys()) == self.get_block_key_set(self.blocks, 'course',
'chapter1', 'lesson1',
'vertical1',
- 'library_content1',
+ f'{block_type}1',
selected_vertical,
selected_child), f"Expected 'selected' equality failed in iteration {i}." # pylint: disable=line-too-long
+@ddt
class ContentLibraryOrderTransformerTestCase(CourseStructureTestCase):
"""
ContentLibraryOrderTransformer Test
@@ -176,7 +190,13 @@ def setUp(self):
Setup course structure and create user for content library order transformer test.
"""
super().setUp()
- self.course_hierarchy = self.get_course_hierarchy()
+ self._initialize_course_hierarchy()
+
+ def _initialize_course_hierarchy(self, block_type='library_content'):
+ """
+ Initialize course hierarchy with the given block type.
+ """
+ self.course_hierarchy = self.get_course_hierarchy(block_type)
self.blocks = self.build_course(self.course_hierarchy)
self.course = self.blocks['course']
bs_api.update_course_in_cache(self.course.id)
@@ -185,14 +205,14 @@ def setUp(self):
# Enroll user in course.
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
- def get_course_hierarchy(self):
+ def get_course_hierarchy(self, block_type='library_content'):
"""
Get a course hierarchy to test with.
"""
return [{
'org': 'ContentLibraryTransformer',
'course': 'CL101F',
- 'run': 'test_run',
+ 'run': f'test_run_{block_type}',
'#type': 'course',
'#ref': 'course',
'#children': [
@@ -209,8 +229,8 @@ def get_course_hierarchy(self):
'#ref': 'vertical1',
'#children': [
{
- '#type': 'library_content',
- '#ref': 'library_content1',
+ '#type': block_type,
+ '#ref': f'{block_type}1',
'#children': [
{
'metadata': {'display_name': "CL Vertical 2"},
@@ -260,11 +280,15 @@ def get_course_hierarchy(self):
}]
@mock.patch('lms.djangoapps.course_blocks.transformers.library_content.get_student_module_as_dict')
- def test_content_library_randomize(self, mocked):
+ @data('library_content', 'itembank')
+ def test_content_library_randomize(self, block_type, mocked):
"""
Test whether the order of the children blocks matches the order of the selected blocks when
course has content library section
"""
+ # Re-initialize if testing with a different block type
+ if block_type != 'library_content':
+ self._initialize_course_hierarchy(block_type)
mocked.return_value = {
'selected': [
['vertical', 'vertical_vertical3'],
@@ -280,7 +304,7 @@ def test_content_library_randomize(self, mocked):
)
children = []
for block_key in trans_block_structure.topological_traversal():
- if block_key.block_type == 'library_content':
+ if block_key.block_type == block_type:
children = trans_block_structure.get_children(block_key)
break
diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py
index 7bc84e5038c0..1f5ae7805740 100644
--- a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py
+++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py
@@ -180,7 +180,8 @@ def test_flag(self):
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send"
) as signal_mock:
- response = self.call_view("flag_abuse_for_thread", "update_thread_flag")
+ with self.captureOnCommitCallbacks(execute=True):
+ response = self.call_view("flag_abuse_for_thread", "update_thread_flag")
self._assert_json_response_contains_group_info(response)
self.assertEqual(signal_mock.call_count, 1)
response = self.call_view("un_flag_abuse_for_thread", "update_thread_flag")
@@ -471,10 +472,15 @@ def setUp(self):
def assert_discussion_signals(self, signal, user=None):
if user is None:
user = self.student
+ # Use captureOnCommitCallbacks to execute on_commit callbacks during tests,
+ # since signals are now deferred until after transaction commit.
+ # Order matters: assert_signal_sent must be outer context so the signal
+ # fires (via captureOnCommitCallbacks) before the assertion check.
with self.assert_signal_sent(
views, signal, sender=None, user=user, exclude_args=("post",)
):
- yield
+ with self.captureOnCommitCallbacks(execute=True):
+ yield
def test_create_thread(self):
with self.assert_discussion_signals("thread_created"):
@@ -1218,7 +1224,8 @@ def test_flag(self):
with mock.patch(
"openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send"
) as signal_mock:
- self.call_view("flag_abuse_for_comment", "update_comment_flag")
+ with self.captureOnCommitCallbacks(execute=True):
+ self.call_view("flag_abuse_for_comment", "update_comment_flag")
self.assertEqual(signal_mock.call_count, 1)
diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py
index 95d5a020108f..14ce9c4b575a 100644
--- a/lms/djangoapps/discussion/django_comment_client/base/views.py
+++ b/lms/djangoapps/discussion/django_comment_client/base/views.py
@@ -50,6 +50,7 @@
prepare_content,
sanitize_body
)
+from lms.djangoapps.discussion.rest_api.utils import send_signal_after_commit
from openedx.core.djangoapps.django_comment_common.signals import (
comment_created,
comment_deleted,
@@ -587,7 +588,10 @@ def create_thread(request, course_id, commentable_id):
thread.save()
- thread_created.send(sender=None, user=user, post=thread)
+ # Use send_signal_after_commit() to ensure the signal is sent only after the transaction commits.
+ send_signal_after_commit(
+ lambda: thread_created.send(sender=None, user=user, post=thread)
+ )
# patch for backward compatibility to comments service
if 'pinned' not in thread.attributes:
@@ -598,7 +602,9 @@ def create_thread(request, course_id, commentable_id):
if follow:
cc_user = cc.User.from_django_user(user)
cc_user.follow(thread, course_id)
- thread_followed.send(sender=None, user=user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_followed.send(sender=None, user=user, post=thread)
+ )
data = thread.to_dict()
@@ -645,7 +651,9 @@ def update_thread(request, course_id, thread_id):
thread.save()
- thread_edited.send(sender=None, user=user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_edited.send(sender=None, user=user, post=thread)
+ )
track_thread_edited_event(request, course, thread, None)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
@@ -688,7 +696,9 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None):
)
comment.save(params={"course_id": str(course_key)})
- comment_created.send(sender=None, user=user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_created.send(sender=None, user=user, post=comment)
+ )
followed = post.get('auto_subscribe', 'false').lower() == 'true'
@@ -729,7 +739,9 @@ def delete_thread(request, course_id, thread_id):
course = get_course_with_access(request.user, 'load', course_key)
thread = cc.Thread.find(thread_id)
thread.delete(course_id=course_id)
- thread_deleted.send(sender=None, user=request.user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_deleted.send(sender=None, user=request.user, post=thread)
+ )
track_thread_deleted_event(request, course, thread)
return JsonResponse(prepare_content(thread.to_dict(), course_key))
@@ -751,7 +763,9 @@ def update_comment(request, course_id, comment_id):
comment.body = sanitize_body(request.POST["body"])
comment.save(params={"course_id": course_id})
- comment_edited.send(sender=None, user=request.user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_edited.send(sender=None, user=request.user, post=comment)
+ )
track_comment_edited_event(request, course, comment, None)
if request.headers.get('x-requested-with') == 'XMLHttpRequest':
@@ -776,7 +790,9 @@ def endorse_comment(request, course_id, comment_id):
comment.endorsed = endorsed
comment.endorsement_user_id = user.id
comment.save(params={"course_id": course_id})
- comment_endorsed.send(sender=None, user=user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_endorsed.send(sender=None, user=user, post=comment)
+ )
track_forum_response_mark_event(request, course, comment, endorsed)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -828,7 +844,9 @@ def delete_comment(request, course_id, comment_id):
course = get_course_with_access(request.user, 'load', course_key)
comment = cc.Comment.find(comment_id)
comment.delete(course_id=course_id)
- comment_deleted.send(sender=None, user=request.user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_deleted.send(sender=None, user=request.user, post=comment)
+ )
track_comment_deleted_event(request, course, comment)
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -847,7 +865,9 @@ def _vote_or_unvote(request, course_id, obj, value='up', undo_vote=False):
# (People could theoretically downvote by handcrafting AJAX requests.)
else:
user.vote(obj, value, course_id)
- thread_voted.send(sender=None, user=request.user, post=obj)
+ send_signal_after_commit(
+ lambda: thread_voted.send(sender=None, user=request.user, post=obj)
+ )
track_voted_event(request, course, obj, value, undo_vote)
return JsonResponse(prepare_content(obj.to_dict(), course_key))
@@ -861,7 +881,9 @@ def vote_for_comment(request, course_id, comment_id, value):
"""
comment = cc.Comment.find(comment_id)
result = _vote_or_unvote(request, course_id, comment, value)
- comment_voted.send(sender=None, user=request.user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_voted.send(sender=None, user=request.user, post=comment)
+ )
return result
@@ -914,7 +936,9 @@ def flag_abuse_for_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
thread.flagAbuse(user, thread, course_id)
track_discussion_reported_event(request, course, thread)
- thread_flagged.send(sender='flag_abuse_for_thread', user=request.user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_flagged.send(sender='flag_abuse_for_thread', user=request.user, post=thread)
+ )
return JsonResponse(prepare_content(thread.to_dict(), course_key))
@@ -953,7 +977,9 @@ def flag_abuse_for_comment(request, course_id, comment_id):
comment = cc.Comment.find(comment_id)
comment.flagAbuse(user, comment, course_id)
track_discussion_reported_event(request, course, comment)
- comment_flagged.send(sender='flag_abuse_for_comment', user=request.user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_flagged.send(sender='flag_abuse_for_comment', user=request.user, post=comment)
+ )
return JsonResponse(prepare_content(comment.to_dict(), course_key))
@@ -1019,7 +1045,9 @@ def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disab
course = get_course_by_id(course_key)
thread = cc.Thread.find(thread_id)
user.follow(thread, course_id=course_id)
- thread_followed.send(sender=None, user=request.user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_followed.send(sender=None, user=request.user, post=thread)
+ )
track_thread_followed_event(request, course, thread, True)
return JsonResponse({})
@@ -1051,7 +1079,9 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unfollow(thread, course_id=course_id)
- thread_unfollowed.send(sender=None, user=request.user, post=thread)
+ send_signal_after_commit(
+ lambda: thread_unfollowed.send(sender=None, user=request.user, post=thread)
+ )
track_thread_followed_event(request, course, thread, False)
return JsonResponse({})
diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py
index b87852c16cfa..443f9527acda 100644
--- a/lms/djangoapps/discussion/rest_api/api.py
+++ b/lms/djangoapps/discussion/rest_api/api.py
@@ -128,6 +128,7 @@
discussion_open_for_user,
get_usernames_for_course,
get_usernames_from_search_string,
+ send_signal_after_commit,
set_attribute,
is_posting_allowed,
can_user_notify_all_learners, is_captcha_enabled, get_captcha_site_key_by_platform
@@ -1017,7 +1018,6 @@ def get_thread_list(
"group_id": group_id,
"page": page,
"per_page": page_size,
- "text": text_search,
"sort_key": cc_map.get(order_by),
"author_id": author_id,
"flagged": flagged,
@@ -1382,7 +1382,9 @@ def _handle_following_field(form_value, user, cc_content, request):
else:
user.unfollow(cc_content)
signal = thread_followed if form_value else thread_unfollowed
- signal.send(sender=None, user=user, post=cc_content)
+ send_signal_after_commit(
+ lambda: signal.send(sender=None, user=user, post=cc_content)
+ )
track_thread_followed_event(request, course, cc_content, form_value)
@@ -1395,9 +1397,13 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request):
track_discussion_reported_event(request, course, cc_content)
if ENABLE_DISCUSSIONS_MFE.is_enabled(course_key):
if cc_content.type == 'thread':
- thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content)
+ send_signal_after_commit(
+ lambda: thread_flagged.send(sender='flag_abuse_for_thread', user=user, post=cc_content)
+ )
else:
- comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content)
+ send_signal_after_commit(
+ lambda: comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content)
+ )
else:
remove_all = bool(is_privileged_user(course_key, User.objects.get(id=user.id)))
cc_content.unFlagAbuse(user, cc_content, remove_all)
@@ -1407,7 +1413,9 @@ def _handle_abuse_flagged_field(form_value, user, cc_content, request):
def _handle_voted_field(form_value, cc_content, api_content, request, context):
"""vote or undo vote on thread/comment"""
signal = thread_voted if cc_content.type == 'thread' else comment_voted
- signal.send(sender=None, user=context["request"].user, post=cc_content)
+ send_signal_after_commit(
+ lambda: signal.send(sender=None, user=context["request"].user, post=cc_content)
+ )
if form_value:
context["cc_requester"].vote(cc_content, "up")
api_content["vote_count"] += 1
@@ -1452,7 +1460,9 @@ def _handle_comment_signals(update_data, comment, user, sender=None):
"""
for key, value in update_data.items():
if key == "endorsed" and value is True:
- comment_endorsed.send(sender=sender, user=user, post=comment)
+ send_signal_after_commit(
+ lambda: comment_endorsed.send(sender=sender, user=user, post=comment)
+ )
def create_thread(request, thread_data):
@@ -1502,7 +1512,10 @@ def create_thread(request, thread_data):
raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items())))
serializer.save()
cc_thread = serializer.instance
- thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
+ # Use send_signal_after_commit() to ensure the signal is sent only after the transaction commits.
+ send_signal_after_commit(
+ lambda: thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
+ )
api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request)
@@ -1550,7 +1563,9 @@ def create_comment(request, comment_data):
context["cc_requester"].follow(cc_thread)
serializer.save()
cc_comment = serializer.instance
- comment_created.send(sender=None, user=request.user, post=cc_comment)
+ send_signal_after_commit(
+ lambda: comment_created.send(sender=None, user=request.user, post=cc_comment)
+ )
api_comment = serializer.data
_do_extra_actions(api_comment, cc_comment, list(comment_data.keys()), actions_form, context, request)
track_comment_created_event(request, course, cc_comment, cc_thread["commentable_id"], followed=False,
@@ -1586,7 +1601,9 @@ def update_thread(request, thread_id, update_data):
if set(update_data) - set(actions_form.fields):
serializer.save()
# signal to update Teams when a user edits a thread
- thread_edited.send(sender=None, user=request.user, post=cc_thread)
+ send_signal_after_commit(
+ lambda: thread_edited.send(sender=None, user=request.user, post=cc_thread)
+ )
api_thread = serializer.data
_do_extra_actions(api_thread, cc_thread, list(update_data.keys()), actions_form, context, request)
@@ -1635,7 +1652,9 @@ def update_comment(request, comment_id, update_data):
# Only save comment object if some of the edited fields are in the comment data, not extra actions
if set(update_data) - set(actions_form.fields):
serializer.save()
- comment_edited.send(sender=None, user=request.user, post=cc_comment)
+ send_signal_after_commit(
+ lambda: comment_edited.send(sender=None, user=request.user, post=cc_comment)
+ )
api_comment = serializer.data
_do_extra_actions(api_comment, cc_comment, list(update_data.keys()), actions_form, context, request)
_handle_comment_signals(update_data, cc_comment, request.user)
@@ -1823,7 +1842,9 @@ def delete_thread(request, thread_id):
cc_thread, context = _get_thread_and_context(request, thread_id)
if can_delete(cc_thread, context):
cc_thread.delete()
- thread_deleted.send(sender=None, user=request.user, post=cc_thread)
+ send_signal_after_commit(
+ lambda: thread_deleted.send(sender=None, user=request.user, post=cc_thread)
+ )
track_thread_deleted_event(request, context["course"], cc_thread)
else:
raise PermissionDenied
@@ -1848,7 +1869,9 @@ def delete_comment(request, comment_id):
cc_comment, context = _get_comment_and_context(request, comment_id)
if can_delete(cc_comment, context):
cc_comment.delete()
- comment_deleted.send(sender=None, user=request.user, post=cc_comment)
+ send_signal_after_commit(
+ lambda: comment_deleted.send(sender=None, user=request.user, post=cc_comment)
+ )
track_comment_deleted_event(request, context["course"], cc_comment)
else:
raise PermissionDenied
diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
index 8efb2e8acb83..531090732781 100644
--- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -452,6 +452,14 @@ def clean_thread_html_body(html_body):
truncated_body = html.unescape(truncated_body)
html_body = BeautifulSoup(truncated_body, 'html.parser')
+ # Remove tags including their content (decompose, not unwrap)
+ tags_to_decompose = [
+ "style", # CSS injection
+ ]
+ for tag in tags_to_decompose:
+ for match in html_body.find_all(tag):
+ match.decompose()
+
tags_to_remove = [
"a", "link", # Link Tags
"img", "picture", "source", # Image Tags
diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py
index cfcea5b32834..a1ae60c35ef3 100644
--- a/lms/djangoapps/discussion/rest_api/permissions.py
+++ b/lms/djangoapps/discussion/rest_api/permissions.py
@@ -6,7 +6,7 @@
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions
-from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
+from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import (
CourseInstructorRole,
CourseStaffRole,
@@ -19,7 +19,7 @@
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
from openedx.core.djangoapps.django_comment_common.models import (
- Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR
+ FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR
)
@@ -194,26 +194,7 @@ def can_take_action_on_spam(user, course_id):
user: User object
course_id: CourseKey or string of course_id
"""
- if GlobalStaff().has_user(user):
- return True
-
- if isinstance(course_id, str):
- course_id = CourseKey.from_string(course_id)
- org_id = course_id.org
- course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True)
- course_ids = [c_id for c_id in course_ids if c_id.org == org_id]
- user_roles = set(
- Role.objects.filter(
- users=user,
- course_id__in=course_ids,
- ).values_list('name', flat=True).distinct()
- )
- if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}):
- return True
-
- if CourseAccessRole.objects.filter(user=user, course_id__in=course_ids, role__in=["instructor", "staff"]).exists():
- return True
- return False
+ return GlobalStaff().has_user(user)
class IsAllowedToBulkDelete(permissions.BasePermission):
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py
index 900d52017c5e..df4ac947bf0d 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py
@@ -273,7 +273,8 @@ def test_basic(self, mock_emit):
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
- actual = create_thread(self.request, self.minimal_data)
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data(
{
"id": "test_id",
@@ -352,7 +353,8 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit):
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
- actual = create_thread(self.request, self.minimal_data)
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = create_thread(self.request, self.minimal_data)
expected = self.expected_thread_data(
{
"author_label": "Moderator",
@@ -428,7 +430,8 @@ def test_title_truncation(self, mock_emit):
with self.assert_signal_sent(
api, "thread_created", sender=None, user=self.user, exclude_args=("post", "notify_all_learners")
):
- create_thread(self.request, data)
+ with self.captureOnCommitCallbacks(execute=True):
+ create_thread(self.request, data)
event_name, event_data = mock_emit.call_args[0]
assert event_name == "edx.forum.thread.created"
assert event_data == {
@@ -678,7 +681,8 @@ def test_success(self, parent_id, mock_emit):
with self.assert_signal_sent(
api, "comment_created", sender=None, user=self.user, exclude_args=("post",)
):
- actual = create_comment(self.request, data)
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
@@ -785,7 +789,8 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit):
with self.assert_signal_sent(
api, "comment_created", sender=None, user=self.user, exclude_args=("post",)
):
- actual = create_comment(self.request, data)
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = create_comment(self.request, data)
expected = {
"id": "test_comment",
"thread_id": "test_thread",
@@ -1118,9 +1123,10 @@ def test_basic(self):
with self.assert_signal_sent(
api, "thread_edited", sender=None, user=self.user, exclude_args=("post",)
):
- actual = update_thread(
- self.request, "test_thread", {"raw_body": "Edited body"}
- )
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = update_thread(
+ self.request, "test_thread", {"raw_body": "Edited body"}
+ )
assert actual == self.expected_thread_data(
{
@@ -1436,13 +1442,13 @@ def test_following(self, old_following, new_following, mock_emit):
self.register_thread()
data = {"following": new_following}
signal_name = "thread_followed" if new_following else "thread_unfollowed"
- mock_path = (
- f"openedx.core.djangoapps.django_comment_common.signals.{signal_name}.send"
- )
+ # Patch at the api module level where the signal is imported and used
+ mock_path = f"lms.djangoapps.discussion.rest_api.api.{signal_name}"
with mock.patch(mock_path) as signal_patch:
- result = update_thread(self.request, "test_thread", data)
+ with self.captureOnCommitCallbacks(execute=True):
+ result = update_thread(self.request, "test_thread", data)
if old_following != new_following:
- self.assertEqual(signal_patch.call_count, 1)
+ self.assertEqual(signal_patch.send.call_count, 1)
assert result["following"] == new_following
if old_following == new_following:
@@ -1782,9 +1788,10 @@ def test_basic(self, parent_id):
with self.assert_signal_sent(
api, "comment_edited", sender=None, user=self.user, exclude_args=("post",)
):
- actual = update_comment(
- self.request, "test_comment", {"raw_body": "Edited body"}
- )
+ with self.captureOnCommitCallbacks(execute=True):
+ actual = update_comment(
+ self.request, "test_comment", {"raw_body": "Edited body"}
+ )
expected = {
"anonymous": False,
"anonymous_to_peers": False,
@@ -2207,7 +2214,7 @@ def test_raw_body_access(self, role_name, is_thread_author, is_comment_author):
)
@ddt.unpack
@mock.patch(
- "openedx.core.djangoapps.django_comment_common.signals.comment_endorsed.send"
+ "lms.djangoapps.discussion.rest_api.api.comment_endorsed.send"
)
def test_endorsed_access(
self, role_name, is_thread_author, thread_type, is_comment_author, endorsed_mock
@@ -2226,7 +2233,8 @@ def test_endorsed_access(
thread_type == "discussion" or not is_thread_author
)
try:
- update_comment(self.request, "test_comment", {"endorsed": True})
+ with self.captureOnCommitCallbacks(execute=True):
+ update_comment(self.request, "test_comment", {"endorsed": True})
self.assertEqual(endorsed_mock.call_count, 1)
assert not expected_error
except ValidationError as err:
@@ -2354,7 +2362,8 @@ def test_basic(self, mock_emit):
with self.assert_signal_sent(
api, "thread_deleted", sender=None, user=self.user, exclude_args=("post",)
):
- assert delete_thread(self.request, self.thread_id) is None
+ with self.captureOnCommitCallbacks(execute=True):
+ assert delete_thread(self.request, self.thread_id) is None
self.check_mock_called("delete_thread")
params = {
"thread_id": self.thread_id,
@@ -2540,7 +2549,8 @@ def test_basic(self, mock_emit):
with self.assert_signal_sent(
api, "comment_deleted", sender=None, user=self.user, exclude_args=("post",)
):
- assert delete_comment(self.request, self.comment_id) is None
+ with self.captureOnCommitCallbacks(execute=True):
+ assert delete_comment(self.request, self.comment_id) is None
self.check_mock_called("delete_comment")
params = {
"comment_id": self.comment_id,
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
index aaa920a0ff78..5b2bc471928e 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
@@ -214,4 +214,17 @@ def test_strip_empty_tags(self):
"""
html_body = ''
result = clean_thread_html_body(html_body)
- self.assertEqual(result, 'content
')
+ self.assertEqual(result, 'content
') # noqa: PT009
+
+ def test_style_tag_removed_with_content(self):
+ """
+ Test that style tags and their CSS content are fully removed (CSS injection prevention).
+ """
+ html_body = (
+ 'Hello
'
+ ''
+ )
+ result = clean_thread_html_body(html_body)
+ self.assertNotIn('