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.
135 lines
3.6 KiB
Python
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
|