Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 093dd01

Browse files
committedMar 14, 2025·
feat: New models (WIP)
1 parent cf84c89 commit 093dd01

File tree

5 files changed

+189
-56
lines changed

5 files changed

+189
-56
lines changed
 

‎cms/djangoapps/contentstore/tasks.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1462,13 +1462,16 @@ def create_or_update_upstream_links(
14621462
if replace:
14631463
PublishableEntityLink.objects.filter(downstream_context_key=course_key).delete()
14641464
try:
1465-
xblocks = store.get_items(course_key, settings={"upstream": lambda x: x is not None})
1465+
linked_xblocks = store.get_items(course_key, settings={"upstream": lambda x: x is not None})
1466+
lc_xblocks = store.get_items(course_key, block_type="library_content", depth=2)
14661467
except ItemNotFoundError:
14671468
LOGGER.exception(f'Could not find items for given course: {course_key}')
14681469
course_status.update_status(LearningContextLinksStatusChoices.FAILED)
14691470
return
1470-
for xblock in xblocks:
1471+
for xblock in linked_xblocks:
14711472
create_or_update_xblock_upstream_link(xblock, course_key_str, created)
1473+
for lc in lc_xblocks:
1474+
create_or_update_legacy_library_content_children_upstream_links(lc, created)
14721475
course_status.update_status(LearningContextLinksStatusChoices.COMPLETED)
14731476

14741477

‎cms/djangoapps/contentstore/utils.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from openedx_events.learning.data import CourseNotificationData
3232
from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED
3333
from pytz import UTC
34+
from xblock.core import XBlock
3435
from xblock.fields import Scope
3536

3637
from cms.djangoapps.contentstore.toggles import (
@@ -2372,7 +2373,7 @@ def get_xblock_render_context(request, block):
23722373
return ""
23732374

23742375

2375-
def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None) -> None:
2376+
def create_or_update_xblock_upstream_link(xblock: XBlock, created: datetime | None = None) -> None:
23762377
"""
23772378
Create or update upstream->downstream link in database for given xblock.
23782379
"""
@@ -2388,9 +2389,32 @@ def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, c
23882389
lib_component,
23892390
upstream_usage_key=upstream_usage_key,
23902391
upstream_context_key=str(upstream_usage_key.context_key),
2391-
downstream_context_key=course_key,
2392+
downstream_context_key=str(xblock.context_key),
23922393
downstream_usage_key=xblock.usage_key,
23932394
version_synced=xblock.upstream_version,
23942395
version_declined=xblock.upstream_version_declined,
23952396
created=created,
23962397
)
2398+
2399+
2400+
def create_or_update_children_upstream_links(lc_block: LegacyLibraryContentBlock, created: datetime | None = None):
2401+
"""
2402+
Given an LLC XBlock, save its upstream info for each of its children which don't already have links in the database.
2403+
"""
2404+
for child in xblock.children:
2405+
upstream_usage_key = get_migrated_library_block_usage_key(lc_block.usage_key, child.usage_key) # @@TODO
2406+
try:
2407+
lib_component = get_component_from_usage_key(upstream_usage_key)
2408+
except ObjectDoesNotExist:
2409+
log.error(f"Library component not found for {upstream_usage_key}")
2410+
lib_component = None
2411+
PublishableEntityLink.update_or_create(
2412+
lib_component,
2413+
upstream_usage_key=str(upstream_usage_key),
2414+
upstream_context_key=str(upstream_usage_key.context_key),
2415+
downstream_context_key=str(child.context_key),
2416+
downstream_usage_key=str(child.usage_key),
2417+
version_synced=2, # @@TODO
2418+
version_declined=1, # @@TODO
2419+
created=created,
2420+
)

‎openedx/core/djangoapps/content_libraries/admin.py

