Files
edx-platform/cms/djangoapps/modulestore_migrator/data.py
Kyle McCormick 91e521ef51 fix: Various fixes to modulestore_migrator (#37711)
For legacy library_content references in courses, this PR:
- **Removes the spurious sync after updating a reference to a migrated
  library**, so that users don't need to "update" their content _after_
  updating their reference, _unless_ there were real content edits that
  happened since they last synced. We do this by correctly associating a
  DraftChangeLogRecord with the ModulestoreBlockSource migration artifact,
  and then comparing that version information before offering a sync.
  (related issue:
  https://github.com/openedx/frontend-app-authoring/issues/2626).
- **Prompts users to update a reference to a migrated library with higher
  priority than prompting them to sync legacy content updates for that
  reference**, so that users don't end up needing to accept legacy content
  updates in order to get a to a point where they can update to V2 content.
- **Ensures the library references in courses always follow the correct
  migration,** as defined by the data `forwarded` fields in the data model,
  which are populated based on the REST API spec and the stated product UI
  requirements.

* For the migration itself, this PR:

- **Allows non-admins to migrate libraries**, fixing:
  https://github.com/openedx/edx-platform/issues/37774
- **When triggered via the UI, ensures the migration uses nice title-based
  target slugs instead of ugly source-hash-based slugs.** We've had this as an
  option for a long time, but preserve_url_slugs defaulted to True instead of
  False in the REST API serializer, so we weren't taking advantage of it.
- **Unifies logic between single-source and bulk migration**. These were
  implement as two separate code paths, with drift in their implementations. In
  particular, the collection update-vs-create-new logic was completely
  different for single-souce vs. bulk.
- **When using the Skip or Update strategies for repeats, it consistently
  follows mappings established by the latest successful migration** rather than
  following mappings across arbitrary previous migrations.
- **We log unexpected exceptions more often**, although there is so much more
  room for improvement here.
- **Adds more validation to the REST API** so that client mistakes more often
  become 400s with validation messages rather than 500s.

For developers, this PR:
- Adds unit tests to the REST API 
- Ensures that all migration business logic now goes through a general-purpose
  Python API.
- Ensures that the data model (specifically `forwarded`, and
  `change_log_record`) is now populated and respected.
- Adds more type annotations.
2025-12-18 23:49:36 +00:00

135 lines
3.6 KiB
Python

"""
Value objects
"""
from __future__ import annotations
import typing as t
from dataclasses import dataclass
from enum import Enum
from uuid import UUID
from django.utils.translation import gettext_lazy as _
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import (
CourseLocator,
LibraryContainerLocator,
LibraryLocator,
LibraryLocatorV2,
LibraryUsageLocatorV2,
)
from openedx.core.djangoapps.content_libraries.api import ContainerType
class CompositionLevel(Enum):
"""
Enumeration of composition levels for legacy content.
Defined in increasing order of complexity so that `is_higher_than` works correctly.
"""
# Components are individual XBlocks, e.g. Problem
Component = 'component'
# Container types currently supported by Content Libraries
Unit = ContainerType.Unit.value
Subsection = ContainerType.Subsection.value
Section = ContainerType.Section.value
@property
def is_container(self) -> bool:
return self is not self.Component
def is_higher_than(self, other: 'CompositionLevel') -> bool:
"""
Is this composition level 'above' (more complex than) the other?
"""
levels: list[CompositionLevel] = list(self.__class__)
return levels.index(self) > levels.index(other)
@classmethod
def supported_choices(cls) -> list[tuple[str, str]]:
"""
Returns all supported composition levels as a list of tuples,
for use in a Django Models ChoiceField.
"""
return [
(composition_level.value, composition_level.name)
for composition_level in cls
]
class RepeatHandlingStrategy(Enum):
"""
Enumeration of repeat handling strategies for imported content.
"""
Skip = 'skip'
Fork = 'fork'
Update = 'update'
@classmethod
def supported_choices(cls) -> list[tuple[str, str]]:
"""
Returns all supported repeat handling strategies as a list of tuples,
for use in a Django Models ChoiceField.
"""
return [
(strategy.value, strategy.name)
for strategy in cls
]
@classmethod
def default(cls) -> RepeatHandlingStrategy:
"""
Returns the default repeat handling strategy.
"""
return cls.Skip
SourceContextKey: t.TypeAlias = CourseLocator | LibraryLocator
@dataclass(frozen=True)
class ModulestoreMigration:
"""
Metadata on a migration of a course or legacy library to a v2 library in learning core.
"""
pk: int
source_key: SourceContextKey
target_key: LibraryLocatorV2
target_title: str
target_collection_slug: str | None
target_collection_title: str | None
is_failed: bool
task_uuid: UUID # the UserTask which executed this migration
@dataclass(frozen=True)
class ModulestoreBlockMigrationResult:
"""
Base class for a modulestore block that was part of an attempted migration to learning core.
"""
source_key: UsageKey
is_failed: t.ClassVar[bool]
@dataclass(frozen=True)
class ModulestoreBlockMigrationSuccess(ModulestoreBlockMigrationResult):
"""
Info on a modulestore block which has been successfully migrated into an LC entity
"""
target_entity_pk: int
target_key: LibraryUsageLocatorV2 | LibraryContainerLocator
target_title: str
target_version_num: int | None
is_failed: t.ClassVar[bool] = False
@dataclass(frozen=True)
class ModulestoreBlockMigrationFailure(ModulestoreBlockMigrationResult):
"""
Info on a modulestore block which failed to be migrated into LC
"""
unsupported_reason: str
is_failed: t.ClassVar[bool] = True