diff --git a/cms/djangoapps/import_from_modulestore/api.py b/cms/djangoapps/import_from_modulestore/api.py new file mode 100644 index 000000000000..7f8dc16b76cb --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/api.py @@ -0,0 +1,45 @@ +""" +API for course to library import. +""" +from typing import Sequence + +from opaque_keys.edx.keys import LearningContextKey, UsageKey + +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 .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( + usage_ids: Sequence[str | UsageKey], + 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_staged_content_to_library_task.apply_async( + kwargs={ + '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, + }, + ) diff --git a/cms/djangoapps/import_from_modulestore/constants.py b/cms/djangoapps/import_from_modulestore/constants.py new file mode 100644 index 000000000000..09e0d4e30f1a --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/constants.py @@ -0,0 +1,5 @@ +""" +Constants for import_from_modulestore app +""" + +IMPORT_FROM_MODULESTORE_STAGING_PURPOSE = "import_from_modulestore" diff --git a/cms/djangoapps/import_from_modulestore/data.py b/cms/djangoapps/import_from_modulestore/data.py index 7821e463a76a..998ea8dfc745 100644 --- a/cms/djangoapps/import_from_modulestore/data.py +++ b/cms/djangoapps/import_from_modulestore/data.py @@ -1,6 +1,10 @@ """ This module contains the data models for the import_from_modulestore app. """ +from collections import namedtuple +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 _ @@ -18,3 +22,33 @@ 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 = content_libraries_api.ContainerType.Section + SEQUENTIAL = content_libraries_api.ContainerType.Subsection + VERTICAL = content_libraries_api.ContainerType.Unit + COMPONENT = 'component' + OLX_COMPLEX_LEVELS = [ + VERTICAL.olx_tag, + SEQUENTIAL.olx_tag, + CHAPTER.olx_tag, + ] + + @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..e540e0ff3dba --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/helpers.py @@ -0,0 +1,466 @@ +""" +Helper functions for importing course content into a library. +""" +from datetime import datetime, timezone +from functools import partial +import logging +import mimetypes +import os +import secrets +from typing import TYPE_CHECKING + +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, ImportStatus, PublishableVersionWithMapping +from .models import Import, PublishableEntityMapping + +if TYPE_CHECKING: + from openedx_learning.apps.authoring_models import LearningPackage + from xblock.core import XBlock + + from openedx.core.djangoapps.content_staging.api import _StagedContent as StagedContent + + +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. + """ + + # The create functions have different kwarg names for the child list, + # so we need to use partial to set the child list to empty. + CONTAINER_CREATORS_MAP: dict[str, partial] = { + api.ContainerType.Section.olx_tag: partial(authoring_api.create_section_and_version, subsections=[]), + api.ContainerType.Subsection.olx_tag: partial(authoring_api.create_subsection_and_version, units=[]), + api.ContainerType.Unit.olx_tag: partial(authoring_api.create_unit_and_version, components=[]), + } + + CONTAINER_OVERRIDERS_MAP: dict[str, partial] = { + api.ContainerType.Section.olx_tag: partial(authoring_api.create_next_section_version, subsections=[]), + api.ContainerType.Subsection.olx_tag: partial(authoring_api.create_next_subsection_version, units=[]), + api.ContainerType.Unit.olx_tag: partial(authoring_api.create_next_unit_version, components=[]), + } + + 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_node_for_usage_key(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.OLX_COMPLEX_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 == CompositionLevel.COMPONENT.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.OLX_COMPLEX_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.OLX_COMPLEX_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. + """ + try: + container_creator_func = self.CONTAINER_CREATORS_MAP[container_type] + container_override_func = self.CONTAINER_OVERRIDERS_MAP[container_type] + except KeyError as exc: + raise ValueError(f"Unknown container type: {container_type}") from exc + + 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}", + 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}", + 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 None + 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 None + + 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 import_from_staged_content( + import_event: Import, + usage_key_string: str, + target_learning_package: 'LearningPackage', + staged_content: 'StagedContent', + composition_level: str, + override: bool = False, +) -> list[PublishableVersionWithMapping]: + """ + Import staged content to a library from staged content. + + Returns a list of PublishableVersionWithMappings created during the import. + """ + import_client = ImportClient( + import_event, + usage_key_string, + target_learning_package, + staged_content, + composition_level, + override, + ) + return import_client.import_from_staged_content() + + +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: 'StagedContent', block_id: str) -> str | None: + """ + Get the usage ID from a staged content by block ID. + """ + if staged_content.tags is None: + return None + return next((block_usage_id for block_usage_id in staged_content.tags if block_usage_id.endswith(block_id)), None) + + +def get_node_for_usage_key(node: etree._Element, usage_key: UsageKey) -> etree._Element: + """ + Get the node in an XML tree which matches to the usage key. + """ + if node.tag == usage_key.block_type and node.get('url_name') == usage_key.block_id: + return node + + for child in node.getchildren(): + found = get_node_for_usage_key(child, usage_key) + if found is not None: + return found + + +def get_items_to_import(import_event: Import) -> list['XBlock']: + """ + Collect items to import from a course. + """ + items_to_import: list['XBlock'] = [] + 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 + + +def cancel_incomplete_old_imports(import_event: Import) -> None: + """ + 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. + """ + incomplete_user_imports_with_same_target = Import.objects.filter( + user=import_event.user, + target_change=import_event.target_change, + source_key=import_event.source_key, + staged_content_for_import__isnull=False + ).exclude(uuid=import_event.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/models.py b/cms/djangoapps/import_from_modulestore/models.py index acbe82fa6d07..5b6122749bba 100644 --- a/cms/djangoapps/import_from_modulestore/models.py +++ b/cms/djangoapps/import_from_modulestore/models.py @@ -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 diff --git a/cms/djangoapps/import_from_modulestore/tasks.py b/cms/djangoapps/import_from_modulestore/tasks.py new file mode 100644 index 000000000000..4644b29e4904 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tasks.py @@ -0,0 +1,101 @@ +""" +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_STAGING_PURPOSE +from .data import ImportStatus +from .helpers import get_items_to_import, import_from_staged_content +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. + """ + import_event = Import.objects.get(uuid=import_uuid) + + 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_STAGING_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_staged_content_to_library_task( + usage_key_strings: 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) + + import_event = Import.objects.get(uuid=import_uuid, status=ImportStatus.STAGED, user_id=user_id) + target_learning_package = LearningPackage.objects.get(id=learning_package_id) + + 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_key_strings: + staged_content_for_import = import_event.staged_content_for_import.get( + source_usage_key=usage_key_string + ) + publishable_versions = import_from_staged_content( + import_event, + usage_key_string, + target_learning_package, + staged_content_for_import.staged_content, + composition_level, + override, + ) + imported_publishable_versions.extend(publishable_versions) + except: # pylint: disable=bare-except + import_event.set_status(ImportStatus.IMPORTING_FAILED) + raise + + 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..62fe2e4159a9 --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tests/test_api.py @@ -0,0 +1,109 @@ +""" +Test cases for import_from_modulestore.api module. +""" +from unittest.mock import patch + +import pytest +from opaque_keys.edx.keys import CourseKey +from organizations.models import Organization + +from common.djangoapps.student.tests.factories import UserFactory +from cms.djangoapps.import_from_modulestore.api import import_staged_content_to_library, stage_content_for_import +from cms.djangoapps.import_from_modulestore.data import ImportStatus +from cms.djangoapps.import_from_modulestore.models import Import +from openedx.core.djangoapps.content_libraries import api as content_libraries_api +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() + + _library_metadata = 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.library = content_libraries_api.ContentLibrary.objects.get_by_key(_library_metadata.key) + + def test_stage_content_for_import(self): + """ + Test stage_content_for_import function. + """ + course_id = "course-v1:edX+DemoX+Demo_Course" + user = UserFactory() + stage_content_for_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_staged_content_to_library(self): + """ + Test import_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_staged_content_to_library_task" + ) as import_staged_content_to_library_task_mock: + import_staged_content_to_library( + usage_ids, + import_event.uuid, + self.library.learning_package.id, + import_event.user.id, + "xblock", + override + ) + + import_staged_content_to_library_task_mock.apply_async.assert_called_once_with( + kwargs={ + "usage_key_strings": 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_staged_content_to_library_invalid_usage_key(self): + """ + Test import_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_staged_content_to_library_task" + ) as import_staged_content_to_library_task_mock: + with self.assertRaises(ValueError): + import_staged_content_to_library( + usage_ids, + import_event.uuid, + self.library.learning_package.id, + import_event.user.id, + "xblock", + False + ) + import_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..1d2ce26867aa --- /dev/null +++ b/cms/djangoapps/import_from_modulestore/tests/test_helpers.py @@ -0,0 +1,396 @@ +""" +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="""