Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 47 additions & 2 deletions cms/djangoapps/import_from_modulestore/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""
from django.contrib import admin

from .api import import_to_library
from .forms import ImportCreateForm
from .models import Import, PublishableEntityImport, PublishableEntityMapping


Expand All @@ -19,15 +21,58 @@ class ImportAdmin(admin.ModelAdmin):
'target_change',
)
list_filter = (
'status',
'user_task_status__state',
)
search_fields = (
'source_key',
'target_change',
)

raw_id_fields = ('user',)
readonly_fields = ('status',)
readonly_fields = ('user_task_status',)

def uuid(self, obj):
"""
Returns the UUID of the import.
"""
return getattr(obj.user_task_status, 'uuid', None)
uuid.short_description = 'UUID'

def created(self, obj):
"""
Returns the creation date of the import.
"""
return getattr(obj.user_task_status, 'created', None)

def status(self, obj):
"""
Returns the status of the import.
"""
return getattr(obj.user_task_status, 'state', None)

def get_form(self, request, obj=None, change=None, **kwargs):
if not obj:
return ImportCreateForm
return super().get_form(request, obj, change, **kwargs)

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:
_import, _ = import_to_library(
source_key=form.cleaned_data['source_key'],
usage_ids=form.cleaned_data['usage_keys_string'],
target_learning_package_id=form.cleaned_data['library'].pk,
user_id=form.cleaned_data['user'].pk,
composition_level=form.cleaned_data['composition_level'],
override=form.cleaned_data['override'],
)
_import.target_change = form.cleaned_data['target_change']
_import.save(update_fields=['target_change'])


admin.site.register(Import, ImportAdmin)
Expand Down
36 changes: 17 additions & 19 deletions cms/djangoapps/import_from_modulestore/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,37 @@

from .helpers import cancel_incomplete_old_imports
from .models import Import as _Import
from .tasks import import_staged_content_to_library_task, save_legacy_content_to_staged_content_task
from .tasks import import_to_library_task
from .validators import validate_usage_keys_to_import


def stage_content_for_import(source_key: LearningContextKey, user_id: int) -> _Import:
"""
Create a new import event to import a course to a library and save course to staged content.
"""
import_from_modulestore = _Import.objects.create(source_key=source_key, user_id=user_id)
cancel_incomplete_old_imports(import_from_modulestore)
save_legacy_content_to_staged_content_task.delay_on_commit(import_from_modulestore.uuid)
return import_from_modulestore


