diff --git a/cms/djangoapps/import_from_modulestore/api.py b/cms/djangoapps/import_from_modulestore/api.py new file mode 100644 index 000000000000..2136a295b716 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/api.py @@ -0,0 +1,44 @@ +""" +API for course to library import. +""" +from opaque_keys.edx.keys import LearningContextKey + +from .models import Import as _Import +from .tasks import import_course_staged_content_to_library_task, save_legacy_content_to_staged_content_task +from .validators import validate_usage_keys_to_import + + +def create_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, + ) + save_legacy_content_to_staged_content_task.delay_on_commit(import_from_modulestore.uuid) + return import_from_modulestore + + +def import_course_staged_content_to_library( + usage_ids: list[str], + import_uuid: str, + target_learning_package_id: int, + user_id: int, + composition_level: str, + override: bool, +) -> None: + """ + Import staged content to a library from staged content. + """ + validate_usage_keys_to_import(usage_ids) + import_course_staged_content_to_library_task.apply_async( + kwargs={ + 'usage_keys_string': usage_ids, + 'import_uuid': import_uuid, + 'learning_package_id': target_learning_package_id, + 'user_id': user_id, + 'composition_level': composition_level, + 'override': override, + }, + ) diff --git a/cms/djangoapps/import_from_modulestore/constants.py b/cms/djangoapps/import_from_modulestore/constants.py new file mode 100644 index 000000000000..099acfbc2b7b --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/constants.py @@ -0,0 +1,5 @@ +""" +Constants for import_from_modulestore app +""" + +IMPORT_FROM_MODULESTORE_PURPOSE = "import_from_modulestore" diff --git a/cms/djangoapps/import_from_modulestore/data.py b/cms/djangoapps/import_from_modulestore/data.py index 7821e463a76a..0a891f2f30a3 100644 --- a/cms/djangoapps/import_from_modulestore/data.py +++ b/cms/djangoapps/import_from_modulestore/data.py @@ -1,6 +1,9 @@ """ This module contains the data models for the import_from_modulestore app. """ +from collections import namedtuple +from enum import Enum + from django.db.models import TextChoices from django.utils.translation import gettext_lazy as _ @@ -18,3 +21,30 @@ class ImportStatus(TextChoices): IMPORTING_FAILED = 'importing_failed', _('Failed to import staged content') IMPORTED = 'imported', _('Successfully imported content') CANCELED = 'canceled', _('Canceled') + + +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] + + +PublishableVersionWithMapping = namedtuple('PublishableVersionWithMapping', ['publishable_version', 'mapping']) diff --git a/cms/djangoapps/import_from_modulestore/helpers.py b/cms/djangoapps/import_from_modulestore/helpers.py new file mode 100644 index 000000000000..12a2505d726b --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/helpers.py @@ -0,0 +1,416 @@ +""" +Helper functions for importing course content into a library. +""" +from datetime import datetime, timezone +import logging +import mimetypes +import os +import secrets + +from django.db import transaction +from django.db.utils import IntegrityError +from lxml import etree + +from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import CourseLocator +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Component, Container, ContainerVersion, PublishableEntity + +from openedx.core.djangoapps.content_libraries import api +from openedx.core.djangoapps.content_staging import api as content_staging_api +from xmodule.modulestore.django import modulestore + +from .data import CompositionLevel, PublishableVersionWithMapping +from .models import Import, PublishableEntityMapping + + +log = logging.getLogger(__name__) +parser = etree.XMLParser(strip_cdata=False) + + +class ImportClient: + """ + Client for importing course content into a library. + + This class handles the import of course content from staged content into a + content library, creating the appropriate container hierarchy based on the + specified composition level. + """ + + CONTAINER_CREATORS_MAP = { + 'chapter': authoring_api.create_unit_and_version, # TODO: replace with create_module_and_version + 'sequential': authoring_api.create_unit_and_version, # TODO: replace with create_section_and_version + 'vertical': authoring_api.create_unit_and_version, + } + + CONTAINER_OVERRIDERS_MAP = { + 'chapter': authoring_api.create_next_unit_version, # TODO: replace with create_next_module_version + 'sequential': authoring_api.create_next_unit_version, # TODO: replace with create_next_section_version + 'vertical': authoring_api.create_next_unit_version, + } + + def __init__( + self, + import_event: Import, + block_usage_key_to_import: str, + target_learning_package: 'LearningPackage', + staged_content: 'StagedContent', + composition_level: str, + override: bool = False, + ): + self.import_event = import_event + self.block_usage_key_to_import = block_usage_key_to_import + self.learning_package = target_learning_package + self.staged_content = staged_content + self.composition_level = composition_level + self.override = override + + self.user_id = import_event.user_id + self.content_library = target_learning_package.contentlibrary + self.library_key = self.content_library.library_key + self.parser = etree.XMLParser(strip_cdata=False) + + def import_from_staged_content(self) -> list[PublishableVersionWithMapping]: + """ + Import staged content into a library. + """ + node = etree.fromstring(self.staged_content.olx, parser=parser) + usage_key = UsageKey.from_string(self.block_usage_key_to_import) + block_to_import = get_block_to_import(node, usage_key) + if block_to_import is None: + return [] + + return self._process_import(self.block_usage_key_to_import, block_to_import) + + def _process_import(self, usage_key_string, block_to_import) -> list[PublishableVersionWithMapping]: + """ + Process import of a block from staged content into a library. + + Imports a block and its children into the library based on the + composition level. It handles both simple and complicated blocks, creating + the necessary container hierarchy. + """ + usage_key = UsageKey.from_string(usage_key_string) + result = [] + + if block_to_import.tag not in CompositionLevel.COMPLICATED_LEVELS.value: + return self._import_simple_block(block_to_import, usage_key) + + for child in block_to_import.getchildren(): + child_usage_key_string = get_usage_key_string_from_staged_content( + self.staged_content, child.get('url_name') + ) + if not child_usage_key_string: + continue + + result.extend(self._import_child_block(child, child_usage_key_string)) + + if self.composition_level in CompositionLevel.FLAT_LEVELS.value: + return [ + publishable_version_with_mapping for publishable_version_with_mapping in result + if not isinstance(publishable_version_with_mapping.publishable_version, ContainerVersion) + ] + return result + + def _import_simple_block(self, block_to_import, usage_key) -> list[PublishableVersionWithMapping]: + """ + Import a simple block into the library. + + Creates a block in the library from the staged content block. + It returns a list containing the created component version. + """ + publishable_version_with_mapping = self._create_block_in_library(block_to_import, usage_key) + return [publishable_version_with_mapping] if publishable_version_with_mapping else [] + + def _import_child_block(self, child, child_usage_key_string): + """ + Import a child block into the library. + + Determines whether the child block is simple or complicated and + delegates the import process to the appropriate helper method. + """ + child_usage_key = UsageKey.from_string(child_usage_key_string) + if child.tag in CompositionLevel.COMPLICATED_LEVELS.value: + return self._import_complicated_child(child, child_usage_key_string) + else: + return self._import_simple_block(child, child_usage_key) + + def _import_complicated_child(self, child, child_usage_key_string): + """ + Import a complicated child block into the library. + + Handles the import of complicated child blocks, including creating + containers and updating components. + Returns a list containing the created container version. + """ + if not self._should_create_container(child.tag): + return self._process_import(child_usage_key_string, child) + + container_version_with_mapping = self.get_or_create_container( + child.tag, + child.get('url_name'), + child.get('display_name', child.tag), + child_usage_key_string, + ) + child_component_versions_with_mapping = self._process_import(child_usage_key_string, child) + child_component_versions = [ + child_component_version.publishable_version for child_component_version + in child_component_versions_with_mapping + ] + self._update_container_components(container_version_with_mapping.publishable_version, child_component_versions) + return [container_version_with_mapping] + child_component_versions_with_mapping + + def _should_create_container(self, container_type: str) -> bool: + """ + Determine if a new container should be created. + + Container type should be at a lower level than the current composition level. + """ + composition_hierarchy = CompositionLevel.COMPLICATED_LEVELS.value + return ( + container_type in composition_hierarchy and + self.composition_level in composition_hierarchy and + composition_hierarchy.index(container_type) >= composition_hierarchy.index(self.composition_level) + ) + + def get_or_create_container( + self, + container_type: str, + key: str, + display_name: str, + block_usage_key_string: str + ) -> PublishableVersionWithMapping: + """ + Create a container of the specified type. + + Creates a container (e.g., chapter, sequential, vertical) in the + content library. + """ + container_creator_func = self.CONTAINER_CREATORS_MAP.get(container_type) + container_override_func = self.CONTAINER_OVERRIDERS_MAP.get(container_type) + if not all((container_creator_func, container_override_func)): + raise ValueError(f"Unknown container type: {container_type}") + + try: + container_version = self.content_library.learning_package.publishable_entities.get(key=key) + except PublishableEntity.DoesNotExist: + container_version = None + + if container_version and self.override: + container_version = container_override_func( + container_version.container, + title=display_name or f"New {container_type}", + components=[], + created=datetime.now(tz=timezone.utc), + created_by=self.import_event.user_id, + ) + elif not container_version: + _, container_version = container_creator_func( + self.learning_package.id, + key=key or secrets.token_hex(16), + title=display_name or f"New {container_type}", + components=[], + created=datetime.now(tz=timezone.utc), + created_by=self.import_event.user_id, + ) + + publishable_entity_mapping, _ = get_or_create_publishable_entity_mapping( + block_usage_key_string, + container_version.container + ) + + return PublishableVersionWithMapping(container_version, publishable_entity_mapping) + + def _update_container_components(self, container_version, component_versions): + """ + Update components of a container. + """ + entity_rows = [ + authoring_api.ContainerEntityRow( + entity_pk=cv.container.pk if isinstance(cv, ContainerVersion) else cv.component.pk, + version_pk=cv.pk, + ) + for cv in component_versions + ] + return authoring_api.create_next_container_version( + container_pk=container_version.container.pk, + title=container_version.title, + entity_rows=entity_rows, + created=datetime.now(tz=timezone.utc), + created_by=self.import_event.user_id, + container_version_cls=container_version.__class__, + ) + + def _create_block_in_library(self, block_to_import, usage_key) -> PublishableVersionWithMapping | None: + """ + Create a block in a library from a staged content block. + """ + now = datetime.now(tz=timezone.utc) + staged_content_files = content_staging_api.get_staged_content_static_files(self.staged_content.id) + + with transaction.atomic(): + component_type = authoring_api.get_or_create_component_type("xblock.v1", usage_key.block_type) + does_component_exist = authoring_api.get_components( + self.learning_package.id + ).filter(local_key=usage_key.block_id).exists() + + if does_component_exist: + if not self.override: + log.info(f"Component {usage_key.block_id} already exists in library {self.library_key}, skipping.") + return + else: + component_version = self._handle_component_override(usage_key, etree.tostring(block_to_import)) + else: + try: + _, library_usage_key = api.validate_can_add_block_to_library( + self.library_key, + block_to_import.tag, + usage_key.block_id, + ) + except api.IncompatibleTypesError as e: + log.error(f"Error validating block {usage_key} for library {self.library_key}: {e}") + return + + authoring_api.create_component( + self.learning_package.id, + component_type=component_type, + local_key=usage_key.block_id, + created=now, + created_by=self.import_event.user_id, + ) + component_version = api.set_library_block_olx(library_usage_key, etree.tostring(block_to_import)) + + self._process_staged_content_files( + component_version, + staged_content_files, + usage_key, + block_to_import, + now, + ) + publishable_entity_mapping, _ = get_or_create_publishable_entity_mapping( + usage_key, + component_version.component + ) + return PublishableVersionWithMapping(component_version, publishable_entity_mapping) + + def _handle_component_override(self, usage_key, new_content): + """ + Create new ComponentVersion for overridden component. + """ + component_version = None + try: + component = authoring_api.get_components(self.learning_package.id).get(local_key=usage_key.block_id) + except Component.DoesNotExist: + return component_version + library_usage_key = api.library_component_usage_key(self.library_key, component) + + component_version = api.set_library_block_olx(library_usage_key, new_content) + + return component_version + + def _process_staged_content_files( + self, + component_version, + staged_content_files, + usage_key, + block_to_import, + created_at, + ): + """ + Process staged content files for a component. + + Processes the staged content files for a component, creating the + necessary file content and associating it with the component version. + """ + block_olx = etree.tostring(block_to_import).decode('utf-8') + + for staged_content_file_data in staged_content_files: + original_filename = staged_content_file_data.filename + file_basename = os.path.basename(original_filename) + file_basename_no_ext, _ = os.path.splitext(file_basename) + + # Skip files not referenced in the block + if file_basename not in block_olx and file_basename_no_ext not in block_olx: + log.info(f"Skipping file {original_filename} as it is not referenced in block {usage_key}") + continue + + file_data = content_staging_api.get_staged_content_static_file_data( + self.staged_content.id, + original_filename, + ) + if not file_data: + log.error( + f"Staged content {self.staged_content.id} included referenced " + f"file {original_filename}, but no file data was found." + ) + continue + + filename = f"static/{file_basename}" + media_type_str, _ = mimetypes.guess_type(filename) + if not media_type_str: + media_type_str = "application/octet-stream" + + media_type = authoring_api.get_or_create_media_type(media_type_str) + content = authoring_api.get_or_create_file_content( + self.learning_package.id, + media_type.id, + data=file_data, + created=created_at, + ) + + try: + authoring_api.create_component_version_content(component_version.pk, content.id, key=filename) + except IntegrityError: + pass # Content already exists + + +def get_or_create_publishable_entity_mapping(usage_key, component) -> tuple[PublishableEntityMapping, bool]: + """ + Creates a mapping between the source usage key and the target publishable entity. + """ + if isinstance(component, Container): + target_package = component.publishable_entity.learning_package + else: + target_package = component.learning_package + return PublishableEntityMapping.objects.get_or_create( + source_usage_key=usage_key, + target_entity=component.publishable_entity, + target_package=target_package, + ) + + +def get_usage_key_string_from_staged_content(staged_content, block_id): + """ + Get the usage ID from a staged content by block ID. + """ + return next((block_usage_id for block_usage_id in staged_content.tags if block_usage_id.endswith(block_id)), None) + + +def get_block_to_import(node, usage_key): + """ + Get the block to import from a node. + """ + + if node.get('url_name') == usage_key.block_id: + return node + + for child in node.getchildren(): + found = get_block_to_import(child, usage_key) + if found is not None: + return found + + +def get_items_to_import(import_event): + """ + Collect items to import from a course. + """ + items_to_import = [] + if isinstance(import_event.source_key, CourseLocator): + items_to_import.extend( + modulestore().get_items(import_event.source_key, qualifiers={"category": "chapter"}) or [] + ) + items_to_import.extend( + modulestore().get_items(import_event.source_key, qualifiers={"category": "static_tab"}) or [] + ) + + return items_to_import diff --git a/cms/djangoapps/import_from_modulestore/migrations/0001_initial.py b/cms/djangoapps/import_from_modulestore/migrations/0001_initial.py new file mode 100644 index 000000000000..ce6349c71c35 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.20 on 2025-04-17 19:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import opaque_keys.edx.django.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('content_staging', '0005_stagedcontent_version_num'), + ('oel_publishing', '0008_alter_draftchangelogrecord_options_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Import', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('status', models.CharField(choices=[('not_started', 'Waiting to stage content'), ('staging', 'Staging content for import'), ('Failed to stage content', 'Staging Failed'), ('staged', 'Content is staged and ready for import'), ('importing', 'Importing staged content'), ('importing_failed', 'Failed to import staged content'), ('imported', 'Successfully imported content'), ('canceled', 'Canceled')], db_index=True, default='not_started', max_length=100)), + ('source_key', opaque_keys.edx.django.models.LearningContextKeyField(db_index=True, help_text='The modulestore course', max_length=255)), + ('target_change', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_publishing.draftchangelog')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Import from modulestore', + 'verbose_name_plural': 'Imports from modulestore', + }, + ), + migrations.CreateModel( + name='PublishableEntityMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('source_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='Original usage key/ID of the thing that has been imported.', max_length=255)), + ('target_entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.publishableentity')), + ('target_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')), + ], + options={ + 'unique_together': {('source_usage_key', 'target_package')}, + }, + ), + migrations.CreateModel( + name='StagedContentForImport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('source_usage_key', opaque_keys.edx.django.models.UsageKeyField(help_text='The original Usage key of the highest-level component that was saved in StagedContent.', max_length=255)), + ('import_event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staged_content_for_import', to='import_from_modulestore.import')), + ('staged_content', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staged_content_for_import', to='content_staging.stagedcontent')), + ], + options={ + 'unique_together': {('import_event', 'staged_content')}, + }, + ), + migrations.CreateModel( + name='PublishableEntityImport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('import_event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='import_from_modulestore.import')), + ('resulting_change', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='oel_publishing.draftchangelogrecord')), + ('resulting_mapping', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='import_from_modulestore.publishableentitymapping')), + ], + options={ + 'unique_together': {('import_event', 'resulting_mapping')}, + }, + ), + ] diff --git a/cms/djangoapps/import_from_modulestore/models.py b/cms/djangoapps/import_from_modulestore/models.py index acbe82fa6d07..8fd9e46584fe 100644 --- a/cms/djangoapps/import_from_modulestore/models.py +++ b/cms/djangoapps/import_from_modulestore/models.py @@ -1,7 +1,7 @@ """ Models for the course to library import app. """ - +from typing import Optional import uuid as uuid_tools from django.contrib.auth import get_user_model @@ -62,6 +62,13 @@ def clean_related_staged_content(self) -> None: for staged_content_for_import in self.staged_content_for_import.all(): staged_content_for_import.staged_content.delete() + def get_staged_content_by_source_usage_key(self, source_key) -> Optional["StagedContent"]: + """ + Get staged content by source usage key in related to import staged content. + """ + staged_content_for_import = self.staged_content_for_import.filter(source_usage_key=source_key).first() + return staged_content_for_import.staged_content if staged_content_for_import else None + class PublishableEntityMapping(TimeStampedModel): """ diff --git a/cms/djangoapps/import_from_modulestore/permissions.py b/cms/djangoapps/import_from_modulestore/permissions.py new file mode 100644 index 000000000000..0d30f8b6af58 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/permissions.py @@ -0,0 +1,17 @@ +""" +Permission classes for the import_from_modulestore app. +""" +from django.shortcuts import get_object_or_404 +from rest_framework import permissions + +from cms.djangoapps.import_from_modulestore.models import Import + + +class IsImportAuthor(permissions.BasePermission): + """ + Permission class to check if the user is the author of the import. + """ + + def has_permission(self, request, view): + import_event = get_object_or_404(Import, uuid=request.data.get('import_uuid')) + return import_event.user_id == request.user.pk diff --git a/cms/djangoapps/import_from_modulestore/signals.py b/cms/djangoapps/import_from_modulestore/signals.py new file mode 100644 index 000000000000..d6d52e5d6b6b --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/signals.py @@ -0,0 +1,26 @@ +""" +Signals for Import. +""" +from django.dispatch import receiver +from django.db.models.signals import post_save + +from .data import ImportStatus +from .models import Import + + +@receiver(post_save, sender=Import) +def cancel_incomplete_imports(sender, instance, created, **kwargs): + """ + Cancel any incomplete imports that have the same target as the current import. + + When a new import is created, we want to cancel any other incomplete user imports that have the same target. + """ + if created: + incomplete_user_imports_with_same_target = Import.objects.filter( + user=instance.user, + target_change=instance.target_change, + source_key=instance.source_key, + staged_content_for_import__isnull=False + ).exclude(uuid=instance.uuid) + for incomplete_import in incomplete_user_imports_with_same_target: + incomplete_import.set_status(ImportStatus.CANCELED) diff --git a/cms/djangoapps/import_from_modulestore/tasks.py b/cms/djangoapps/import_from_modulestore/tasks.py new file mode 100644 index 000000000000..c13feee30561 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tasks.py @@ -0,0 +1,110 @@ +""" +Tasks for course to library import. +""" + +from celery import shared_task +from celery.utils.log import get_task_logger +from django.db import transaction +from edx_django_utils.monitoring import set_code_owner_attribute + +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import LearningPackage +from openedx.core.djangoapps.content_staging import api as content_staging_api + +from .constants import IMPORT_FROM_MODULESTORE_PURPOSE +from .data import ImportStatus +from .helpers import get_items_to_import, ImportClient +from .models import Import, PublishableEntityImport, StagedContentForImport +from .validators import validate_composition_level + +log = get_task_logger(__name__) + + +@shared_task +@set_code_owner_attribute +def save_legacy_content_to_staged_content_task(import_uuid: str) -> None: + """ + Save courses to staged content task by sections/chapters. + """ + try: + import_event = Import.objects.get(uuid=import_uuid) + except Import.DoesNotExist: + log.info('Import event not found for UUID %s', import_uuid) + return + + import_event.clean_related_staged_content() + import_event.set_status(ImportStatus.STAGING) + try: + with transaction.atomic(): + items_to_import = get_items_to_import(import_event) + for item in items_to_import: + staged_content = content_staging_api.stage_xblock_temporarily( + item, + import_event.user.id, + purpose=IMPORT_FROM_MODULESTORE_PURPOSE, + ) + StagedContentForImport.objects.create( + staged_content=staged_content, + import_event=import_event, + source_usage_key=item.location + ) + + if items_to_import: + import_event.set_status(ImportStatus.STAGED) + else: + import_event.set_status(ImportStatus.STAGING_FAILED) + except Exception as exc: # pylint: disable=broad-except + import_event.set_status(ImportStatus.STAGING_FAILED) + raise exc + + +@shared_task +@set_code_owner_attribute +def import_course_staged_content_to_library_task( + usage_keys_string: list[str], + import_uuid: str, + learning_package_id: int, + user_id: int, + composition_level: str, + override: bool, +) -> None: + """ + Import staged content to a library task. + """ + validate_composition_level(composition_level) + try: + import_event = Import.objects.get(uuid=import_uuid, status=ImportStatus.STAGED, user_id=user_id) + except Import.DoesNotExist: + log.info('Ready import from modulestore not found') + return + try: + target_learning_package = LearningPackage.objects.get(id=learning_package_id) + except Import.LearningPackage: + log.info('Target learning package not found') + return + + imported_publishable_versions = [] + with authoring_api.bulk_draft_changes_for(learning_package_id=learning_package_id) as change_log: + try: + for usage_key_string in usage_keys_string: + if staged_content_item := import_event.get_staged_content_by_source_usage_key(usage_key_string): + import_client = ImportClient( + import_event, + usage_key_string, + target_learning_package, + staged_content_item, + composition_level, + override, + ) + imported_publishable_versions.extend(import_client.import_from_staged_content()) + except Exception as exc: # pylint: disable=broad-except + import_event.set_status(ImportStatus.IMPORTING_FAILED) + raise exc from exc # TODO: retest raise and describe change_log commitment on Exception + + import_event.set_status(ImportStatus.IMPORTED) + for imported_component_version in imported_publishable_versions: + PublishableEntityImport.objects.create( + import_event=import_event, + resulting_mapping=imported_component_version.mapping, + resulting_change=change_log.records.get(entity=imported_component_version.mapping.target_entity), + ) diff --git a/cms/djangoapps/import_from_modulestore/tests/__init__.py b/cms/djangoapps/import_from_modulestore/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/import_from_modulestore/tests/factories.py b/cms/djangoapps/import_from_modulestore/tests/factories.py new file mode 100644 index 000000000000..368cc0ed94ff --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tests/factories.py @@ -0,0 +1,28 @@ +""" +Factories for Import model. +""" + +import uuid + +import factory +from factory.django import DjangoModelFactory +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.tests.factories import UserFactory +from cms.djangoapps.import_from_modulestore.models import Import + + +class ImportFactory(DjangoModelFactory): + """ + Factory for Import model. + """ + + class Meta: + model = Import + + @factory.lazy_attribute + def source_key(self): + return CourseKey.from_string(f'course-v1:edX+DemoX+{self.uuid}') + + uuid = factory.LazyFunction(lambda: str(uuid.uuid4())) + user = factory.SubFactory(UserFactory) diff --git a/cms/djangoapps/import_from_modulestore/tests/test_api.py b/cms/djangoapps/import_from_modulestore/tests/test_api.py new file mode 100644 index 000000000000..4523fa440372 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tests/test_api.py @@ -0,0 +1,102 @@ +""" +Test cases for import_from_modulestore.api module. +""" +from unittest.mock import patch + +import pytest +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.tests.factories import UserFactory +from cms.djangoapps.import_from_modulestore.api import create_import, import_course_staged_content_to_library +from cms.djangoapps.import_from_modulestore.data import ImportStatus +from cms.djangoapps.import_from_modulestore.models import Import +from openedx.core.djangoapps.content_libraries.tests import factories +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from .factories import ImportFactory + + +@pytest.mark.django_db +class TestCourseToLibraryImportAPI(ModuleStoreTestCase): + """ + Test cases for Import API. + """ + + def setUp(self): + super().setUp() + + self.library = factories.ContentLibraryFactory() + + def test_create_import(self): + """ + Test create_import function. + """ + course_id = "course-v1:edX+DemoX+Demo_Course" + user = UserFactory() + create_import(course_id, user.id) + + import_event = Import.objects.get() + assert import_event.source_key == CourseKey.from_string(course_id) + assert import_event.user_id == user.id + assert import_event.status == ImportStatus.NOT_STARTED + + def test_import_course_staged_content_to_library(self): + """ + Test import_course_staged_content_to_library function with different override values. + """ + import_event = ImportFactory( + source_key=CourseKey.from_string("course-v1:edX+DemoX+Demo_Course"), + ) + usage_ids = [ + "block-v1:edX+DemoX+Demo_Course+type@chapter+block@123", + "block-v1:edX+DemoX+Demo_Course+type@chapter+block@456", + ] + override = False + + with patch( + "cms.djangoapps.import_from_modulestore.api.import_course_staged_content_to_library_task" + ) as import_course_staged_content_to_library_task_mock: + import_course_staged_content_to_library( + usage_ids, + import_event.uuid, + self.library.learning_package.id, + import_event.user.id, + "xblock", + override + ) + + import_course_staged_content_to_library_task_mock.apply_async.assert_called_once_with( + kwargs={ + "usage_keys_string": usage_ids, + "import_uuid": import_event.uuid, + "learning_package_id": self.library.learning_package.id, + "user_id": import_event.user.id, + "composition_level": "xblock", + "override": override, + }, + ) + + def test_import_course_staged_content_to_library_invalid_usage_key(self): + """ + Test import_course_staged_content_to_library function with not chapter usage keys. + """ + import_event = ImportFactory( + source_key=CourseKey.from_string("course-v1:edX+DemoX+Demo_Course"), + ) + usage_ids = [ + "block-v1:edX+DemoX+Demo_Course+type@problem+block@123", + "block-v1:edX+DemoX+Demo_Course+type@vertical+block@456", + ] + + with patch( + "cms.djangoapps.import_from_modulestore.api.import_course_staged_content_to_library_task" + ) as import_course_staged_content_to_library_task_mock: + with self.assertRaises(ValueError): + import_course_staged_content_to_library( + usage_ids, + import_event.uuid, + self.library.learning_package.id, + import_event.user.id, + "xblock", + False + ) + import_course_staged_content_to_library_task_mock.apply_async.assert_not_called() diff --git a/cms/djangoapps/import_from_modulestore/tests/test_helpers.py b/cms/djangoapps/import_from_modulestore/tests/test_helpers.py new file mode 100644 index 000000000000..2a50d5d281ea --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tests/test_helpers.py @@ -0,0 +1,360 @@ +""" +Tests for the import_from_modulestore helper functions. +""" +import ddt +from organizations.models import Organization +from unittest import mock +from unittest.mock import patch + +from lxml import etree +from openedx_learning.api.authoring_models import LearningPackage + +from cms.djangoapps.import_from_modulestore import api +from cms.djangoapps.import_from_modulestore.helpers import ImportClient +from common.djangoapps.student.tests.factories import UserFactory + +from openedx.core.djangoapps.content_libraries import api as content_libraries_api +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + + +@ddt.ddt +class TestImportClient(ModuleStoreTestCase): + """ + Functional tests for the ImportClient class. + """ + + def setUp(self): + super().setUp() + self.library = content_libraries_api.create_library( + org=Organization.objects.create(name='Organization 1', short_name='org1'), + slug='lib_1', + title='Library Org 1', + description='This is a library from Org 1', + ) + self.learning_package = LearningPackage.objects.get(id=self.library.learning_package_id) + self.user = UserFactory() + self.course = CourseFactory.create() + self.chapter = BlockFactory.create(category='chapter', parent=self.course, display_name='Chapter') + self.sequential = BlockFactory.create(category='sequential', parent=self.chapter, display_name='Sequential') + self.vertical = BlockFactory.create(category='vertical', parent=self.sequential, display_name='Vertical') + self.problem = BlockFactory.create( + category='problem', + parent=self.vertical, + display_name='Problem', + data="""""", + ) + self.video = BlockFactory.create( + category='video', + parent=self.vertical, + display_name='Video', + data="""