Skip to content

Commit d49e237

Browse files
committed
feat: allow block publish if was published before Library v2 migration
1 parent daa2d28 commit d49e237

5 files changed

Lines changed: 293 additions & 3 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Management command to migrate legacy library content blocks to Item Bank blocks for course(s).
3+
4+
This command can be run for a specific list of courses or for all courses.
5+
"""
6+
from __future__ import annotations
7+
8+
import logging
9+
10+
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
11+
from django.core.management.base import BaseCommand, CommandError
12+
from opaque_keys import InvalidKeyError
13+
from opaque_keys.edx.keys import CourseKey
14+
15+
from cms.djangoapps.contentstore.tasks import migrate_course_legacy_library_blocks_to_item_bank
16+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
17+
from xmodule.modulestore.django import modulestore # pylint: disable=wrong-import-order
18+
19+
log = logging.getLogger(__name__)
20+
21+
22+
class Command(BaseCommand):
23+
"""
24+
Migrate legacy library content blocks to Item Bank blocks for course(s).
25+
26+
Examples:
27+
# Migrate specific courses.
28+
$ ./manage.py cms migrate_course_legacy_library_blocks_to_item_bank \
29+
--course-ids course-v1:edX+DemoX+2024,course-v1:edX+Demo2+2024 --user-id 3
30+
31+
# Migrate all courses.
32+
$ ./manage.py cms migrate_course_legacy_library_blocks_to_item_bank --all-courses --user-id 3
33+
34+
# Migrate all courses, re-publishing blocks that were published before the migration.
35+
$ ./manage.py cms migrate_course_legacy_library_blocks_to_item_bank --all-courses --user-id 3 \
36+
--persist-publish-state
37+
"""
38+
39+
def add_arguments(self, parser):
40+
parser.add_argument(
41+
'--course-ids',
42+
help='Comma-separated list of course keys to migrate.',
43+
)
44+
parser.add_argument(
45+
'--all-courses',
46+
action='store_true',
47+
help='Migrate legacy library content blocks for all courses.',
48+
)
49+
parser.add_argument(
50+
'--user-id',
51+
type=int,
52+
required=True,
53+
help='ID of the user performing the migration.',
54+
)
55+
parser.add_argument(
56+
'--persist-publish-state',
57+
action='store_true',
58+
help='Re-publish blocks that were published before the migration. Defaults to False.',
59+
)
60+
61+
def handle(self, *args, **options):
62+
course_ids = options['course_ids']
63+
all_courses = options['all_courses']
64+
user_id = options['user_id']
65+
persist_publish_state = options['persist_publish_state']
66+
67+
if not course_ids and not all_courses:
68+
raise CommandError('Either --course-ids or --all-courses argument should be provided.')
69+
if course_ids and all_courses:
70+
raise CommandError('Only one of --course-ids or --all-courses argument should be provided.')
71+
72+
try:
73+
User.objects.get(id=user_id)
74+
except User.DoesNotExist:
75+
raise CommandError(f'No user found with id: {user_id}') # pylint: disable=raise-missing-from # noqa: B904
76+
77+
if all_courses:
78+
raw_course_ids = CourseOverview.get_all_course_keys()
79+
else:
80+
raw_course_ids = [course_id.strip() for course_id in course_ids.split(',') if course_id.strip()]
81+
82+
course_keys = []
83+
for raw_course_id in raw_course_ids:
84+
try:
85+
course_key = CourseKey.from_string(str(raw_course_id))
86+
except InvalidKeyError:
87+
log.error(f'Invalid course key: {raw_course_id}, skipping..')
88+
continue
89+
if not all_courses and not modulestore().get_course(course_key):
90+
log.warning(f'Course not found: {course_key}, skipping..')
91+
continue
92+
course_keys.append(course_key)
93+
94+
for course_key in course_keys:
95+
log.info(f'Dispatching legacy library migration for course: {course_key}')
96+
migrate_course_legacy_library_blocks_to_item_bank.delay(user_id, str(course_key), persist_publish_state)
97+
98+
log.info(f'Dispatched migration for {len(course_keys)} course(s)')
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Tests for `migrate_course_legacy_library_blocks_to_item_bank` Studio (cms) management command.
3+
"""
4+
from unittest import mock
5+
6+
import ddt
7+
from django.core.management import CommandError, call_command
8+
9+
from common.djangoapps.student.tests.factories import UserFactory
10+
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
11+
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # pylint: disable=wrong-import-order
12+
from xmodule.modulestore.tests.factories import CourseFactory # pylint: disable=wrong-import-order
13+
14+
15+
@ddt.ddt
16+
class MigrateCourseLegacyLibraryBlocksToItemBankTests(ModuleStoreTestCase):
17+
""" Tests for the `migrate_course_legacy_library_blocks_to_item_bank` management command. """
18+
TASK_PATCH_LOCATION = (
19+
'cms.djangoapps.contentstore.management.commands.migrate_course_legacy_library_blocks_to_item_bank'
20+
'.migrate_course_legacy_library_blocks_to_item_bank'
21+
)
22+
23+
def setUp(self):
24+
""" Setup method - create a user and courses to migrate """
25+
super().setUp()
26+
self.user = UserFactory()
27+
self.first_course = CourseFactory.create()
28+
self.second_course = CourseFactory.create()
29+
30+
def _call_command(self, **options):
31+
""" Invoke the command, defaulting `user_id` to a valid user. """
32+
options.setdefault('user_id', self.user.id)
33+
call_command('migrate_course_legacy_library_blocks_to_item_bank', **options)
34+
35+
@ddt.data(
36+
({}, 'Either --course-ids or --all-courses argument should be provided.'),
37+
(
38+
{'course_ids': 'course-v1:test+course+run', 'all_courses': True},
39+
'Only one of --course-ids or --all-courses argument should be provided.',
40+
),
41+
)
42+
@ddt.unpack
43+
def test_invalid_course_selector_raises_command_error(self, options, expected_message):
44+
""" Test that specifying neither, or both, of --course-ids/--all-courses raises a CommandError. """
45+
with self.assertRaisesRegex(CommandError, expected_message): # noqa: PT027
46+
self._call_command(**options)
47+
48+
def test_invalid_user_id_raises_command_error(self):
49+
""" Test that an unknown --user-id raises a CommandError. """
50+
invalid_user_id = self.user.id + 1000
51+
with self.assertRaisesRegex(CommandError, f'No user found with id: {invalid_user_id}'): # noqa: PT027
52+
call_command(
53+
'migrate_course_legacy_library_blocks_to_item_bank',
54+
all_courses=True,
55+
user_id=invalid_user_id,
56+
)
57+
58+
@ddt.data(
59+
'invalid_key',
60+
'course-v1:test+nonexistent+run',
61+
)
62+
def test_unparsable_or_nonexistent_course_id_is_skipped(self, bad_course_id):
63+
"""
64+
Test that an unparsable or nonexistent course key passed via --course-ids is skipped,
65+
while other, valid, course keys are still dispatched.
66+
"""
67+
course_ids = f'{bad_course_id},{self.first_course.id}'
68+
with mock.patch(self.TASK_PATCH_LOCATION) as patched_task:
69+
self._call_command(course_ids=course_ids)
70+
71+
patched_task.delay.assert_called_once_with(self.user.id, str(self.first_course.id), False)
72+
73+
def test_course_ids_dispatches_task_for_each_course(self):
74+
""" Test that the task is dispatched once per course key passed via --course-ids. """
75+
course_ids = f'{self.first_course.id},{self.second_course.id}'
76+
with mock.patch(self.TASK_PATCH_LOCATION) as patched_task:
77+
self._call_command(course_ids=course_ids)
78+
79+
expected_calls = [
80+
mock.call(self.user.id, str(self.first_course.id), False),
81+
mock.call(self.user.id, str(self.second_course.id), False),
82+
]
83+
self.assertEqual(patched_task.delay.mock_calls, expected_calls) # noqa: PT009
84+
85+
def test_all_courses_dispatches_task_for_every_course(self):
86+
""" Test that --all-courses dispatches the task for every course known to CourseOverview. """
87+
CourseOverviewFactory(id=self.first_course.id)
88+
CourseOverviewFactory(id=self.second_course.id)
89+
90+
with mock.patch(self.TASK_PATCH_LOCATION) as patched_task:
91+
self._call_command(all_courses=True)
92+
93+
expected_calls = [
94+
mock.call(self.user.id, str(self.first_course.id), False),
95+
mock.call(self.user.id, str(self.second_course.id), False),
96+
]
97+
self.assertCountEqual(patched_task.delay.mock_calls, expected_calls) # noqa: PT009
98+
99+
@ddt.data(True, False)
100+
def test_persist_publish_state_passed_to_task(self, persist_publish_state):
101+
""" Test that --persist-publish-state is forwarded to the task, defaulting to False. """
102+
with mock.patch(self.TASK_PATCH_LOCATION) as patched_task:
103+
self._call_command(
104+
course_ids=str(self.first_course.id),
105+
persist_publish_state=persist_publish_state,
106+
)
107+
108+
patched_task.delay.assert_called_once_with(self.user.id, str(self.first_course.id), persist_publish_state)

