Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5bec7d0
feat: [AXM-1607] create initial DB layer (#2601)
andrii-hantkovskyi Mar 7, 2025
d765543
feat: [AXM-1621] add staged content creation (#2602)
andrii-hantkovskyi Mar 12, 2025
2f13155
feat: [AXM-1614] add course to library import feature (#2603)
andrii-hantkovskyi Mar 17, 2025
c75c0a4
docs: add readme
NiedielnitsevIvan Mar 17, 2025
05c4195
feat: [AXM-1726] add container feature for import (#2607)
andrii-hantkovskyi Mar 21, 2025
530157d
refactor: add uuid and remove source_type field to CourseToLibraryImp…
NiedielnitsevIvan Mar 21, 2025
7157981
feat: [AXM-1635] create block importing route (#2611)
andrii-hantkovskyi Mar 24, 2025
8405b21
feat: [AXM-1780] add static tabs importing (#2614)
andrii-hantkovskyi Mar 25, 2025
a0c88f5
feat: REST API to get and create imports (#2612)
NiedielnitsevIvan Mar 28, 2025
39160be
refactor: refactor importing models and related functionality (#2618)
NiedielnitsevIvan Apr 3, 2025
e5dbb88
fix: [AXM-1823] static assets collision during import (#2622)
andrii-hantkovskyi Apr 3, 2025
2305ace
feat: add admin action to import course to library (#2615)
NiedielnitsevIvan Apr 3, 2025
acf2e8f
fix: [AXM-1869] error on container creation if exists (#2625)
andrii-hantkovskyi Apr 3, 2025
c19f13f
fix: fix deadlock when import created from admin panel (#2628)
NiedielnitsevIvan Apr 3, 2025
d20dae2
fix: fix tasks running (#2630)
NiedielnitsevIvan Apr 4, 2025
7d3ffa6
fix: add tasks to app ready method
NiedielnitsevIvan Apr 4, 2025
d6a70a5
test: fix tests after adding 'transaction.on_commit' running task
NiedielnitsevIvan Apr 8, 2025
3046231
test: [AXM-1870] add tests for overried & different composition lvls …
andrii-hantkovskyi Apr 9, 2025
0d9e1d0
refactor: add translations for admin template (#2629)
andrii-hantkovskyi Apr 9, 2025
aa35955
refactor: refactor after rebasing
NiedielnitsevIvan Apr 10, 2025
6b703b4
fix: [AXM-1877] staged content not creating (#2632)
andrii-hantkovskyi Apr 10, 2025
8dd0605
fix: fix tests after rebase
NiedielnitsevIvan Apr 10, 2025
ffe6598
fix: [AXM-1877] staged content not creating after import create (#2633)
andrii-hantkovskyi Apr 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/unit-test-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@
"cms/djangoapps/api/",
"cms/djangoapps/cms_user_tasks/",
"cms/djangoapps/course_creators/",
"cms/djangoapps/import_from_modulestore/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
Expand Down
32 changes: 32 additions & 0 deletions cms/djangoapps/import_from_modulestore/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
========================
Course to Library Import
========================

The new Django application `import_from_modulestore` is designed to
automate the process of importing course OLX content from Modulestore to
Content Libraries. The application allows users to easily and quickly
migrate existing course content, minimizing the manual work and potential
errors associated with manual migration.
The new app makes the import process automated and easy to manage.

The main problems solved by the application:

* Reducing the time to import course content.
* Ensuring data integrity during the transfer.
* Ability to choose which content to import before the final import.

------------------------------
Course to Library Import Usage
------------------------------

* Import course elements at the level of sections, subsections, units,
and xblocks into the Content Libraries.
* Choose the structure of this import, whether it will be only xblocks
from a particular course or full sections/subsections/units.
* Store the history of imports.
* Synchronize the course content with the library content (when re-importing,
the blocks can be updated according to changes in the original course).
* The new import mechanism ensures data integrity at the time of importing
by saving the course in StagedContent.


Empty file.
159 changes: 159 additions & 0 deletions cms/djangoapps/import_from_modulestore/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
This module contains the admin configuration for the Import model.
"""
from django import forms
from django.contrib import admin, messages
from django.http import HttpResponseRedirect
from django.template.response import TemplateResponse
from django.utils.translation import gettext_lazy as _

from opaque_keys.edx.keys import UsageKey
from opaque_keys import InvalidKeyError

from . import api
from .data import ImportStatus
from .models import Import, PublishableEntityImport, PublishableEntityMapping
from .tasks import save_legacy_content_to_staged_content_task

COMPOSITION_LEVEL_CHOICES = (
('xblock', _('XBlock')),
('vertical', _('Unit')),
('sequential', _('Section')),
('chapter', _('Chapter')),
)


def _validate_block_keys(model_admin, request, block_keys_to_import):
"""
Validate the block keys to import.
"""
block_keys_to_import = block_keys_to_import.split(',')
for block_key in block_keys_to_import:
try:
UsageKey.from_string(block_key)
except InvalidKeyError:
model_admin.message_user(
request,
_('Invalid block key: {block_key}').format(block_key=block_key),
level=messages.ERROR,
)
return False
return True


class ImportActionForm(forms.Form):
"""
Form for the CourseToLibraryImport action.
"""

composition_level = forms.ChoiceField(
choices=COMPOSITION_LEVEL_CHOICES,
required=False,
label='Composition Level'
)
override = forms.BooleanField(
required=False,
label='Override Existing Content'
)
block_keys_to_import = forms.CharField(
widget=forms.Textarea(attrs={
'placeholder': 'Comma separated list of block keys to import.',
'rows': 4
}),
required=False,
label='Block Keys to Import'
)


class ImportAdmin(admin.ModelAdmin):
"""
Admin configuration for the Import model.
"""

list_display = (
'uuid',
'created',
'status',
'source_key',
'target',
)
list_filter = (
'status',
)
search_fields = (
'source_key',
'target',
)

raw_id_fields = ('user',)
readonly_fields = ('status',)
actions = ['import_course_to_library_action']

def save_model(self, request, obj, form, change):
"""
Launches the creation of Staged Content after creating a new import instance.
"""
is_created = not getattr(obj, 'id', None)
super().save_model(request, obj, form, change)
if is_created:
save_legacy_content_to_staged_content_task.delay_on_commit(obj.uuid)

def import_course_to_library_action(self, request, queryset):
"""
Import selected courses to the library.
"""
form = ImportActionForm(request.POST or None)

if request.POST and 'apply' in request.POST:
if form.is_valid():
block_keys_string = form.cleaned_data['block_keys_to_import']
are_keys_valid = _validate_block_keys(self, request, block_keys_string)
if not are_keys_valid:
return

target_key_string = block_keys_string.split(',') if block_keys_string else []
composition_level = form.cleaned_data['composition_level']
override = form.cleaned_data['override']

if not queryset.count() == queryset.filter(status=ImportStatus.READY).count():
self.message_user(
request,
_('Only imports with status "Ready" can be imported to the library.'),
level=messages.ERROR,
)
return

for obj in queryset:
api.import_course_staged_content_to_library(
usage_ids=target_key_string,
import_uuid=str(obj.uuid),
user_id=request.user.pk,
composition_level=composition_level,
override=override,
)

self.message_user(
request,
_('Importing courses to library.'),
level=messages.SUCCESS,
)

return HttpResponseRedirect(request.get_full_path())

return TemplateResponse(
request,
'admin/custom_course_import_form.html',
{
'form': form,
'queryset': queryset,
'action_name': 'import_course_to_library_action',
'title': _('Import Selected Courses to Library')
}
)

import_course_to_library_action.short_description = _('Import selected courses to library')


admin.site.register(Import, ImportAdmin)
admin.site.register(PublishableEntityImport)
admin.site.register(PublishableEntityMapping)
39 changes: 39 additions & 0 deletions cms/djangoapps/import_from_modulestore/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
API for course to library import.
"""
from .models import Import as _Import
from .tasks import import_course_staged_content_to_library_task, save_legacy_content_to_staged_content_task


def import_course_staged_content_to_library(
usage_ids: list[str],
import_uuid: str,
user_id: int,
composition_level: str,
override: bool
) -> None:
"""
Import staged content to a library.
"""
import_course_staged_content_to_library_task.apply_async(
kwargs={
'usage_ids': usage_ids,
'import_uuid': import_uuid,
'user_id': user_id,
'composition_level': composition_level,
'override': override,
},
)


def create_import(source_key, user_id: int, learning_package_id: int) -> _Import:
"""
Create a new import task to import a course to a library.
"""
import_from_modulestore = _Import.objects.create(
source_key=source_key,
target_id=learning_package_id,
user_id=user_id,
)
save_legacy_content_to_staged_content_task.delay_on_commit(import_from_modulestore.uuid)
return import_from_modulestore
19 changes: 19 additions & 0 deletions cms/djangoapps/import_from_modulestore/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
App for importing from the modulestore tools.
"""

from django.apps import AppConfig


class ImportFromModulestoreConfig(AppConfig):
"""
App for importing legacy content from the modulestore.
"""

name = 'cms.djangoapps.import_from_modulestore'

def ready(self):
"""
Connect handlers to signals.
"""
from . import signals, tasks # pylint: disable=unused-import, import-outside-toplevel
5 changes: 5 additions & 0 deletions cms/djangoapps/import_from_modulestore/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Constants for import_from_modulestore app
"""

IMPORT_FROM_MODULESTORE_PURPOSE = "import_from_modulestore"
50 changes: 50 additions & 0 deletions cms/djangoapps/import_from_modulestore/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
This module contains the data models for the import_from_modulestore app.
"""
from enum import Enum

from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _


class ImportStatus(TextChoices):
"""
The status of this course import.
"""

# PENDING: The import has been created, but the OLX and related data are not yet in the library.
# It is not ready to be read.
PENDING = 'pending', _('Pending')
# READY: The content is staged and ready to be read.
READY = 'ready', _('Ready')
# IMPORTED: The content has been imported into the library.
IMPORTED = 'imported', _('Imported')
# CANCELED: The import was canceled before it was imported.
CANCELED = 'canceled', _('Canceled')
# ERROR: The content could not be imported.
ERROR = 'error', _('Error')


class CompositionLevel(Enum):
"""
Enumeration of composition levels for course content.

Defines the different levels of composition for course content,
including chapters, sequentials, verticals, and xblocks.
It also categorizes these levels into complicated and flat
levels for easier processing.
"""

CHAPTER = 'chapter'
SEQUENTIAL = 'sequential'
VERTICAL = 'vertical'
XBLOCK = 'xblock'
COMPLICATED_LEVELS = [CHAPTER, SEQUENTIAL, VERTICAL]
FLAT_LEVELS = [XBLOCK]

@classmethod
def values(cls):
"""
Returns all levels of composition levels.
"""
return [composition_level.value for composition_level in cls]
Loading
Loading