+30-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
"""
44
from django.contrib import admin
55
from .models import (
6-
ContentLibrary, ContentLibraryPermission, ContentLibraryMigration, ContentLibraryBlockMigration
6+
ContentLibrary,
7+
ContentLibraryPermission,
8+
LegacyLibraryMigrationSource,
9+
LegacyLibraryMigration,
10+
LegacyLibraryBlockMigration,
711
)
812

913

@@ -43,18 +47,37 @@ def get_readonly_fields(self, request, obj=None):
4347
return ["library_key", ]
4448

4549

46-
class ContentLibraryBlockMigrationInline(admin.TabularInline):
50+
class LegacyLibraryBlockMigrationInline(admin.TabularInline):
4751
"""
4852
Django admin UI for content library block migrations
4953
"""
50-
model = ContentLibraryBlockMigration
51-
list_display = ("library_migration", "block_type", "source_block_id", "target_block_id")
54+
model = LegacyLibraryBlockMigration
55+
list_display = ("library_migration", "source_key") # @@TODO
5256

5357

54-
@admin.register(ContentLibraryMigration)
55-
class ContentLibraryMigrationAdmin(admin.ModelAdmin):
58+
@admin.register(LegacyLibraryMigration)
59+
class LegacyLibraryMigrationAdmin(admin.ModelAdmin):
5660
"""
5761
Django admin UI for content library migrations
5862
"""
63+
model = LegacyLibraryMigration
5964
list_display = ("source_key", "target", "target_collection")
60-
inlines = (ContentLibraryBlockMigrationInline,)
65+
inlines = (LegacyLibraryBlockMigrationInline,)
66+
67+
68+
class LegacyLibraryMigrationInline(admin.ModelAdmin):
69+
"""
70+
Django admin UI for content library migrations
71+
"""
72+
model = LegacyLibraryMigration
73+
list_display = ("source_key", "target", "target_collection")
74+
75+
76+
@admin.register(LegacyLibraryMigrationSource)
77+
class LegacyLibraryMigrationSourceAdmin(admin.ModelAdmin):
78+
"""
79+
@@TODO
80+
"""
81+
model = LegacyLibraryMigrationSource
82+
list_display = ("source_key", "target", "target_collection")
83+
inlines = (LegacyLibraryMigrationInline,)

‎openedx/core/djangoapps/content_libraries/migration_api.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from xmodule.util.keys import BlockKey
2020
from xmodule.modulestore.django import modulestore
2121

22-
from .models import ContentLibrary, ContentLibraryMigration, ContentLibraryBlockMigration
22+
from .models import ContentLibrary, LegacyLibraryMigrationSource, LegacyLibraryMigration, LegacyLibraryBlockMigration
2323

2424

2525
log = logging.getLogger(__name__)
@@ -75,11 +75,20 @@ def migrate_legacy_library(
7575
target_block_entity_keys: set[str] = set()
7676

7777
with transaction.atomic():
78-
migration = ContentLibraryMigration.objects.create(
79-
source_key=source_key,
80-
target=target,
78+
migration_source = LegacyLibraryMigrationSource.objects.get_or_create(source_key=source_key)
79+
migration = LegacyLibraryMigration(
80+
source=migration_source,
81+
target_library=target,
8182
target_collection=collection,
83+
migrated_by=user,
8284
)
85+
authoritative = "@@TODO"
86+
if authoritative:
87+
if migration_source.authoritative_migration:
88+
raise Exception("@@TODO")
89+
migration_source.authoritative_migration = migration
90+
migration.save()
91+
migration_source.save()
8392

8493
for source_block in source.get_children():
8594
block_type: str = source_block.usage_key.block_type
@@ -118,7 +127,7 @@ def migrate_legacy_library(
118127
tag_object(str(target_block_meta.usage_key), taxonomy, taxonomy_tags)
119128

120129
# Make a record of the migration
121-
ContentLibraryBlockMigration.objects.create(
130+
LegacyLibraryBlockMigration.objects.create(
122131
library_migration=migration,
123132
block_type=block_type,
124133
source_block_id=source_block.usage_key.block_id,

‎openedx/core/djangoapps/content_libraries/models.py

+114-40
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@
4646
from django.utils.translation import gettext_lazy as _
4747

4848
from opaque_keys.edx.django.models import CourseKeyField
49-
from opaque_keys.edx.locator import (
50-
BlockUsageLocator, LibraryUsageLocatorV2, LibraryLocatorV2, LibraryCollectionLocator
51-
)
49+
from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2
5250
from pylti1p3.contrib.django import DjangoDbToolConf
5351
from pylti1p3.contrib.django import DjangoMessageLaunch
5452
from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool
@@ -58,7 +56,7 @@
5856
LICENSE_OPTIONS, ALL_RIGHTS_RESERVED,
5957
)
6058
from opaque_keys.edx.django.models import LearningContextKeyField, UsageKeyField
61-
from openedx_learning.api.authoring_models import LearningPackage, Collection
59+
from openedx_learning.api.authoring_models import LearningPackage, Collection, Component
6260
from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order
6361

6462
from .apps import ContentLibrariesConfig
@@ -229,58 +227,134 @@ def __str__(self):
229227
return f"ContentLibraryPermission ({self.access_level} for {who})"
230228

231229

232-
class ContentLibraryMigration(models.Model):
230+
class LegacyLibraryMigrationSource(models.Model):
233231
"""
234-
Record of a legacy (v1) content library that has been migrated into a new (v2) content library.
232+
For each legacy (v1) content library, a record of its migration(s).
233+
234+
If a v1 library doesn't have a row here, then it hasn't been migrated yet.
235235
"""
236-
source_key = LearningContextKeyField(unique=True, max_length=255)
237-
target = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE)
238-
target_collection = models.ForeignKey(Collection, on_delete=models.SET_NULL, null=True)
239236

240-
@property
241-
def target_key(self) -> LibraryLocatorV2:
242-
return self.target.library_key
237+
# V1 library that we're migrating from.
238+
library_key = LearningContextKeyField(
239+
max_length=255,
240+
unique=True, # At most one status per v1 library
241+
)
243242

244-
@property
245-
def target_library_collection_key(self) -> LibraryCollectionLocator | None:
246-
return (
247-
LibraryCollectionLocator(self.target_key, self.target_collection.key)
248-
if self.target_collection
249-
else None
250-
)
243+
# V1 libraries can be migrated multiple times, but only one of them can be the "authoritative" migration--that is,
244+
# the one through which legacy course references are forwarded.
245+
authoritative_migration = models.ForeignKey(
246+
"LegacyLibraryMigration",
247+
null=True, # NULL means no authoritative migration (no forwarding of references)
248+
on_delete=models.SET_NULL, # authoritative migration can be deleted without affecting non-authoritative ones.
249+
)
250+
251+
class Meta:
252+
# The authoritative_target Migration should have a foreign key back to this same MigrationSource.
253+
# In other words, we expect: `self.authoritative_target in self.all_targets`
254+
constraints = [
255+
models.CheckConstraint(
256+
check=models.Q(authoritative_target__migration_info__pk=models.F("pk")),
257+
name="authoritative_migration_points_back_to_its_source",
258+
),
259+
]
260+
261+
262+
class LegacyLibraryMigration(models.Model):
263+
"""
264+
A particular migration from a legacy (V1) content to a new (V2) content library collection.
265+
"""
266+
267+
# Associate this migration target back to a source legacy library.
268+
source = models.ForeignKey(
269+
LegacyLibraryMigrationSource,
270+
on_delete=models.CASCADE, # Delete this record if the source is deleted.
271+
related_name="all_migrations",
272+
)
273+
274+
# V2 library that we're migrating to.
275+
target_library = models.ForeignKey(
276+
ContentLibrary,
277+
on_delete=models.CASCADE, # Delete this record if the source is deleted.
278+
# Not unique. Multiple V1 libraries can be migrated to the same V2 library.
279+
)
280+
281+
# Collection within a V2 library that we've migrated to.
282+
target_collection = models.ForeignKey(
283+
Collection,
284+
unique=True, # Any given collection should be the target of at most one V1 library migration.
285+
on_delete=models.SET_NULL, # Collections can be deleted, but the migrated blocks (and the migration) survive.
286+
null=True,
287+
)
251288

252-
def __str__(self) -> str:
253-
return f"{self.source_key} -> {self.target_library_collection_key or self.target_key}"
289+
# User who initiated this library migration.
290+
migrated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
291+
292+
# When the migration was initiated.
293+
migrated_at = models.DateTimeField(auto_now_add=True)
294+
295+
class Meta:
296+
constraints = [
297+
# The target collection should be part of the target library (or NULL). @@TODO
298+
models.CheckConstraint(
299+
check=models.Q(target_collection__isnull=True) | models.Q(
300+
target_collection__learning_package=models.F("target_library__learning_package")
301+
),
302+
name="target_collection_belongs_to_target_library",
303+
),
304+
]
254305

255306

256-
class ContentLibraryBlockMigration(models.Model):
307+
class LegacyLibraryBlockMigration(models.Model):
257308
"""
258-
Record of a legacy (v1) content library block that has been migrated into a new (v) content library block.
309+
Record of a legacy (V1) content library block that has been migrated into a new (V2) content library block.
259310
"""
311+
# The library-migration event of which this block-migration was a part.
260312
library_migration = models.ForeignKey(
261-
ContentLibraryMigration, on_delete=models.CASCADE, related_name="block_migrations"
313+
LegacyLibraryMigration,
314+
on_delete=models.CASCADE, # If the library-migration event is deleted, then this block-migration event goes too
315+
related_name="block_migrations",
262316
)
263-
block_type = models.SlugField()
264-
source_block_id = models.SlugField()
265-
target_block_id = models.SlugField()
266317

267-
@property
268-
def source_usage_key(self) -> BlockUsageLocator:
269-
return self.library_migration.source_key.make_usage_key(self.block_type, self.source_block_id)
318+
# The usage key of the source legacy library block.
319+
# Any given legacy library block will be migrated at most once (hence unique=True).
320+
# EXPECTATION: source_key points at a block within the source V1 library.
321+
# i.e., `source_key.context_key` == `library_migration.source.library_key`.
322+
source_key = UsageKeyField(max_length=255)
323+
324+
# The V2 library component holding the migrated content.
325+
target = models.ForeignKey(
326+
Component, # No need to support Units, etc., because V1 libraries only supported problem, html, and video
327+
unique=True, # Any given lib component can be the target of at most one block migration
328+
on_delete=models.SET_NULL, # Block might get deleted by author and then pruned; that doesn't undo the migration
329+
null=True,
330+
)
331+
332+
class Meta:
333+
constraints = [
334+
# For each LegacyLibraryMigration, each source block (source_key) must have exactly one
335+
# LegacyLibraryBlockMigration.
336+
models.UniqueConstraint(
337+
fields=["library_migration", "source_key"],
338+
name="source_block_unique_within_library_migration",
339+
),
340+
# The target component should be part of the target library (or NULL). @@TODO
341+
models.CheckConstraint(
342+
check=(
343+
models.Q(target__isnull=True) |
344+
models.Q(
345+
target__learning_package=models.F("library_migration__target_library__learning_package")
346+
)
347+
),
348+
name="target_component_belongs_to_target_library",
349+
),
350+
]
270351

271352
@property
272-
def target_usage_key(self) -> LibraryUsageLocatorV2:
273-
return LibraryUsageLocatorV2( # type: ignore[abstract] # (we are missing an annotation in opaque-keys)
274-
lib_key=self.library_migration.target_key,
275-
usage_id=self.target_block_id,
276-
block_type=self.block_type,
277-
)
353+
def target_key(self) -> LibraryUsageLocatorV2:
354+
return "@@TODO"
278355

279356
def __str__(self):
280-
return f"{self.source_usage_key} -> {self.target_usage_key}"
281-
282-
class Meta:
283-
unique_together = [('library_migration', 'block_type', 'source_block_id')]
357+
return f"{self.source_key} -> {self.target_key}"
284358

285359

286360
class ContentLibraryBlockImportTask(models.Model):

0 commit comments

Comments
 (0)
Please sign in to comment.