|
46 | 46 | from django.utils.translation import gettext_lazy as _
|
47 | 47 |
|
48 | 48 | 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 |
52 | 50 | from pylti1p3.contrib.django import DjangoDbToolConf
|
53 | 51 | from pylti1p3.contrib.django import DjangoMessageLaunch
|
54 | 52 | from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool
|
|
58 | 56 | LICENSE_OPTIONS, ALL_RIGHTS_RESERVED,
|
59 | 57 | )
|
60 | 58 | 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 |
62 | 60 | from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order
|
63 | 61 |
|
64 | 62 | from .apps import ContentLibrariesConfig
|
@@ -229,58 +227,134 @@ def __str__(self):
|
229 | 227 | return f"ContentLibraryPermission ({self.access_level} for {who})"
|
230 | 228 |
|
231 | 229 |
|
232 |
| -class ContentLibraryMigration(models.Model): |
| 230 | +class LegacyLibraryMigrationSource(models.Model): |
233 | 231 | """
|
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. |
235 | 235 | """
|
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) |
239 | 236 |
|
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 | + ) |
243 | 242 |
|
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 | + ) |
251 | 288 |
|
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 | + ] |
254 | 305 |
|
255 | 306 |
|
256 |
| -class ContentLibraryBlockMigration(models.Model): |
| 307 | +class LegacyLibraryBlockMigration(models.Model): |
257 | 308 | """
|
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. |
259 | 310 | """
|
| 311 | + # The library-migration event of which this block-migration was a part. |
260 | 312 | 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", |
262 | 316 | )
|
263 |
| - block_type = models.SlugField() |
264 |
| - source_block_id = models.SlugField() |
265 |
| - target_block_id = models.SlugField() |
266 | 317 |
|
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 | + ] |
270 | 351 |
|
271 | 352 | @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" |
278 | 355 |
|
279 | 356 | 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}" |
284 | 358 |
|
285 | 359 |
|
286 | 360 | class ContentLibraryBlockImportTask(models.Model):
|
|
0 commit comments