Files
edx-platform/cms/djangoapps/modulestore_migrator/models.py
Kyle McCormick 7275ce1634 feat!: modulestore_migrator (#36873)
This introduces the modulestore_migrator app, which can be
used to copy content (courses and libraries) from modulestore
into Learning Core. It is currently aimed to work on the legacy
library -> v2 library migration, but it will be used in the future
for course->library and course->course migrations.

This includes an initial REST API, Django admin interface,
and Python API.

Closes: https://github.com/openedx/edx-platform/issues/37211

Requires some follow-up work before this is production-ready:
https://github.com/openedx/edx-platform/issues/37259

Co-authored-by: Andrii <andrii.hantkovskyi@raccoongang.com>
Co-authored-by: Maksim Sokolskiy <maksim.sokolskiy@raccoongang.com>
2025-09-24 11:02:05 -04:00

225 lines
7.4 KiB
Python

"""
Models for the modulestore migration tool.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from user_tasks.models import UserTaskStatus
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import (
LearningContextKeyField,
UsageKeyField,
)
from openedx_learning.api.authoring_models import (
LearningPackage, PublishableEntity, Collection, DraftChangeLog, DraftChangeLogRecord
)
from .data import CompositionLevel, RepeatHandlingStrategy
User = get_user_model()
class ModulestoreSource(models.Model):
"""
A legacy learning context (course or library) which can be a source of a migration.
"""
key = LearningContextKeyField(
max_length=255,
unique=True,
help_text=_('Key of the content source (a course or a legacy library)'),
)
forwarded = models.OneToOneField(
'modulestore_migrator.ModulestoreMigration',
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text=_('If set, the system will forward references of this source over to the target of this migration'),
related_name="forwards",
)
def __str__(self):
return f"{self.__class__.__name__}('{self.key}')"
__repr__ = __str__
class ModulestoreMigration(models.Model):
"""
Tracks the action of a user importing a Modulestore-based course or legacy library into a
learning-core based learning package
Notes:
* As of Ulmo, a learning package is always associated with a v2 content library, but we
will not bake that assumption into this model)
* Each Migration is tied to a single UserTaskStatus, which connects it to a user and
contains the progress of the import.
* A single ModulestoreSource may very well have multiple ModulestoreMigrations; however,
at most one of them with be the "authoritative" migration, as indicated by `forwarded`.
"""
## MIGRATION SPECIFICATION
source = models.ForeignKey(
ModulestoreSource,
on_delete=models.CASCADE,
related_name="migrations",
)
source_version = models.CharField(
max_length=255,
blank=True,
null=True,
help_text=_('Migrated content version, the hash of published content version'),
)
composition_level = models.CharField(
max_length=255,
choices=CompositionLevel.supported_choices(),
default=CompositionLevel.Component.value,
help_text=_('Maximum hierachy level at which content should be aggregated in target library'),
)
repeat_handling_strategy = models.CharField(
choices=RepeatHandlingStrategy.supported_choices(),
default=RepeatHandlingStrategy.default().value,
max_length=24,
help_text=_(
"If a piece of content already exists in the content library, choose how to handle it."
),
)
preserve_url_slugs = models.BooleanField(
default=False,
help_text=_(
"Should the migration preserve the location IDs of the existing blocks?"
"If not, then new, unique human-readable IDs will be generated based on the block titles."
),
)
target = models.ForeignKey(
LearningPackage,
on_delete=models.CASCADE,
help_text=_('Content will be imported into this library'),
)
target_collection = models.ForeignKey(
Collection,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text=_('Optional - Collection (within the target library) into which imported content will be grouped'),
)
## MIGRATION ARTIFACTS
task_status = models.OneToOneField(
UserTaskStatus,
on_delete=models.RESTRICT,
help_text=_("Tracks the status of the task which is executing this migration"),
)
change_log = models.ForeignKey(
DraftChangeLog,
on_delete=models.SET_NULL,
null=True,
help_text=_("Changelog entry in the target learning package which records this migration"),
)
staged_content = models.OneToOneField(
"content_staging.StagedContent",
null=True,
on_delete=models.SET_NULL, # Staged content is liable to be deleted in order to save space
help_text=_(
"Modulestore content is processed and staged before importing it to a learning packge. "
"We temporarily save the staged content to allow for troubleshooting of failed migrations."
)
)
def __str__(self):
return (
f"{self.__class__.__name__} #{self.pk}: "
f"{self.source.key}{self.target_collection or self.target}"
)
def __repr__(self):
return (
f"{self.__class__.__name__}("
f"id={self.id}, source='{self.source}',"
f"target='{self.target_collection or self.target}')"
)
class ModulestoreBlockSource(TimeStampedModel):
"""
A legacy block usage (in a course or library) which can be a source of a block migration.
"""
overall_source = models.ForeignKey(
ModulestoreSource,
on_delete=models.CASCADE,
related_name="blocks",
)
key = UsageKeyField(
max_length=255,
help_text=_('Original usage key of the XBlock that has been imported.'),
)
forwarded = models.OneToOneField(
'modulestore_migrator.ModulestoreBlockMigration',
null=True,
on_delete=models.SET_NULL,
help_text=_(
'If set, the system will forward references of this block source over to the target of this block migration'
),
related_name="forwards",
)
unique_together = [("overall_source", "key")]
def __str__(self):
return f"{self.__class__.__name__}('{self.key}')"
__repr__ = __str__
class ModulestoreBlockMigration(TimeStampedModel):
"""
The migration of a single legacy block into a learning package.
Is always tied to a greater overall ModulestoreMigration.
Note:
* A single ModulestoreBlockSource may very well have multiple ModulestoreBlockMigrations; however,
at most one of them with be the "authoritative" migration, as indicated by `forwarded`.
This will coincide with the `overall_migration` being pointed to by `forwarded` as well.
"""
overall_migration = models.ForeignKey(
ModulestoreMigration,
on_delete=models.CASCADE,
related_name="block_migrations",
)
source = models.ForeignKey(
ModulestoreBlockSource,
on_delete=models.CASCADE,
)
target = models.ForeignKey(
PublishableEntity,
on_delete=models.CASCADE,
)
change_log_record = models.OneToOneField(
DraftChangeLogRecord,
# a changelog record can be pruned, which would set this to NULL, but not delete the
# entire import record
null=True,
on_delete=models.SET_NULL,
)
class Meta:
unique_together = [
('overall_migration', 'source'),
('overall_migration', 'target'),
]
def __str__(self):
return (
f"{self.__class__.__name__} #{self.pk}: "
f"{self.source.key}{self.target}"
)
def __repr__(self):
return (
f"{self.__class__.__name__}("
f"id={self.id}, source='{self.source}',"
f"target='{self.target}')"
)