def import_staged_content_to_library(
def import_to_library(
source_key: LearningContextKey,
usage_ids: Sequence[str | UsageKey],
import_uuid: str,
target_learning_package_id: int,
user_id: int,
composition_level: str,
override: bool,
) -> None:
override: bool = False,
) -> _Import:
"""
Import staged content to a library from staged content.
"""
validate_usage_keys_to_import(usage_ids)
import_staged_content_to_library_task.apply_async(

import_from_modulestore = _Import.objects.create(
source_key=source_key,
user_id=user_id,
composition_level=composition_level,
override=override,
)
cancel_incomplete_old_imports(import_from_modulestore)

import_to_library_task.apply_async(
kwargs={
'import_pk': import_from_modulestore.pk,
'usage_key_strings': usage_ids,
'import_uuid': import_uuid,
'learning_package_id': target_learning_package_id,
'user_id': user_id,
'composition_level': composition_level,
'override': override,
},
)
return import_from_modulestore
33 changes: 24 additions & 9 deletions cms/djangoapps/import_from_modulestore/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,27 @@
from enum import Enum
from openedx.core.djangoapps.content_libraries import api as content_libraries_api

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


class ImportStatus(TextChoices):
class ImportStatus(Enum):
"""
The status of this modulestore-to-learning-core import.
"""

NOT_STARTED = 'not_started', _('Waiting to stage content')
STAGING = 'staging', _('Staging content for import')
WAITNG_TO_STAGE = _('Waiting to stage content')
STAGING = _('Staging content for import')
STAGING_FAILED = _('Failed to stage content')
STAGED = 'staged', _('Content is staged and ready for import')
IMPORTING = 'importing', _('Importing staged content')
IMPORTING_FAILED = 'importing_failed', _('Failed to import staged content')
IMPORTED = 'imported', _('Successfully imported content')
CANCELED = 'canceled', _('Canceled')
STAGED = _('Content is staged and ready for import')
IMPORTING = _('Importing staged content')
IMPORTING_FAILED = _('Failed to import staged content')
IMPORTED = _('Staged content imported successfully')
CANCELED = _('Import canceled')

FAILED_STATUSES = [
STAGING_FAILED,
IMPORTING_FAILED,
]


class CompositionLevel(Enum):
Expand Down Expand Up @@ -50,5 +54,16 @@ def values(cls):
"""
return [composition_level.value for composition_level in cls]

@classmethod
def choices(cls):
"""
Returns all levels of composition levels as a list of tuples.
"""
return [
(composition_level.value, composition_level.name)
for composition_level in cls
if not isinstance(composition_level.value, list)
]


PublishableVersionWithMapping = namedtuple('PublishableVersionWithMapping', ['publishable_version', 'mapping'])
48 changes: 48 additions & 0 deletions cms/djangoapps/import_from_modulestore/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
This module contains forms for the Import model and related functionality.
"""
from django import forms
from django.contrib import admin
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.contrib.auth import get_user_model
from openedx.core.djangoapps.content_libraries.api import ContentLibrary

from .models import Import as _Import
from .validators import validate_usage_keys_to_import


User = get_user_model()
admin.autodiscover()


class ImportCreateForm(forms.ModelForm):
"""
Form for creating an Import instance.
"""
class Meta:
model = _Import
fields = ['source_key', 'user', 'target_change', 'composition_level', 'override']

user = forms.ModelChoiceField(
queryset=User.objects.all(),
required=True,
label='User',
widget=ForeignKeyRawIdWidget(_Import._meta.get_field('user').remote_field, admin.site)
)
usage_keys_string = forms.CharField(
widget=forms.Textarea(attrs={
'placeholder': 'Comma separated list of usage keys to import.',
}),
required=True,
label='Usage Keys to Import',
)
library = forms.ModelChoiceField(queryset=ContentLibrary.objects.all(), required=False)

def clean_usage_keys_string(self):
"""
Validate the usage keys string.
"""
usage_keys_string = self.cleaned_data.get('usage_keys_string')
splitted_keys = usage_keys_string.split(',')
validate_usage_keys_to_import(splitted_keys)
return usage_keys_string.split(',')
4 changes: 2 additions & 2 deletions cms/djangoapps/import_from_modulestore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def import_from_staged_content(self) -> list[PublishableVersionWithMapping]:
if block_to_import is None:
return []

return self._process_import(self.block_usage_key_to_import, block_to_import)
return self._import_complicated_child(block_to_import, self.block_usage_key_to_import)

def _process_import(self, usage_key_string, block_to_import) -> list[PublishableVersionWithMapping]:
"""
Expand Down Expand Up @@ -461,6 +461,6 @@ def cancel_incomplete_old_imports(import_event: Import) -> None:
target_change=import_event.target_change,
source_key=import_event.source_key,
staged_content_for_import__isnull=False
).exclude(uuid=import_event.uuid)
).exclude(pk=import_event.pk)
for incomplete_import in incomplete_user_imports_with_same_target:
incomplete_import.set_status(ImportStatus.CANCELED)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Generated by Django 4.2.20 on 2025-05-14 17:31

from django.db import migrations, models
import django.db.models.deletion
from openedx.core.djangoapps.content_libraries.api import ContainerType


class Migration(migrations.Migration):

dependencies = [
('user_tasks', '0004_url_textfield'),
('import_from_modulestore', '0001_initial'),
]

operations = [
migrations.RemoveField(
model_name='import',
name='created',
),
migrations.RemoveField(
model_name='import',
name='modified',
),
migrations.RemoveField(
model_name='import',
name='status',
),
migrations.RemoveField(
model_name='import',
name='uuid',
),
migrations.AddField(
model_name='import',
name='composition_level',
field=models.CharField(choices=[(ContainerType['Section'], 'CHAPTER'), (ContainerType['Subsection'], 'SEQUENTIAL'), (ContainerType['Unit'], 'VERTICAL'), ('component', 'COMPONENT')], default='component', help_text='The composition level of the target learning package', max_length=255),
),
migrations.AddField(
model_name='import',
name='override',
field=models.BooleanField(default=False, help_text='If true, the import will override any existing content in the target learning package.'),
),
migrations.AddField(
model_name='import',
name='user_task_status',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_tasks.usertaskstatus'),
),
]
38 changes: 27 additions & 11 deletions cms/djangoapps/import_from_modulestore/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
Models for the course to library import app.
"""
import uuid as uuid_tools

from django.contrib.auth import get_user_model
from django.db import models
Expand All @@ -14,29 +13,41 @@
)
from openedx_learning.api.authoring_models import LearningPackage, PublishableEntity

from .data import ImportStatus
from .data import CompositionLevel, ImportStatus

User = get_user_model()


class Import(TimeStampedModel):
class Import(models.Model):
"""
Represents the action of a user importing a modulestore-based course or legacy
library into a learning-core based learning package (today, that is always a content library).
"""

uuid = models.UUIDField(default=uuid_tools.uuid4, editable=False, unique=True)
status = models.CharField(
max_length=100,
choices=ImportStatus.choices,
default=ImportStatus.NOT_STARTED,
db_index=True
user_task_status = models.OneToOneField(
'user_tasks.UserTaskStatus',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='import_event',
)
user = models.ForeignKey(User, on_delete=models.CASCADE)

# Note: For now, this will always be a course key. In the future, it may be a legacy library key.
source_key = LearningContextKeyField(help_text=_('The modulestore course'), max_length=255, db_index=True)
target_change = models.ForeignKey(to='oel_publishing.DraftChangeLog', on_delete=models.SET_NULL, null=True)
composition_level = models.CharField(
max_length=255,
choices=CompositionLevel.choices(),
help_text=_('The composition level of the target learning package'),
default=CompositionLevel.COMPONENT.value,
)
override = models.BooleanField(
default=False,
help_text=_(
'If true, the import will override any existing content in the target learning package.'
),
)

class Meta:
verbose_name = _('Import from modulestore')
Expand All @@ -49,8 +60,13 @@ def set_status(self, status: ImportStatus):
"""
Set import status.
"""
self.status = status
self.save()
if status in ImportStatus.FAILED_STATUSES.value:
self.user_task_status.fail(status)
elif status == ImportStatus.CANCELED:
self.user_task_status.cancel()
else:
self.user_task_status.set_state(status)
self.user_task_status.save()
if status in [ImportStatus.IMPORTED, ImportStatus.CANCELED]:
self.clean_related_staged_content()

Expand Down
Loading
Loading