Files
Kyle McCormick c70bfe980a build!: Switch to openedx-core (renamed from openedx-learning) (#38011)
build!: Switch to openedx-core (renamed from openedx-learning)

Instead of installing openedx-learning==0.32.0, we install openedx-core==0.34.1.
We update various class names, function names, docstrings, and comments to
represent the rename:

* We say "openedx-core" when referring to the whole repo or PyPI project
  * or occasionally "Open edX Core" if we want it to look nice in the docs.
* We say "openedx_content" to refer to the Content API within openedx-core,
   which is actually the thing we have been calling "Learning Core" all along.
  * In snake-case code, it's `*_openedx_content_*`.
  * In camel-case code, it's `*OpenedXContent*`

For consistency's sake we avoid anything else like oex_core, OeXCore,
OpenEdXCore, OexContent, openedx-content, OpenEdxContent, etc.
There should be no more references to learning_core, learning-core, Learning Core,
Learning-Core, LC, openedx-learning, openedx_learning, etc.

BREAKING CHANGE: for openedx-learning/openedx-core developers:
You may need to uninstall openedx-learning and re-install openedx-core
from your venv. If running tutor, you may need to un-mount openedx-learning,
rename the directory to openedx-core, re-mount it, and re-build.
The code APIs themselves are fully backwards-compatible.

Part of: https://github.com/openedx/openedx-core/issues/470
2026-02-18 22:38:25 +00:00

194 lines
7.3 KiB
Python

"""
A nice little admin interface for migrating courses and libraries from modulstore to openedx_content.
"""
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]