Files
Braden MacDonald 3e522d5272 feat: bump opaque-keys to get case-sensitivity support + default max_length (#38044)
refactor: remove some 'max_length=255' to be more DRY

feat: example of making an OpaqueKeyField case_sensitive (modulestore_migrator)

test: update test now that we're using case-insensitive collation on SQLite
2026-02-25 09:05:15 -08:00

271 lines
9.6 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 model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import (
LearningContextKeyField,
UsageKeyField,
)
from openedx_content.models_api import (
Collection,
DraftChangeLog,
DraftChangeLogRecord,
LearningPackage,
PublishableEntity,
)
from user_tasks.models import UserTaskStatus
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.
One source can be associated with multiple (successful or unsuccessful) ModulestoreMigrations.
If a source has been migrated multiple times, then at most one of them can be considered the
"official" or "authoritative" migration; this is indicated by setting the `forwarded` field to
that ModulestoreMigration object.
Note that `forwarded` can be NULL even when 1+ migrations have happened for this source. This just
means that none of them were authoritative. In other words, they were all "imports"/"copies" rather
than true "migrations".
In practice, as of Ulmo:
* The `forwarded` field is used to decide how to update legacy library_content references.
* When using the Libraries Migration UI in Studio, `forwarded` is always set to the first
successful ModulestoreMigration.
* When using the REST API directly, the default is to use the same behavior as the UI, but
clients can also explicitly specify the `forward_source_to_target` boolean param in order to
control whether `forwarded` is set to any given migration.
"""
key = LearningContextKeyField(
unique=True,
case_sensitive=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'),
)
def __str__(self):
return f"{self.key}"
__repr__ = __str__
class ModulestoreMigration(models.Model):
"""
Tracks the action of a user importing a Modulestore-based course or legacy library into a
openedx_content 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.ForeignKey(
UserTaskStatus,
on_delete=models.RESTRICT,
help_text=_(
"Tracks the status of the task which is executing this migration. "
"In a bulk migration, the same task can be multiple migrations"
),
related_name="migrations",
)
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."
)
)
# Mostly used in bulk migrations. The `UserTaskStatus` represents the status of the entire bulk migration;
# a `FAILED` status means that the entire bulk-migration has failed.
# Each `ModulestoreMigration` saves the data of the migration of each legacy library.
# The `is_failed` value is to keep track a failed legacy library in the bulk migration,
# but allow continuing with the migration of the rest of the legacy libraries.
is_failed = models.BooleanField(
default=False,
help_text=_(
"is the migration failed?"
),
)
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.
The semantics of `forwarded` directly mirror those of `ModulestoreSource.forwarded`. Please see
that class's docstring for details.
"""
overall_source = models.ForeignKey(
ModulestoreSource,
on_delete=models.CASCADE,
related_name="blocks",
)
key = UsageKeyField(
case_sensitive=True,
unique=True,
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'
),
)
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,
help_text=_('The target entity of this block migration, set to null if it fails to migrate'),
null=True,
blank=True,
)
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,
)
unsupported_reason = models.TextField(
null=True,
blank=True,
help_text=_('Reason if the block is unsupported and target is set to null'),
)
class Meta:
unique_together = [
('overall_migration', 'source'),
# By default defining a unique index on a nullable column will only enforce unicity of non-null values.
('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}')"
)