Skip to content

Commit 1cd73d1

Browse files
navinkarkerabradenmacdonaldChrisChVpomegranitedrpenido
authored
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>
1 parent 875158f commit 1cd73d1

36 files changed

+1634
-540
lines changed

cms/djangoapps/contentstore/admin.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
from cms.djangoapps.contentstore.models import (
1414
BackfillCourseTabsConfig,
1515
CleanStaleCertificateAvailabilityDatesConfig,
16+
ComponentLink,
17+
ContainerLink,
1618
LearningContextLinksStatus,
17-
PublishableEntityLink,
18-
VideoUploadConfig
19+
VideoUploadConfig,
1920
)
2021
from cms.djangoapps.contentstore.outlines_regenerate import CourseOutlineRegenerate
2122
from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines
2223

23-
from .tasks import update_outline_from_modulestore_task, update_all_outlines_from_modulestore_task
24-
24+
from .tasks import update_all_outlines_from_modulestore_task, update_outline_from_modulestore_task
2525

2626
log = logging.getLogger(__name__)
2727

@@ -88,10 +88,10 @@ class CleanStaleCertificateAvailabilityDatesConfigAdmin(ConfigurationModelAdmin)
8888
pass
8989

9090

91-
@admin.register(PublishableEntityLink)
92-
class PublishableEntityLinkAdmin(admin.ModelAdmin):
91+
@admin.register(ComponentLink)
92+
class ComponentLinkAdmin(admin.ModelAdmin):
9393
"""
94-
PublishableEntityLink admin.
94+
ComponentLink admin.
9595
"""
9696
fields = (
9797
"uuid",
@@ -127,6 +127,45 @@ def has_change_permission(self, request, obj=None):
127127
return False
128128

129129

130+
@admin.register(ContainerLink)
131+
class ContainerLinkAdmin(admin.ModelAdmin):
132+
"""
133+
ContainerLink admin.
134+
"""
135+
fields = (
136+
"uuid",
137+
"upstream_container",
138+
"upstream_container_key",
139+
"upstream_context_key",
140+
"downstream_usage_key",
141+
"downstream_context_key",
142+
"version_synced",
143+
"version_declined",
144+
"created",
145+
"updated",
146+
)
147+
readonly_fields = fields
148+
list_display = [
149+
"upstream_container",
150+
"upstream_container_key",
151+
"downstream_usage_key",
152+
"version_synced",
153+
"updated",
154+
]
155+
search_fields = [
156+
"upstream_container_key",
157+
"upstream_context_key",
158+
"downstream_usage_key",
159+
"downstream_context_key",
160+
]
161+
162+
def has_add_permission(self, request):
163+
return False
164+
165+
def has_change_permission(self, request, obj=None):
166+
return False
167+
168+
130169
@admin.register(LearningContextLinksStatus)
131170
class LearningContextLinksStatusAdmin(admin.ModelAdmin):
132171
"""

cms/djangoapps/contentstore/helpers.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
)
3232

3333
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
34-
from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields
34+
from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException
35+
from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block
3536
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
3637
import openedx.core.djangoapps.content_staging.api as content_staging_api
3738
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
@@ -416,7 +417,7 @@ def _fetch_and_set_upstream_link(
416417
user: User
417418
):
418419
"""
419-
Fetch and set upstream link for the given xblock. This function handles following cases:
420+
Fetch and set upstream link for the given xblock which is being pasted. This function handles following cases:
420421
* the xblock is copied from a v2 library; the library block is set as upstream.
421422
* the xblock is copied from a course; no upstream is set, only copied_from_block is set.
422423
* the xblock is copied from a course where the source block was imported from a library; the original libary block
@@ -425,7 +426,7 @@ def _fetch_and_set_upstream_link(
425426
# Try to link the pasted block (downstream) to the copied block (upstream).
426427
temp_xblock.upstream = copied_from_block
427428
try:
428-
UpstreamLink.get_for_block(temp_xblock)
429+
upstream_link = UpstreamLink.get_for_block(temp_xblock)
429430
except UpstreamLinkException:
430431
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
431432
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
@@ -456,7 +457,8 @@ def _fetch_and_set_upstream_link(
456457
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
457458
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
458459
# new values from the published upstream content.
459-
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
460+
if isinstance(upstream_link.upstream_key, UsageKey): # only if upstream is a block, not a container
461+
fetch_customizable_fields_from_block(downstream=temp_xblock, user=user, upstream=temp_xblock)
460462

461463

462464
def _import_xml_node_to_parent(
@@ -790,3 +792,26 @@ def _get_usage_key_from_node(node, parent_id: str) -> UsageKey | None:
790792
)
791793

792794
return usage_key
795+
796+
797+
def concat_static_file_notices(notices: list[StaticFileNotices]) -> StaticFileNotices:
798+
"""Combines multiple static file notices into a single object
799+
800+
Args:
801+
notices: list of StaticFileNotices
802+
803+
Returns:
804+
Single StaticFileNotices
805+
"""
806+
new_files = []
807+
conflicting_files = []
808+
error_files = []
809+
for notice in notices:
810+
new_files.extend(notice.new_files)
811+
conflicting_files.extend(notice.conflicting_files)
812+
error_files.extend(notice.error_files)
813+
return StaticFileNotices(
814+
new_files=list(set(new_files)),
815+
conflicting_files=list(set(conflicting_files)),
816+
error_files=list(set(error_files)),
817+
)

cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Management command to recreate upstream-dowstream links in PublishableEntityLink for course(s).
2+
Management command to recreate upstream-dowstream links in ComponentLink for course(s).
33
44
This command can be run for all the courses or for given list of courses.
55
"""
@@ -23,7 +23,7 @@
2323

2424
class Command(BaseCommand):
2525
"""
26-
Recreate links for course(s) in PublishableEntityLink table.
26+
Recreate upstream links for course(s) in ComponentLink and ContainerLink tables.
2727
2828
Examples:
2929
# Recreate upstream links for two courses.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Generated by Django 4.2.20 on 2025-04-22 15:08
2+
import uuid
3+
4+
import django.db.models.deletion
5+
import opaque_keys.edx.django.models
6+
import openedx_learning.lib.fields
7+
import openedx_learning.lib.validators
8+
from django.db import migrations, models
9+
10+
11+
class Migration(migrations.Migration):
12+
dependencies = [
13+
('oel_components', '0003_remove_componentversioncontent_learner_downloadable'),
14+
('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'),
15+
]
16+
17+
operations = [
18+
migrations.RenameModel(
19+
old_name='PublishableEntityLink',
20+
new_name='ComponentLink',
21+
),
22+
migrations.AlterModelOptions(
23+
name='componentlink',
24+
options={'verbose_name': 'Component Link', 'verbose_name_plural': 'Component Links'},
25+
),
26+
migrations.AlterField(
27+
model_name='componentlink',
28+
name='upstream_block',
29+
field=models.ForeignKey(
30+
blank=True,
31+
null=True,
32+
on_delete=django.db.models.deletion.SET_NULL,
33+
related_name='links',
34+
to='oel_components.component',
35+
),
36+
),
37+
migrations.CreateModel(
38+
name='ContainerLink',
39+
fields=[
40+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
41+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
42+
('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)),
43+
('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)),
44+
('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
45+
('version_synced', models.IntegerField()),
46+
('version_declined', models.IntegerField(blank=True, null=True)),
47+
('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
48+
('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])),
49+
('upstream_container_key', opaque_keys.edx.django.models.ContainerKeyField(help_text='Upstream block key (e.g. lct:...), this value cannot be null and is useful to track upstream library blocks that do not exist yet or were deleted.', max_length=255)),
50+
('upstream_container', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='oel_publishing.container')),
51+
],
52+
options={
53+
'abstract': False,
54+
'verbose_name': 'Container Link',
55+
'verbose_name_plural': 'Container Links',
56+
},
57+
),
58+
]

0 commit comments

Comments
 (0)