feat: support for syncing units from libraries to courses (#36553)

* feat: library unit sync
* feat: create component link only for component xblocks
* feat: container link model
* feat: update downstream api views
* feat: delete extra components in container on sync (not working)
* fix: duplicate definitions of LibraryXBlockMetadata
* test: add a new integration test suite for syncing
* feat: partially implement container+child syncing
* fix: blockserializer wasn't always serializing all HTML block fields
* feat: handle reorder, addition and deletion of components in sync

Updates children components of unit in course based on upstream unit,
deletes removed component, adds new ones and updates order as per
upstream.

* feat: return unit upstreamInfo and disallow edits to units in courses that are sourced from a library (#773)
* feat: Add upstream_info to unit
* feat: disallow edits to units in courses that are sourced from a library (#774)

---------

Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>

* docs: capitalization of XBlock

Co-authored-by: David Ormsbee <dave@axim.org>

* refactor: (minor) change python property name to reflect type better

* fix: lots of "Tried to inspect a missing...upstream link" warnings

when viewing a unit in Studio

* docs: mention potential REST API for future refactor

* fix: check if upstream actually exists before making unit read-only

* chore: fix camel-case var

* fix: test failure when mocked XBlock doesn't have UpstreamSyncMixin

---------

Co-authored-by: Braden MacDonald <braden@opencraft.com>
Co-authored-by: Chris Chávez <xnpiochv@gmail.com>
Co-authored-by: Jillian Vogel <jill@opencraft.com>
Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
Co-authored-by: Braden MacDonald <mail@bradenm.com>
Co-authored-by: David Ormsbee <dave@axim.org>
This commit is contained in:
Navin Karkera
2025-04-24 18:41:47 +00:00
committed by GitHub
parent 875158f1ad
commit 1cd73d1b96
36 changed files with 1636 additions and 542 deletions

View File

@@ -9,7 +9,7 @@ from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocatorV2
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2
from openedx_events.tests.utils import OpenEdxEventsTestMixin
from common.djangoapps.student.tests.factories import UserFactory
@@ -17,7 +17,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from ..models import LearningContextLinksStatus, LearningContextLinksStatusChoices, PublishableEntityLink
from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink
class BaseUpstreamLinksHelpers(TestCase):
@@ -44,6 +44,39 @@ class BaseUpstreamLinksHelpers(TestCase):
upstream_version=num,
)
def _create_unit(self, num: int):
"""
Create xblock with random upstream key and version number.
"""
random_upstream = LibraryContainerLocator.from_string(
f"lct:OpenedX:CSPROB2:unit:{uuid4()}"
)
return random_upstream, BlockFactory.create(
parent=self.sequence, # pylint: disable=attribute-defined-outside-init
category='vertical',
display_name=f"An unit Block - {num}",
upstream=str(random_upstream),
upstream_version=num,
)
def _create_unit_and_expected_container_link(self, course_key: str | CourseKey, num_blocks: int = 3):
"""
Create unit xblock with random upstream key and version number.
"""
data = []
for i in range(num_blocks):
upstream, block = self._create_unit(i + 1)
data.append({
"upstream_container": None,
"downstream_context_key": course_key,
"downstream_usage_key": block.usage_key,
"upstream_container_key": upstream,
"upstream_context_key": str(upstream.context_key),
"version_synced": i + 1,
"version_declined": None,
})
return data
def _create_block_and_expected_links_data(self, course_key: str | CourseKey, num_blocks: int = 3):
"""
Creates xblocks and its expected links data for given course_key
@@ -62,11 +95,11 @@ class BaseUpstreamLinksHelpers(TestCase):
})
return data
def _compare_links(self, course_key, expected):
def _compare_links(self, course_key, expected_component_links, expected_container_links):
"""
Compares links for given course with passed expected list of dicts.
"""
links = list(PublishableEntityLink.objects.filter(downstream_context_key=course_key).values(
links = list(ComponentLink.objects.filter(downstream_context_key=course_key).values(
'upstream_block',
'upstream_usage_key',
'upstream_context_key',
@@ -75,7 +108,17 @@ class BaseUpstreamLinksHelpers(TestCase):
'version_synced',
'version_declined',
))
self.assertListEqual(links, expected)
self.assertListEqual(links, expected_component_links)
container_links = list(ContainerLink.objects.filter(downstream_context_key=course_key).values(
'upstream_container',
'upstream_container_key',
'upstream_context_key',
'downstream_usage_key',
'downstream_context_key',
'version_synced',
'version_declined',
))
self.assertListEqual(container_links, expected_container_links)
@skip_unless_cms
@@ -95,16 +138,19 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
with self.store.bulk_operations(course_key_1):
self._set_course_data(course_1)
self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1)
self.expected_container_links_1 = self._create_unit_and_expected_container_link(course_key_1)
self.course_2 = course_2 = CourseFactory.create(emit_signals=True)
self.course_key_2 = course_key_2 = self.course_2.id
with self.store.bulk_operations(course_key_2):
self._set_course_data(course_2)
self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2)
self.expected_container_links_2 = self._create_unit_and_expected_container_link(course_key_2)
self.course_3 = course_3 = CourseFactory.create(emit_signals=True)
self.course_key_3 = course_key_3 = self.course_3.id
with self.store.bulk_operations(course_key_3):
self._set_course_data(course_3)
self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3)
self.expected_container_links_3 = self._create_unit_and_expected_container_link(course_key_3)
def call_command(self, *args, **kwargs):
"""
@@ -132,14 +178,14 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
"""
# Pre-checks
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists()
assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_1).exists()
assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_1).exists()
# Run command
self.call_command('--course', str(self.course_key_1))
# Post verfication
assert LearningContextLinksStatus.objects.filter(
context_key=str(self.course_key_1)
).first().status == LearningContextLinksStatusChoices.COMPLETED
self._compare_links(self.course_key_1, self.expected_links_1)
self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1)
def test_call_for_multiple_course(self):
"""
@@ -147,9 +193,9 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
"""
# Pre-checks
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists()
assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_2).exists()
assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_2).exists()
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists()
assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_3).exists()
assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_3).exists()
# Run command
self.call_command('--course', str(self.course_key_2), '--course', str(self.course_key_3))
@@ -161,8 +207,8 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
assert LearningContextLinksStatus.objects.filter(
context_key=str(self.course_key_3)
).first().status == LearningContextLinksStatusChoices.COMPLETED
self._compare_links(self.course_key_2, self.expected_links_2)
self._compare_links(self.course_key_3, self.expected_links_3)
self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2)
self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3)
def test_call_for_all_courses(self):
"""
@@ -170,7 +216,7 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
"""
# Delete all links and status just to make sure --all option works
LearningContextLinksStatus.objects.all().delete()
PublishableEntityLink.objects.all().delete()
ComponentLink.objects.all().delete()
# Pre-checks
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists()
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists()
@@ -189,9 +235,9 @@ class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, Bas
assert LearningContextLinksStatus.objects.filter(
context_key=str(self.course_key_3)
).first().status == LearningContextLinksStatusChoices.COMPLETED
self._compare_links(self.course_key_1, self.expected_links_1)
self._compare_links(self.course_key_2, self.expected_links_2)
self._compare_links(self.course_key_3, self.expected_links_3)
self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1)
self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2)
self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3)
def test_call_for_invalid_course(self):
"""
@@ -239,16 +285,19 @@ class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseU
with self.store.bulk_operations(course_key_1):
self._set_course_data(course_1)
self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1)
self.expected_container_links_1 = self._create_unit_and_expected_container_link(course_key_1)
self.course_2 = course_2 = CourseFactory.create(emit_signals=True)
self.course_key_2 = course_key_2 = self.course_2.id
with self.store.bulk_operations(course_key_2):
self._set_course_data(course_2)
self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2)
self.expected_container_links_2 = self._create_unit_and_expected_container_link(course_key_2)
self.course_3 = course_3 = CourseFactory.create(emit_signals=True)
self.course_key_3 = course_key_3 = self.course_3.id
with self.store.bulk_operations(course_key_3):
self._set_course_data(course_3)
self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3)
self.expected_container_links_3 = self._create_unit_and_expected_container_link(course_key_3)
def test_create_or_update_events(self):
"""
@@ -257,18 +306,23 @@ class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseU
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists()
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists()
assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists()
assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_1).count() == 3
assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_2).count() == 3
assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_3).count() == 3
self._compare_links(self.course_key_1, self.expected_links_1)
self._compare_links(self.course_key_2, self.expected_links_2)
self._compare_links(self.course_key_3, self.expected_links_3)
assert ComponentLink.objects.filter(downstream_context_key=self.course_key_1).count() == 3
assert ComponentLink.objects.filter(downstream_context_key=self.course_key_2).count() == 3
assert ComponentLink.objects.filter(downstream_context_key=self.course_key_3).count() == 3
self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1)
self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2)
self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3)
def test_delete_handler(self):
"""
Test whether links are deleted on deletion of xblock.
"""
usage_key = self.expected_links_1[0]["downstream_usage_key"]
assert PublishableEntityLink.objects.filter(downstream_usage_key=usage_key).exists()
assert ComponentLink.objects.filter(downstream_usage_key=usage_key).exists()
self.store.delete_item(usage_key, self.user.id)
assert not PublishableEntityLink.objects.filter(downstream_usage_key=usage_key).exists()
assert not ComponentLink.objects.filter(downstream_usage_key=usage_key).exists()
usage_key = self.expected_container_links_1[0]["downstream_usage_key"]
assert ContainerLink.objects.filter(downstream_usage_key=usage_key).exists()
self.store.delete_item(usage_key, self.user.id)
assert not ContainerLink.objects.filter(downstream_usage_key=usage_key).exists()