cms/djangoapps/contentstore/tasks.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,12 +2340,21 @@ def _cancel_old_tasks(course_key: str, user: User, ignore_task_ids: list[str]):
23402340

23412341

23422342
@shared_task(base=LegacyLibraryContentToItemBank, bind=True)
2343-
def migrate_course_legacy_library_blocks_to_item_bank(self, user_id: int, course_key: str):
2343+
def migrate_course_legacy_library_blocks_to_item_bank(
2344+
self, user_id: int, course_key: str, persist_publish_state: bool = False,
2345+
):
23442346
"""
23452347
Migrate legacy course library blocks to Item Bank.
23462348
23472349
Depending on the number of blocks and its children blocks this operation can take a significant
23482350
amount of time and this is why it is run as a celery task.
2351+
2352+
Arguments:
2353+
user_id: id of the user performing the migration.
2354+
course_key: the course whose legacy library content blocks should be migrated.
2355+
persist_publish_state: if True, blocks that were published before the migration
2356+
(and had no unpublished changes) are re-published afterward. Defaults to False,
2357+
leaving migrated blocks as drafts.
23492358
"""
23502359
ensure_cms("Legacy library content references may only be executed in CMS")
23512360
set_code_owner_attribute_from_module(__name__)
@@ -2363,7 +2372,9 @@ def migrate_course_legacy_library_blocks_to_item_bank(self, user_id: int, course
23632372
with store.bulk_operations(key):
23642373
for block in blocks:
23652374
self.status.set_state(f'Migrating block: {block.usage_key}')
2366-
block.v2_update_children_upstream_version(user_id)
2375+
block.v2_update_children_upstream_version(
2376+
user_id, persist_publish_state=persist_publish_state
2377+
)
23672378
except Exception as exc: # pylint: disable=broad-except
23682379
LOGGER.exception(f'Error while migrating blocks: {exc}')
23692380
self.status.fail(str(exc))

xmodule/library_content_block.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,14 +329,19 @@ def studio_post_paste(self, store, source_node) -> bool:
329329
self.sync_from_library(upgrade_to_latest=False)
330330
return True # Children have been handled
331331

332-
def v2_update_children_upstream_version(self, user_id=None):
332+
def v2_update_children_upstream_version(self, user_id=None, persist_publish_state=False):
333333
"""
334334
Update the upstream and upstream version fields of all children to point to library v2 version of the legacy
335335
library blocks. This essentially converts this legacy block to new ItemBankBlock.
336+
337+
If `persist_publish_state` is True, and this block was published prior to the migration
338+
(with no unpublished changes), it is re-published afterward so that the
339+
upstream/upstream_version changes reach LMS.
336340
"""
337341
from cms.djangoapps.modulestore_migrator import api as migrator_api
338342
store = modulestore()
339343
with store.bulk_operations(self.course_id):
344+
was_published = persist_publish_state and not store.has_changes(self)
340345
children = self.get_children()
341346
# These are the v1 library item upstream UsageKeys
342347
child_old_upstream_keys = [
@@ -358,6 +363,8 @@ def v2_update_children_upstream_version(self, user_id=None):
358363
self.is_migrated_to_v2 = True
359364
self.save()
360365
store.update_item(self, user_id)
366+
if was_published:
367+
store.publish(self.location, user_id)
361368

362369
def _validate_library_version(self, validation, lib_tools, version, library_key):
363370
"""

xmodule/tests/test_library_content.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,69 @@ def test_author_view(self):
826826
assert '<li>html 2</li>' in rendered.content
827827
assert '<li>html 3</li>' in rendered.content
828828
assert '<li>html 4</li>' in rendered.content
829+
830+
831+
@ddt.ddt
832+
class TestLegacyLibraryContentBlockMigrationPublishing(LegacyLibraryContentTest):
833+
"""
834+
Unit tests for the `persist_publish_state` flag of
835+
LegacyLibraryContentBlock.v2_update_children_upstream_version.
836+
"""
837+
838+
def setUp(self):
839+
from cms.djangoapps.modulestore_migrator import api
840+
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
841+
super().setUp()
842+
user = UserFactory()
843+
self._sync_lc_block_from_library()
844+
self.organization = OrganizationFactory(short_name="myorg")
845+
lib_api.create_library(
846+
org=self.organization,
847+
slug="mylib",
848+
title="My Test V2 Library",
849+
)
850+
self.library_v2 = lib_api.ContentLibrary.objects.get(slug="mylib")
851+
api.start_migration_to_library(
852+
user=user,
853+
source_key=self.library.location.library_key,
854+
target_library_key=self.library_v2.library_key,
855+
target_collection_slug=None,
856+
composition_level=CompositionLevel.Component,
857+
repeat_handling_strategy=RepeatHandlingStrategy.Skip,
858+
preserve_url_slugs=True,
859+
forward_source_to_target=True,
860+
)
861+
862+
@ddt.data(
863+
# Published before migration, flag True: re-published with the migration reflected.
864+
(True, True),
865+
# Published before migration, flag False (default): published branch is left untouched.
866+
(True, False),
867+
# Never published before migration, flag True: stays unpublished.
868+
(False, True),
869+
)
870+
@ddt.unpack
871+
def test_persist_publish_state(self, was_published_before, persist_publish_state):
872+
"""
873+
Tests the `persist_publish_state` flag of `v2_update_children_upstream_version` under
874+
the various combinations of prior publish state and flag value.
875+
"""
876+
if was_published_before:
877+
self.store.publish(self.course.location, self.user_id)
878+
879+
self.lc_block.v2_update_children_upstream_version(
880+
self.user_id, persist_publish_state=persist_publish_state,
881+
)
882+
883+
if was_published_before and persist_publish_state:
884+
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
885+
published_block = self.store.get_item(self.lc_block.location)
886+
assert published_block.is_migrated_to_v2 is True
887+
assert published_block.get_children()[0].upstream == "lb:myorg:mylib:html:html_1"
888+
elif was_published_before and not persist_publish_state:
889+
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
890+
published_block = self.store.get_item(self.lc_block.location)
891+
# The published version still reflects the pre-migration state.
892+
assert published_block.is_migrated_to_v2 is False
893+
else:
894+
assert not self.store.has_published_version(self.store.get_item(self.lc_block.location))

0 commit comments

Comments
 (0)