Files
edx-platform/cms/djangoapps/modulestore_migrator/admin.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

194 lines
7.3 KiB
Python

"""
A nice little admin interface for migrating courses and libraries from modulstore to Learning Core.
"""
import logging
from django import forms
from django.contrib import admin, messages
from django.contrib.admin.helpers import ActionForm
from django.db import models
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryLocatorV2
from user_tasks.models import UserTaskStatus
from openedx.core.types.http import AuthenticatedHttpRequest
from . import api
from .data import CompositionLevel, RepeatHandlingStrategy
from .models import ModulestoreSource, ModulestoreMigration, ModulestoreBlockSource, ModulestoreBlockMigration
log = logging.getLogger(__name__)
class StartMigrationTaskForm(ActionForm):
"""
Params for start_migration_task admin adtion, displayed next the "Go" button.
"""
target_key = forms.CharField(label="Target library or collection key →", required=False)
repeat_handling_strategy = forms.ChoiceField(
label="How to handle existing content? →",
choices=RepeatHandlingStrategy.supported_choices,
required=False,
)
preserve_url_slugs = forms.BooleanField(label="Preserve current slugs? →", required=False, initial=True)
forward_to_target = forms.BooleanField(label="Forward references? →", required=False)
composition_level = forms.ChoiceField(
label="Aggregate up to →", choices=CompositionLevel.supported_choices, required=False
)
def task_status_details(obj: ModulestoreMigration) -> str:
"""
Return the state and, if available, details of the status of the migration.
"""
details: str | None = None
if obj.task_status.state == UserTaskStatus.FAILED:
# Calling fail(msg) from a task should automatically generates an "Error" artifact with that msg.
# https://django-user-tasks.readthedocs.io/en/latest/user_tasks.html#user_tasks.models.UserTaskStatus.fail
if error_artifacts := obj.task_status.artifacts.filter(name="Error"):
if error_text := error_artifacts.order_by("-created").first().text:
details = error_text
elif obj.task_status.state == UserTaskStatus.SUCCEEDED:
details = f"Migrated {obj.block_migrations.count()} blocks"
return f"{obj.task_status.state}: {details}" if details else obj.task_status.state
migration_admin_fields = (
"target",
"target_collection",
"task_status",
# The next line works, but django-stubs incorrectly thinks that these should all be strings,
# so we will need to use type:ignore below.
task_status_details,
"composition_level",
"repeat_handling_strategy",
"preserve_url_slugs",
"change_log",
"staged_content",
)
class ModulestoreMigrationInline(admin.TabularInline):
"""
Readonly table within the ModulestoreSource page; each row is a Migration from this Source.
"""
model = ModulestoreMigration
fk_name = "source"
show_change_link = True
readonly_fields = migration_admin_fields # type: ignore[assignment]
ordering = ("-task_status__created",)
def has_add_permission(self, _request, _obj):
return False
class ModulestoreBlockSourceInline(admin.TabularInline):
"""
Readonly table within the ModulestoreSource page; each row is a BlockSource.
"""
model = ModulestoreBlockSource
fk_name = "overall_source"
readonly_fields = (
"key",
"forwarded"
)
def has_add_permission(self, _request, _obj):
return False
@admin.register(ModulestoreSource)
class ModulestoreSourceAdmin(admin.ModelAdmin):
"""
Admin interface for source legacy libraries and courses.
"""
readonly_fields = ("forwarded",)
list_display = ("id", "key", "forwarded")
actions = ["start_migration_task"]
action_form = StartMigrationTaskForm
inlines = [ModulestoreMigrationInline, ModulestoreBlockSourceInline]
@admin.action(description="Start migration for selected sources")
def start_migration_task(
self,
request: AuthenticatedHttpRequest,
queryset: models.QuerySet[ModulestoreSource],
) -> None:
"""
Start a migration for each selected source
"""
form = StartMigrationTaskForm(request.POST)
form.is_valid()
target_key_string = form.cleaned_data['target_key']
if not target_key_string:
messages.add_message(request, messages.ERROR, "Target key is required")
return
try:
target_library_key = LibraryLocatorV2.from_string(target_key_string)
target_collection_slug = None
except InvalidKeyError:
try:
target_collection_key = LibraryCollectionLocator.from_string(target_key_string)
target_library_key = target_collection_key.lib_key
target_collection_slug = target_collection_key.collection_id
except InvalidKeyError:
messages.add_message(request, messages.ERROR, f"Invalid target key: {target_key_string}")
return
started = 0
total = 0
for source in queryset:
total += 1
try:
api.start_migration_to_library(
user=request.user,
source_key=source.key,
target_library_key=target_library_key,
target_collection_slug=target_collection_slug,
composition_level=CompositionLevel(form.cleaned_data['composition_level']),
repeat_handling_strategy=RepeatHandlingStrategy(form.cleaned_data['repeat_handling_strategy']),
preserve_url_slugs=form.cleaned_data['preserve_url_slugs'],
forward_source_to_target=form.cleaned_data['forward_to_target'],
)
except Exception as exc: # pylint: disable=broad-except
message = f"Failed to start migration {source.key} -> {target_key_string}"
messages.add_message(request, messages.ERROR, f"{message}: {exc}")
log.exception(message)
continue
started += 1
click_in = "Click into the source objects to see migration details."
if not started:
messages.add_message(request, messages.WARNING, f"Failed to start {total} migration(s).")
if started < total:
messages.add_message(request, messages.WARNING, f"Started {started} of {total} migration(s). {click_in}")
else:
messages.add_message(request, messages.INFO, f"Started {started} migration(s). {click_in}")
class ModulestoreBlockMigrationInline(admin.TabularInline):
"""
Readonly table witin the Migration admin; each row is a block
"""
model = ModulestoreBlockMigration
fk_name = "overall_migration"
readonly_fields = (
"source",
"target",
"change_log_record",
"unsupported_reason",
)
list_display = ("id", *readonly_fields)
@admin.register(ModulestoreMigration)
class ModulestoreMigrationAdmin(admin.ModelAdmin):
"""
Readonly admin page for viewing Migrations
"""
readonly_fields = ("source", *migration_admin_fields) # type: ignore[assignment]
list_display = ("id", "source", *migration_admin_fields) # type: ignore[assignment]
inlines = [ModulestoreBlockMigrationInline]