diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py
index 019f67a594..eb9b109cdd 100644
--- a/cms/djangoapps/contentstore/tests/test_libraries.py
+++ b/cms/djangoapps/contentstore/tests/test_libraries.py
@@ -15,8 +15,8 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.x_module import STUDIO_VIEW
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json
-from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, reverse_usage_url
-from cms.djangoapps.contentstore.views.block import _duplicate_block
+from cms.djangoapps.contentstore.utils import reverse_library_url, reverse_url, \
+ reverse_usage_url, duplicate_block
from cms.djangoapps.contentstore.views.preview import _load_preview_block
from cms.djangoapps.contentstore.views.tests.test_library import LIBRARY_REST_URL
from cms.djangoapps.course_creators.views import add_user_with_status_granted
@@ -947,7 +947,7 @@ class TestOverrides(LibraryTestCase):
if duplicate:
# Check that this also works when the RCB is duplicated.
self.lc_block = modulestore().get_item(
- _duplicate_block(self.course.location, self.lc_block.location, self.user)
+ duplicate_block(self.course.location, self.lc_block.location, self.user)
)
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
else:
@@ -1006,7 +1006,7 @@ class TestOverrides(LibraryTestCase):
# Duplicate self.lc_block:
duplicate = store.get_item(
- _duplicate_block(self.course.location, self.lc_block.location, self.user)
+ duplicate_block(self.course.location, self.lc_block.location, self.user)
)
# The duplicate should have identical children to the original:
self.assertEqual(len(duplicate.children), 1)
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 14f86917b4..ed1d1e5df8 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -5,20 +5,28 @@ Common utility functions useful throughout the contentstore
from collections import defaultdict
import logging
from contextlib import contextmanager
-from datetime import datetime
+from datetime import datetime, timezone
+from uuid import uuid4
from django.conf import settings
from django.urls import reverse
from django.utils import translation
from django.utils.translation import gettext as _
+from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryLocator
+from openedx_events.content_authoring.data import DuplicatedXBlockData
+from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
from pytz import UTC
+from xblock.fields import Scope
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
+from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.student import auth
+from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
+from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
from openedx.core.djangoapps.course_apps.toggles import proctoring_settings_modal_view_enabled
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
@@ -29,8 +37,6 @@ from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
from cms.djangoapps.contentstore.toggles import (
- use_new_text_editor,
- use_new_video_editor,
use_new_advanced_settings_page,
use_new_course_outline_page,
use_new_export_page,
@@ -44,10 +50,13 @@ from cms.djangoapps.contentstore.toggles import (
use_new_updates_page,
use_new_video_uploads_page,
)
+from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_video_editor
+from xmodule.library_tools import LibraryToolsService
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.services import SettingsService, ConfigurationService, TeamsConfigurationService
log = logging.getLogger(__name__)
@@ -935,3 +944,202 @@ def update_course_discussions_settings(course_key):
course = store.get_course(course_key)
course.discussions_settings['provider_type'] = provider
store.update_item(course, course.published_by)
+
+
+def duplicate_block(
+ parent_usage_key,
+ duplicate_source_usage_key,
+ user,
+ dest_usage_key=None,
+ display_name=None,
+ shallow=False,
+ is_child=False
+):
+ """
+ Duplicate an existing xblock as a child of the supplied parent_usage_key. You can
+ optionally specify what usage key the new duplicate block will use via dest_usage_key.
+
+ If shallow is True, does not copy children. Otherwise, this function calls itself
+ recursively, and will set the is_child flag to True when dealing with recursed child
+ blocks.
+ """
+ store = modulestore()
+ with store.bulk_operations(duplicate_source_usage_key.course_key):
+ source_item = store.get_item(duplicate_source_usage_key)
+ if not dest_usage_key:
+ # Change the blockID to be unique.
+ dest_usage_key = source_item.location.replace(name=uuid4().hex)
+
+ category = dest_usage_key.block_type
+
+ duplicate_metadata, asides_to_create = gather_block_attributes(
+ source_item, display_name=display_name, is_child=is_child,
+ )
+
+ dest_block = store.create_item(
+ user.id,
+ dest_usage_key.course_key,
+ dest_usage_key.block_type,
+ block_id=dest_usage_key.block_id,
+ definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
+ metadata=duplicate_metadata,
+ runtime=source_item.runtime,
+ asides=asides_to_create
+ )
+
+ children_handled = False
+
+ if hasattr(dest_block, 'studio_post_duplicate'):
+ # Allow an XBlock to do anything fancy it may need to when duplicated from another block.
+ # These blocks may handle their own children or parenting if needed. Let them return booleans to
+ # let us know if we need to handle these or not.
+ load_services_for_studio(dest_block.runtime, user)
+ children_handled = dest_block.studio_post_duplicate(store, source_item)
+
+ # Children are not automatically copied over (and not all xblocks have a 'children' attribute).
+ # Because DAGs are not fully supported, we need to actually duplicate each child as well.
+ if source_item.has_children and not shallow and not children_handled:
+ dest_block.children = dest_block.children or []
+ for child in source_item.children:
+ dupe = duplicate_block(dest_block.location, child, user=user, is_child=True)
+ if dupe not in dest_block.children: # _duplicate_block may add the child for us.
+ dest_block.children.append(dupe)
+ store.update_item(dest_block, user.id)
+
+ # pylint: disable=protected-access
+ if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
+ parent = store.get_item(parent_usage_key)
+ # If source was already a child of the parent, add duplicate immediately afterward.
+ # Otherwise, add child to end.
+ if source_item.location in parent.children:
+ source_index = parent.children.index(source_item.location)
+ parent.children.insert(source_index + 1, dest_block.location)
+ else:
+ parent.children.append(dest_block.location)
+ store.update_item(parent, user.id)
+
+ # .. event_implemented_name: XBLOCK_DUPLICATED
+ XBLOCK_DUPLICATED.send_event(
+ time=datetime.now(timezone.utc),
+ xblock_info=DuplicatedXBlockData(
+ usage_key=dest_block.location,
+ block_type=dest_block.location.block_type,
+ source_usage_key=duplicate_source_usage_key,
+ )
+ )
+
+ return dest_block.location
+
+
+def update_from_source(*, source_block, destination_block, user_id):
+ """
+ Update a block to have all the settings and attributes of another source.
+
+ Copies over all attributes and settings of a source block to a destination
+ block. Blocks must be the same type. This function does not modify or duplicate
+ children.
+
+ This function is useful when a block, originally copied from a source block, drifts
+ and needs to be updated to match the original.
+
+ The modulestore function copy_from_template will copy a block's children recursively,
+ replacing the target block's children. It does not, however, update any of the target
+ block's settings. copy_from_template, then, is useful for cases like the Library
+ Content Block, where the children are the same across all instances, but the settings
+ may differ.
+
+ By contrast, for cases where we're copying a block that has drifted from its source,
+ we need to update the target block's settings, but we don't want to replace its children,
+ or, at least, not only replace its children. update_from_source is useful for these cases.
+
+ This function is meant to be imported by pluggable django apps looking to manage duplicated
+ sections of a course. It is placed here for lack of a more appropriate location, since this
+ code has not yet been brought up to the standards in OEP-45.
+ """
+ duplicate_metadata, asides = gather_block_attributes(source_block, display_name=source_block.display_name)
+ for key, value in duplicate_metadata.items():
+ setattr(destination_block, key, value)
+ for key, value in source_block.get_explicitly_set_fields_by_scope(Scope.content).items():
+ setattr(destination_block, key, value)
+ modulestore().update_item(
+ destination_block,
+ user_id,
+ metadata=duplicate_metadata,
+ asides=asides,
+ )
+
+
+def gather_block_attributes(source_item, display_name=None, is_child=False):
+ """
+ Gather all the attributes of the source block that need to be copied over to a new or updated block.
+ """
+ # Update the display name to indicate this is a duplicate (unless display name provided).
+ # Can't use own_metadata(), b/c it converts data for JSON serialization -
+ # not suitable for setting metadata of the new block
+ duplicate_metadata = {}
+ for field in source_item.fields.values():
+ if field.scope == Scope.settings and field.is_set_on(source_item):
+ duplicate_metadata[field.name] = field.read_from(source_item)
+
+ if is_child:
+ display_name = display_name or source_item.display_name or source_item.category
+
+ if display_name is not None:
+ duplicate_metadata['display_name'] = display_name
+ else:
+ if source_item.display_name is None:
+ duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
+ else:
+ duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
+
+ asides_to_create = []
+ for aside in source_item.runtime.get_asides(source_item):
+ for field in aside.fields.values():
+ if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
+ asides_to_create.append(aside)
+ break
+
+ for aside in asides_to_create:
+ for field in aside.fields.values():
+ if field.scope not in (Scope.settings, Scope.content,):
+ field.delete_from(aside)
+ return duplicate_metadata, asides_to_create
+
+
+def load_services_for_studio(runtime, user):
+ """
+ Function to set some required services used for XBlock edits and studio_view.
+ (i.e. whenever we're not loading _prepare_runtime_for_preview.) This is required to make information
+ about the current user (especially permissions) available via services as needed.
+ """
+ services = {
+ "user": DjangoXBlockUserService(user),
+ "studio_user_permissions": StudioPermissionsService(user),
+ "mako": MakoService(),
+ "settings": SettingsService(),
+ "lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
+ "teams_configuration": TeamsConfigurationService(),
+ "library_tools": LibraryToolsService(modulestore(), user.id)
+ }
+
+ runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
+
+
+class StudioPermissionsService:
+ """
+ Service that can provide information about a user's permissions.
+
+ Deprecated. To be replaced by a more general authorization service.
+
+ Only used by LibraryContentBlock (and library_tools.py).
+ """
+ def __init__(self, user):
+ self._user = user
+
+ def can_read(self, course_key):
+ """ Does the user have read access to the given course/library? """
+ return has_studio_read_access(self._user, course_key)
+
+ def can_write(self, course_key):
+ """ Does the user have read access to the given course/library? """
+ return has_studio_write_access(self._user, course_key)
diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py
index 2cadea08ea..a35230f3b8 100644
--- a/cms/djangoapps/contentstore/views/block.py
+++ b/cms/djangoapps/contentstore/views/block.py
@@ -4,19 +4,15 @@ import logging
from collections import OrderedDict
from datetime import datetime
from functools import partial
-from uuid import uuid4
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse, HttpResponseBadRequest
-from django.utils.timezone import timezone
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from edx_django_utils.plugins import pluggable_override
-from openedx_events.content_authoring.data import DuplicatedXBlockData
-from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
from edx_proctoring.api import (
does_backend_support_onboarding,
get_exam_by_content_id,
@@ -24,7 +20,6 @@ from edx_proctoring.api import (
)
from edx_proctoring.exceptions import ProctoredExamNotFoundException
from help_tokens.core import HelpUrlExpert
-from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC
@@ -35,13 +30,11 @@ from xblock.fields import Scope
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
-from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.static_replace import replace_static_urls
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.json_request import JsonResponse, expect_json
-from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
from openedx.core.djangoapps.bookmarks import api as bookmarks_api
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
@@ -49,13 +42,11 @@ from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside
from openedx.core.toggles import ENTRANCE_EXAMS
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.library_tools import LibraryToolsService # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.inheritance import own_metadata # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.tabs import CourseTabList # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW # lint-amnesty, pylint: disable=wrong-import-order
@@ -68,7 +59,7 @@ from ..utils import (
get_visibility_partition_info,
has_children_visible_to_specific_partition_groups,
is_currently_visible_to_students,
- is_self_paced
+ is_self_paced, duplicate_block, load_services_for_studio
)
from .helpers import (
create_xblock,
@@ -245,11 +236,11 @@ def xblock_handler(request, usage_key_string=None):
status=400
)
- dest_usage_key = _duplicate_block(
+ dest_usage_key = duplicate_block(
parent_usage_key,
duplicate_source_usage_key,
request.user,
- request.json.get('display_name'),
+ display_name=request.json.get('display_name'),
)
return JsonResponse({
'locator': str(dest_usage_key),
@@ -277,45 +268,6 @@ def xblock_handler(request, usage_key_string=None):
)
-class StudioPermissionsService:
- """
- Service that can provide information about a user's permissions.
-
- Deprecated. To be replaced by a more general authorization service.
-
- Only used by LibraryContentBlock (and library_tools.py).
- """
- def __init__(self, user):
- self._user = user
-
- def can_read(self, course_key):
- """ Does the user have read access to the given course/library? """
- return has_studio_read_access(self._user, course_key)
-
- def can_write(self, course_key):
- """ Does the user have read access to the given course/library? """
- return has_studio_write_access(self._user, course_key)
-
-
-def load_services_for_studio(runtime, user):
- """
- Function to set some required services used for XBlock edits and studio_view.
- (i.e. whenever we're not loading _prepare_runtime_for_preview.) This is required to make information
- about the current user (especially permissions) available via services as needed.
- """
- services = {
- "user": DjangoXBlockUserService(user),
- "studio_user_permissions": StudioPermissionsService(user),
- "mako": MakoService(),
- "settings": SettingsService(),
- "lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
- "teams_configuration": TeamsConfigurationService(),
- "library_tools": LibraryToolsService(modulestore(), user.id)
- }
-
- runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
-
-
@require_http_methods("GET")
@login_required
@expect_json
@@ -880,103 +832,6 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
return JsonResponse(context)
-def _duplicate_block(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
- """
- Duplicate an existing xblock as a child of the supplied parent_usage_key.
- """
- store = modulestore()
- with store.bulk_operations(duplicate_source_usage_key.course_key):
- source_item = store.get_item(duplicate_source_usage_key)
- # Change the blockID to be unique.
- dest_usage_key = source_item.location.replace(name=uuid4().hex)
- category = dest_usage_key.block_type
-
- # Update the display name to indicate this is a duplicate (unless display name provided).
- # Can't use own_metadata(), b/c it converts data for JSON serialization -
- # not suitable for setting metadata of the new block
- duplicate_metadata = {}
- for field in source_item.fields.values():
- if field.scope == Scope.settings and field.is_set_on(source_item):
- duplicate_metadata[field.name] = field.read_from(source_item)
-
- if is_child:
- display_name = display_name or source_item.display_name or source_item.category
-
- if display_name is not None:
- duplicate_metadata['display_name'] = display_name
- else:
- if source_item.display_name is None:
- duplicate_metadata['display_name'] = _("Duplicate of {0}").format(source_item.category)
- else:
- duplicate_metadata['display_name'] = _("Duplicate of '{0}'").format(source_item.display_name)
-
- asides_to_create = []
- for aside in source_item.runtime.get_asides(source_item):
- for field in aside.fields.values():
- if field.scope in (Scope.settings, Scope.content,) and field.is_set_on(aside):
- asides_to_create.append(aside)
- break
-
- for aside in asides_to_create:
- for field in aside.fields.values():
- if field.scope not in (Scope.settings, Scope.content,):
- field.delete_from(aside)
-
- dest_block = store.create_item(
- user.id,
- dest_usage_key.course_key,
- dest_usage_key.block_type,
- block_id=dest_usage_key.block_id,
- definition_data=source_item.get_explicitly_set_fields_by_scope(Scope.content),
- metadata=duplicate_metadata,
- runtime=source_item.runtime,
- asides=asides_to_create
- )
-
- children_handled = False
-
- if hasattr(dest_block, 'studio_post_duplicate'):
- # Allow an XBlock to do anything fancy it may need to when duplicated from another block.
- # These blocks may handle their own children or parenting if needed. Let them return booleans to
- # let us know if we need to handle these or not.
- load_services_for_studio(dest_block.runtime, user)
- children_handled = dest_block.studio_post_duplicate(store, source_item)
-
- # Children are not automatically copied over (and not all xblocks have a 'children' attribute).
- # Because DAGs are not fully supported, we need to actually duplicate each child as well.
- if source_item.has_children and not children_handled:
- dest_block.children = dest_block.children or []
- for child in source_item.children:
- dupe = _duplicate_block(dest_block.location, child, user=user, is_child=True)
- if dupe not in dest_block.children: # _duplicate_block may add the child for us.
- dest_block.children.append(dupe)
- store.update_item(dest_block, user.id)
-
- # pylint: disable=protected-access
- if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
- parent = store.get_item(parent_usage_key)
- # If source was already a child of the parent, add duplicate immediately afterward.
- # Otherwise, add child to end.
- if source_item.location in parent.children:
- source_index = parent.children.index(source_item.location)
- parent.children.insert(source_index + 1, dest_block.location)
- else:
- parent.children.append(dest_block.location)
- store.update_item(parent, user.id)
-
- # .. event_implemented_name: XBLOCK_DUPLICATED
- XBLOCK_DUPLICATED.send_event(
- time=datetime.now(timezone.utc),
- xblock_info=DuplicatedXBlockData(
- usage_key=dest_block.location,
- block_type=dest_block.location.block_type,
- source_usage_key=duplicate_source_usage_key,
- )
- )
-
- return dest_block.location
-
-
@login_required
@expect_json
def delete_item(request, usage_key):
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 60d6f68c9a..8aadd41869 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -35,9 +35,10 @@ except ImportError:
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
-from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url
+from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, \
+ load_services_for_studio
from .helpers import get_parent_xblock, is_unit, xblock_type_display_name
-from .block import add_container_page_publishing_info, create_xblock_info, load_services_for_studio
+from .block import add_container_page_publishing_info, create_xblock_info
__all__ = [
'container_handler',
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index 841e93c656..bb827d361c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -48,7 +48,7 @@ from xmodule.partitions.tests.test_partitions import MockPartitionService
from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
-from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url
+from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url, duplicate_block, update_from_source
from cms.djangoapps.contentstore.views import block as item_module
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
from common.djangoapps.xblock_django.models import (
@@ -787,6 +787,30 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
# Now send a custom display name for the duplicate.
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")
+ def test_shallow_duplicate(self):
+ """
+ Test that duplicate_block(..., shallow=True) can duplicate a block but ignores its children.
+ """
+ source_course = CourseFactory()
+ user = UserFactory.create()
+ source_chapter = BlockFactory(parent=source_course, category='chapter', display_name='Source Chapter')
+ BlockFactory(parent=source_chapter, category='html', display_name='Child')
+ # Refresh.
+ source_chapter = self.store.get_item(source_chapter.location)
+ self.assertEqual(len(source_chapter.get_children()), 1)
+ destination_course = CourseFactory()
+ destination_location = duplicate_block(
+ parent_usage_key=destination_course.location,
+ duplicate_source_usage_key=source_chapter.location,
+ user=user,
+ display_name=source_chapter.display_name,
+ shallow=True,
+ )
+ # Refresh here, too, just to be sure.
+ destination_chapter = self.store.get_item(destination_location)
+ self.assertEqual(len(destination_chapter.get_children()), 0)
+ self.assertEqual(destination_chapter.display_name, 'Source Chapter')
+
@ddt.ddt
class TestMoveItem(ItemTest):
@@ -3495,3 +3519,111 @@ class TestXBlockPublishingInfo(ItemTest):
# Check that in self paced course content has live state now
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.live)
+
+
+@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
+ lambda self, block: ['test_aside'])
+class TestUpdateFromSource(ModuleStoreTestCase):
+ """
+ Test update_from_source.
+ """
+
+ def setUp(self):
+ """
+ Set up the runtime for tests.
+ """
+ super().setUp()
+ key_store = DictKeyValueStore()
+ field_data = KvsFieldData(key_store)
+ self.runtime = TestRuntime(services={'field-data': field_data})
+
+ def create_source_block(self, course):
+ """
+ Create a chapter with all the fixings.
+ """
+ source_block = BlockFactory(
+ parent=course,
+ category='course_info',
+ display_name='Source Block',
+ metadata={'due': datetime(2010, 11, 22, 4, 0, tzinfo=UTC)},
+ )
+
+ def_id = self.runtime.id_generator.create_definition('html')
+ usage_id = self.runtime.id_generator.create_usage(def_id)
+
+ aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
+ aside.field11 = 'html_new_value1'
+
+ # The data attribute is handled in a special manner and should be updated.
+ source_block.data = '
test
'
+ # This field is set on the content scope (definition_data), which should be updated.
+ source_block.items = ['test', 'beep']
+
+ self.store.update_item(source_block, self.user.id, asides=[aside])
+
+ # quick sanity checks
+ source_block = self.store.get_item(source_block.location)
+ self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
+ self.assertEqual(source_block.display_name, 'Source Block')
+ self.assertEqual(source_block.runtime.get_asides(source_block)[0].field11, 'html_new_value1')
+ self.assertEqual(source_block.data, 'test
')
+ self.assertEqual(source_block.items, ['test', 'beep'])
+
+ return source_block
+
+ def check_updated(self, source_block, destination_key):
+ """
+ Check that the destination block has been updated to match our source block.
+ """
+ revised = self.store.get_item(destination_key)
+ self.assertEqual(source_block.display_name, revised.display_name)
+ self.assertEqual(source_block.due, revised.due)
+ self.assertEqual(revised.data, source_block.data)
+ self.assertEqual(revised.items, source_block.items)
+
+ self.assertEqual(
+ revised.runtime.get_asides(revised)[0].field11,
+ source_block.runtime.get_asides(source_block)[0].field11,
+ )
+
+ @XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
+ def test_update_from_source(self):
+ """
+ Test that update_from_source updates the destination block.
+ """
+ course = CourseFactory()
+ user = UserFactory.create()
+
+ source_block = self.create_source_block(course)
+
+ destination_block = BlockFactory(parent=course, category='course_info', display_name='Destination Problem')
+ update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
+ self.check_updated(source_block, destination_block.location)
+
+ @XBlockAside.register_temp_plugin(AsideTest, 'test_aside')
+ def test_update_clobbers(self):
+ """
+ Verify that our update replaces all settings on the block.
+ """
+ course = CourseFactory()
+ user = UserFactory.create()
+
+ source_block = self.create_source_block(course)
+
+ destination_block = BlockFactory(
+ parent=course,
+ category='course_info',
+ display_name='Destination Chapter',
+ metadata={'due': datetime(2025, 10, 21, 6, 5, tzinfo=UTC)},
+ )
+
+ def_id = self.runtime.id_generator.create_definition('html')
+ usage_id = self.runtime.id_generator.create_usage(def_id)
+ aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime)
+ aside.field11 = 'Other stuff'
+ destination_block.data = 'other stuff
'
+ destination_block.items = ['other stuff', 'boop']
+ self.store.update_item(destination_block, user.id, asides=[aside])
+
+ update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id)
+ self.check_updated(source_block, destination_block.location)
diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py
index 4d86edcc10..afad888fe0 100644
--- a/lms/djangoapps/bulk_email/tasks.py
+++ b/lms/djangoapps/bulk_email/tasks.py
@@ -10,21 +10,10 @@ import re
import time
from collections import Counter
from datetime import datetime
-from smtplib import SMTPConnectError, SMTPDataError, SMTPException, SMTPServerDisconnected, SMTPSenderRefused
+from smtplib import SMTPConnectError, SMTPDataError, SMTPException, SMTPSenderRefused, SMTPServerDisconnected
from time import sleep
-from boto.exception import AWSConnectionError
-from boto.ses.exceptions import (
- SESAddressBlacklistedError,
- SESAddressNotVerifiedError,
- SESDailyQuotaExceededError,
- SESDomainEndsWithDotError,
- SESDomainNotConfirmedError,
- SESIdentityNotVerifiedError,
- SESIllegalAddressError,
- SESLocalAddressCharacterError,
- SESMaxSendingRateExceededError
-)
+from botocore.exceptions import ClientError, EndpointConnectionError
from celery import current_task, shared_task
from celery.exceptions import RetryTaskError
from celery.states import FAILURE, RETRY, SUCCESS
@@ -34,8 +23,8 @@ from django.core.mail import get_connection
from django.core.mail.message import forbid_multi_line_headers
from django.urls import reverse
from django.utils import timezone
-from django.utils.translation import override as override_language
from django.utils.translation import gettext as _
+from django.utils.translation import override as override_language
from edx_django_utils.monitoring import set_code_owner_attribute
from markupsafe import escape
@@ -43,15 +32,12 @@ from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.string_utils import _has_non_ascii_characters
from lms.djangoapps.branding.api import get_logo_url_for_email
from lms.djangoapps.bulk_email.api import get_unsubscribed_link
-from lms.djangoapps.bulk_email.messages import (
- DjangoEmail,
- ACEEmail,
-)
+from lms.djangoapps.bulk_email.messages import ACEEmail, DjangoEmail
+from lms.djangoapps.bulk_email.models import CourseEmail, Optout
from lms.djangoapps.bulk_email.toggles import (
is_bulk_email_edx_ace_enabled,
- is_email_use_course_id_from_for_bulk_enabled,
+ is_email_use_course_id_from_for_bulk_enabled
)
-from lms.djangoapps.bulk_email.models import CourseEmail, Optout
from lms.djangoapps.courseware.courses import get_course
from lms.djangoapps.instructor_task.models import InstructorTask
from lms.djangoapps.instructor_task.subtasks import (
@@ -66,13 +52,11 @@ from openedx.core.lib.courses import course_image_url
log = logging.getLogger('edx.celery.task')
+
# Errors that an individual email is failing to be sent, and should just
# be treated as a fail.
SINGLE_EMAIL_FAILURE_ERRORS = (
- SESAddressBlacklistedError, # Recipient's email address has been temporarily blacklisted.
- SESDomainEndsWithDotError, # Recipient's email address' domain ends with a period/dot.
- SESIllegalAddressError, # Raised when an illegal address is encountered.
- SESLocalAddressCharacterError, # An address contained a control or whitespace character.
+ ClientError
)
# Exceptions that, if caught, should cause the task to be re-tried.
@@ -80,7 +64,7 @@ SINGLE_EMAIL_FAILURE_ERRORS = (
LIMITED_RETRY_ERRORS = (
SMTPConnectError,
SMTPServerDisconnected,
- AWSConnectionError,
+ EndpointConnectionError,
)
# Errors that indicate that a mailing task should be retried without limit.
@@ -91,20 +75,17 @@ LIMITED_RETRY_ERRORS = (
# Those not in this range (i.e. in the 5xx range) are treated as hard failures
# and thus like SINGLE_EMAIL_FAILURE_ERRORS.
INFINITE_RETRY_ERRORS = (
- SESMaxSendingRateExceededError, # Your account's requests/second limit has been exceeded.
SMTPDataError,
SMTPSenderRefused,
+ ClientError
)
# Errors that are known to indicate an inability to send any more emails,
# and should therefore not be retried. For example, exceeding a quota for emails.
# Also, any SMTP errors that are not explicitly enumerated above.
BULK_EMAIL_FAILURE_ERRORS = (
- SESAddressNotVerifiedError, # Raised when a "Reply-To" address has not been validated in SES yet.
- SESIdentityNotVerifiedError, # Raised when an identity has not been verified in SES yet.
- SESDomainNotConfirmedError, # Raised when domain ownership is not confirmed for DKIM.
- SESDailyQuotaExceededError, # 24-hour allotment of outbound email has been exceeded.
- SMTPException,
+ ClientError,
+ SMTPException
)
@@ -588,13 +569,16 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
except SINGLE_EMAIL_FAILURE_ERRORS as exc:
# This will fall through and not retry the message.
- total_recipients_failed += 1
- log.exception(
- f"BulkEmail ==> Status: Failed(SINGLE_EMAIL_FAILURE_ERRORS), Task: {parent_task_id}, SubTask: "
- f"{task_id}, EmailId: {email_id}, Recipient num: {recipient_num}/{total_recipients}, Recipient "
- f"UserId: {current_recipient['pk']}"
- )
- subtask_status.increment(failed=1)
+ if exc.response['Error']['Code'] in ['MessageRejected', 'MailFromDomainNotVerified', 'MailFromDomainNotVerifiedException', 'FromEmailAddressNotVerifiedException']: # lint-amnesty, pylint: disable=line-too-long
+ total_recipients_failed += 1
+ log.exception(
+ f"BulkEmail ==> Status: Failed(SINGLE_EMAIL_FAILURE_ERRORS), Task: {parent_task_id}, SubTask: "
+ f"{task_id}, EmailId: {email_id}, Recipient num: {recipient_num}/{total_recipients}, Recipient "
+ f"UserId: {current_recipient['pk']}"
+ )
+ subtask_status.increment(failed=1)
+ else:
+ raise exc
else:
total_recipients_successful += 1
@@ -631,10 +615,13 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
except INFINITE_RETRY_ERRORS as exc:
# Increment the "retried_nomax" counter, update other counters with progress to date,
# and set the state to RETRY:
- subtask_status.increment(retried_nomax=1, state=RETRY)
- return _submit_for_retry(
- entry_id, email_id, to_list, global_email_context, exc, subtask_status, skip_retry_max=True
- )
+ if isinstance(exc, (SMTPDataError, SMTPSenderRefused)) or exc.response['Error']['Code'] in ['LimitExceededException']: # lint-amnesty, pylint: disable=line-too-long
+ subtask_status.increment(retried_nomax=1, state=RETRY)
+ return _submit_for_retry(
+ entry_id, email_id, to_list, global_email_context, exc, subtask_status, skip_retry_max=True
+ )
+ else:
+ raise exc
except LIMITED_RETRY_ERRORS as exc:
# Errors caught here cause the email to be retried. The entire task is actually retried
@@ -642,21 +629,28 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
# Errors caught are those that indicate a temporary condition that might succeed on retry.
# Increment the "retried_withmax" counter, update other counters with progress to date,
# and set the state to RETRY:
+
subtask_status.increment(retried_withmax=1, state=RETRY)
return _submit_for_retry(
entry_id, email_id, to_list, global_email_context, exc, subtask_status, skip_retry_max=False
)
except BULK_EMAIL_FAILURE_ERRORS as exc:
- num_pending = len(to_list)
- log.exception(
- f"Task {task_id}: email with id {email_id} caused send_course_email task to fail with 'fatal' exception. "
- f"{num_pending} emails unsent."
- )
- # Update counters with progress to date, counting unsent emails as failures,
- # and set the state to FAILURE:
- subtask_status.increment(failed=num_pending, state=FAILURE)
- return subtask_status, exc
+ if isinstance(exc, SMTPException) or exc.response['Error']['Code'] in [
+ 'AccountSendingPausedException', 'MailFromDomainNotVerifiedException', 'LimitExceededException'
+ ]:
+ num_pending = len(to_list)
+ log.exception(
+ f"Task {task_id}: email with id {email_id} caused send_course_email "
+ f"task to fail with 'fatal' exception. "
+ f"{num_pending} emails unsent."
+ )
+ # Update counters with progress to date, counting unsent emails as failures,
+ # and set the state to FAILURE:
+ subtask_status.increment(failed=num_pending, state=FAILURE)
+ return subtask_status, exc
+ else:
+ raise exc
except Exception as exc: # pylint: disable=broad-except
# Errors caught here cause the email to be retried. The entire task is actually retried
diff --git a/lms/djangoapps/bulk_email/tests/test_tasks.py b/lms/djangoapps/bulk_email/tests/test_tasks.py
index e73db046aa..96c48e0d9e 100644
--- a/lms/djangoapps/bulk_email/tests/test_tasks.py
+++ b/lms/djangoapps/bulk_email/tests/test_tasks.py
@@ -7,27 +7,23 @@ paths actually work.
"""
-from datetime import datetime
-from dateutil.relativedelta import relativedelta
import json # lint-amnesty, pylint: disable=wrong-import-order
+from datetime import datetime
from itertools import chain, cycle, repeat # lint-amnesty, pylint: disable=wrong-import-order
-from smtplib import SMTPAuthenticationError, SMTPConnectError, SMTPDataError, SMTPServerDisconnected, SMTPSenderRefused # lint-amnesty, pylint: disable=wrong-import-order
+from smtplib import ( # lint-amnesty, pylint: disable=wrong-import-order
+ SMTPAuthenticationError,
+ SMTPConnectError,
+ SMTPDataError,
+ SMTPSenderRefused,
+ SMTPServerDisconnected
+)
from unittest.mock import Mock, patch # lint-amnesty, pylint: disable=wrong-import-order
from uuid import uuid4 # lint-amnesty, pylint: disable=wrong-import-order
+
import pytest
-from boto.exception import AWSConnectionError
-from boto.ses.exceptions import (
- SESAddressBlacklistedError,
- SESAddressNotVerifiedError,
- SESDailyQuotaExceededError,
- SESDomainEndsWithDotError,
- SESDomainNotConfirmedError,
- SESIdentityNotVerifiedError,
- SESIllegalAddressError,
- SESLocalAddressCharacterError,
- SESMaxSendingRateExceededError
-)
+from botocore.exceptions import ClientError, EndpointConnectionError
from celery.states import FAILURE, SUCCESS
+from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.management import call_command
from django.test.utils import override_settings
@@ -268,19 +264,22 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase):
def test_ses_blacklisted_user(self):
# Test that celery handles permanent SMTPDataErrors by failing and not retrying.
- self._test_email_address_failures(SESAddressBlacklistedError(554, "Email address is blacklisted"))
+
+ operation_name = ''
+ parsed_response = {'Error': {'Code': 'MessageRejected', 'Message': 'Error Uploading'}}
+ self._test_email_address_failures(ClientError(parsed_response, operation_name))
def test_ses_illegal_address(self):
# Test that celery handles permanent SMTPDataErrors by failing and not retrying.
- self._test_email_address_failures(SESIllegalAddressError(554, "Email address is illegal"))
-
- def test_ses_local_address_character_error(self):
- # Test that celery handles permanent SMTPDataErrors by failing and not retrying.
- self._test_email_address_failures(SESLocalAddressCharacterError(554, "Email address contains a bad character"))
+ operation_name = ''
+ parsed_response = {'Error': {'Code': 'MailFromDomainNotVerifiedException', 'Message': 'Error Uploading'}}
+ self._test_email_address_failures(ClientError(parsed_response, operation_name))
def test_ses_domain_ends_with_dot(self):
# Test that celery handles permanent SMTPDataErrors by failing and not retrying.
- self._test_email_address_failures(SESDomainEndsWithDotError(554, "Email address ends with a dot"))
+ operation_name = ''
+ parsed_response = {'Error': {'Code': 'MailFromDomainNotVerifiedException', 'Message': 'invalid domain'}}
+ self._test_email_address_failures(ClientError(parsed_response, operation_name))
def test_bulk_email_skip_with_non_ascii_emails(self):
"""
@@ -367,12 +366,12 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase):
def test_retry_after_aws_connect_error(self):
self._test_retry_after_limited_retry_error(
- AWSConnectionError("Unable to provide secure connection through proxy")
+ EndpointConnectionError(endpoint_url="Could not connect to the endpoint URL:")
)
def test_max_retry_after_aws_connect_error(self):
self._test_max_retry_limit_causes_failure(
- AWSConnectionError("Unable to provide secure connection through proxy")
+ EndpointConnectionError(endpoint_url="Could not connect to the endpoint URL:")
)
def test_retry_after_general_error(self):
@@ -416,11 +415,6 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase):
SMTPSenderRefused(421, "Throttling: Sending rate exceeded", self.instructor.email)
)
- def test_retry_after_ses_throttling_error(self):
- self._test_retry_after_unlimited_retry_error(
- SESMaxSendingRateExceededError(455, "Throttling: Sending rate exceeded")
- )
-
def _test_immediate_failure(self, exception):
"""Test that celery can hit a maximum number of retries."""
# Doesn't really matter how many recipients, since we expect
@@ -444,18 +438,6 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase):
def test_failure_on_unhandled_smtp(self):
self._test_immediate_failure(SMTPAuthenticationError(403, "That password doesn't work!"))
- def test_failure_on_ses_quota_exceeded(self):
- self._test_immediate_failure(SESDailyQuotaExceededError(403, "You're done for the day!"))
-
- def test_failure_on_ses_address_not_verified(self):
- self._test_immediate_failure(SESAddressNotVerifiedError(403, "Who *are* you?"))
-
- def test_failure_on_ses_identity_not_verified(self):
- self._test_immediate_failure(SESIdentityNotVerifiedError(403, "May I please see an ID!"))
-
- def test_failure_on_ses_domain_not_confirmed(self):
- self._test_immediate_failure(SESDomainNotConfirmedError(403, "You're out of bounds!"))
-
def test_bulk_emails_with_unicode_course_image_name(self):
# Test bulk email with unicode characters in course image name
course_image = '在淡水測試.jpg'
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 9cb75c7c4f..4d1b2a876f 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -54,6 +54,7 @@ from enterprise.constants import (
ENTERPRISE_CATALOG_ADMIN_ROLE,
ENTERPRISE_DASHBOARD_ADMIN_ROLE,
ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE,
+ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE,
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
ENTERPRISE_OPERATOR_ROLE
)
@@ -4633,6 +4634,7 @@ SYSTEM_TO_FEATURE_ROLE_MAPPING = {
ENTERPRISE_CATALOG_ADMIN_ROLE,
ENTERPRISE_ENROLLMENT_API_ADMIN_ROLE,
ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE,
+ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE,
],
}
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index ed4b01da01..ae580a05e9 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -25,7 +25,7 @@ django-storages<1.9
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==3.63.0
+edx-enterprise==3.65.1
# oauthlib>3.0.1 causes test failures ( also remove the django-oauth-toolkit constraint when this is fixed )
oauthlib==3.0.1
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index e9ad77fd97..c5e6ca2605 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -483,7 +483,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.63.0
+edx-enterprise==3.65.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.in
@@ -779,7 +779,7 @@ openedx-events==8.0.0
# -r requirements/edx/base.in
# edx-event-bus-kafka
# edx-event-bus-redis
-openedx-filters==1.2.0
+openedx-filters==1.3.0
# via
# -r requirements/edx/base.in
# lti-consumer-xblock
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index f42d50d787..5283bd1765 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -607,7 +607,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.63.0
+edx-enterprise==3.65.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/testing.txt
@@ -1037,7 +1037,7 @@ openedx-events==8.0.0
# -r requirements/edx/testing.txt
# edx-event-bus-kafka
# edx-event-bus-redis
-openedx-filters==1.2.0
+openedx-filters==1.3.0
# via
# -r requirements/edx/testing.txt
# lti-consumer-xblock
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 2f0a79d3df..464463ac94 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -585,7 +585,7 @@ edx-drf-extensions==8.8.0
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.63.0
+edx-enterprise==3.65.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -984,7 +984,7 @@ openedx-events==8.0.0
# -r requirements/edx/base.txt
# edx-event-bus-kafka
# edx-event-bus-redis
-openedx-filters==1.2.0
+openedx-filters==1.3.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
diff --git a/xmodule/annotatable_block.py b/xmodule/annotatable_block.py
index 774ad90097..9ec6bf21da 100644
--- a/xmodule/annotatable_block.py
+++ b/xmodule/annotatable_block.py
@@ -4,7 +4,7 @@ import logging
import textwrap
from lxml import etree
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.fields import Scope, String
@@ -75,28 +75,28 @@ class AnnotatableBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/html/display.js'),
- resource_string(__name__, 'js/src/annotatable/display.js'),
- resource_string(__name__, 'js/src/javascript_loader.js'),
- resource_string(__name__, 'js/src/collapsible.js'),
+ resource_filename(__name__, 'js/src/html/display.js'),
+ resource_filename(__name__, 'js/src/annotatable/display.js'),
+ resource_filename(__name__, 'js/src/javascript_loader.js'),
+ resource_filename(__name__, 'js/src/collapsible.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/annotatable/display.scss'),
+ resource_filename(__name__, 'css/annotatable/display.scss'),
],
}
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/raw/edit/xml.js'),
+ resource_filename(__name__, 'js/src/raw/edit/xml.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [
- resource_string(__name__, 'css/codemirror/codemirror.scss'),
+ resource_filename(__name__, 'css/codemirror/codemirror.scss'),
],
}
studio_js_module_name = "XMLEditingDescriptor"
diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py
index 3c02053c0e..2b2ec80825 100644
--- a/xmodule/capa_block.py
+++ b/xmodule/capa_block.py
@@ -19,7 +19,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.encoding import smart_str
from django.utils.functional import cached_property
from lxml import etree
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from pytz import utc
from web_fragments.fragment import Fragment
from xblock.core import XBlock
@@ -168,32 +168,32 @@ class ProblemBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/javascript_loader.js'),
- resource_string(__name__, 'js/src/capa/display.js'),
- resource_string(__name__, 'js/src/collapsible.js'),
- resource_string(__name__, 'js/src/capa/imageinput.js'),
- resource_string(__name__, 'js/src/capa/schematic.js'),
+ resource_filename(__name__, 'js/src/javascript_loader.js'),
+ resource_filename(__name__, 'js/src/capa/display.js'),
+ resource_filename(__name__, 'js/src/collapsible.js'),
+ resource_filename(__name__, 'js/src/capa/imageinput.js'),
+ resource_filename(__name__, 'js/src/capa/schematic.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js')
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/capa/display.scss'),
+ resource_filename(__name__, 'css/capa/display.scss'),
],
}
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/problem/edit.js'),
+ resource_filename(__name__, 'js/src/problem/edit.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [
- resource_string(__name__, 'css/editor/edit.scss'),
- resource_string(__name__, 'css/problem/edit.scss'),
+ resource_filename(__name__, 'css/editor/edit.scss'),
+ resource_filename(__name__, 'css/problem/edit.scss'),
]
}
diff --git a/xmodule/conditional_block.py b/xmodule/conditional_block.py
index 91e7af2c15..7fe545d1a3 100644
--- a/xmodule/conditional_block.py
+++ b/xmodule/conditional_block.py
@@ -9,7 +9,7 @@ import logging
from lazy import lazy
from lxml import etree
from opaque_keys.edx.locator import BlockUsageLocator
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.fields import ReferenceList, Scope, String
@@ -148,11 +148,11 @@ class ConditionalBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/conditional/display.js'),
- resource_string(__name__, 'js/src/javascript_loader.js'),
- resource_string(__name__, 'js/src/collapsible.js'),
+ resource_filename(__name__, 'js/src/conditional/display.js'),
+ resource_filename(__name__, 'js/src/javascript_loader.js'),
+ resource_filename(__name__, 'js/src/collapsible.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [],
@@ -161,8 +161,8 @@ class ConditionalBlock(
mako_template = 'widgets/metadata-edit.html'
studio_js_module_name = 'SequenceDescriptor'
studio_view_js = {
- 'js': [resource_string(__name__, 'js/src/sequence/edit.js')],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'js': [resource_filename(__name__, 'js/src/sequence/edit.js')],
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],
diff --git a/xmodule/html_block.py b/xmodule/html_block.py
index ed5d84e395..48786c2c56 100644
--- a/xmodule/html_block.py
+++ b/xmodule/html_block.py
@@ -8,7 +8,7 @@ import sys
import textwrap
from datetime import datetime
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from django.conf import settings
from fs.errors import ResourceNotFound
@@ -144,15 +144,15 @@ class HtmlBlockMixin( # lint-amnesty, pylint: disable=abstract-method
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/html/display.js'),
- resource_string(__name__, 'js/src/javascript_loader.js'),
- resource_string(__name__, 'js/src/collapsible.js'),
- resource_string(__name__, 'js/src/html/imageModal.js'),
- resource_string(__name__, 'js/common_static/js/vendor/draggabilly.js'),
+ resource_filename(__name__, 'js/src/html/display.js'),
+ resource_filename(__name__, 'js/src/javascript_loader.js'),
+ resource_filename(__name__, 'js/src/collapsible.js'),
+ resource_filename(__name__, 'js/src/html/imageModal.js'),
+ resource_filename(__name__, 'js/common_static/js/vendor/draggabilly.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
- preview_view_css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
+ preview_view_css = {'scss': [resource_filename(__name__, 'css/html/display.scss')]}
uses_xmodule_styles_setup = True
@@ -164,14 +164,14 @@ class HtmlBlockMixin( # lint-amnesty, pylint: disable=abstract-method
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/html/edit.js')
+ resource_filename(__name__, 'js/src/html/edit.js')
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [
- resource_string(__name__, 'css/editor/edit.scss'),
- resource_string(__name__, 'css/html/edit.scss')
+ resource_filename(__name__, 'css/editor/edit.scss'),
+ resource_filename(__name__, 'css/html/edit.scss')
]
}
diff --git a/xmodule/library_content_block.py b/xmodule/library_content_block.py
index 04abe5de6e..7193247f58 100644
--- a/xmodule/library_content_block.py
+++ b/xmodule/library_content_block.py
@@ -17,7 +17,7 @@ from lazy import lazy
from lxml import etree
from lxml.etree import XMLSyntaxError
from opaque_keys.edx.locator import LibraryLocator
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from webob import Response
from xblock.completable import XBlockCompletionMode
@@ -97,7 +97,7 @@ class LibraryContentBlock(
preview_view_js = {
'js': [],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [],
@@ -107,9 +107,9 @@ class LibraryContentBlock(
studio_js_module_name = "VerticalDescriptor"
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/vertical/edit.js'),
+ resource_filename(__name__, 'js/src/vertical/edit.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],
diff --git a/xmodule/lti_block.py b/xmodule/lti_block.py
index 8bf60f1c44..76c03d73a6 100644
--- a/xmodule/lti_block.py
+++ b/xmodule/lti_block.py
@@ -68,7 +68,7 @@ import oauthlib.oauth1
from django.conf import settings
from lxml import etree
from oauthlib.oauth1.rfc5849 import signature
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from pytz import UTC
from webob import Response
from web_fragments.fragment import Fragment
@@ -374,13 +374,13 @@ class LTIBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/lti/lti.js')
+ resource_filename(__name__, 'js/src/lti/lti.js')
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/lti/lti.scss')
+ resource_filename(__name__, 'css/lti/lti.scss')
],
}
@@ -389,9 +389,9 @@ class LTIBlock(
studio_js_module_name = 'MetadataOnlyEditingDescriptor'
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/raw/edit/metadata-only.js')
+ resource_filename(__name__, 'js/src/raw/edit/metadata-only.js')
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],
diff --git a/xmodule/modulestore/split_mongo/split.py b/xmodule/modulestore/split_mongo/split.py
index e76f6c8120..f72f950a54 100644
--- a/xmodule/modulestore/split_mongo/split.py
+++ b/xmodule/modulestore/split_mongo/split.py
@@ -57,7 +57,6 @@ Representation:
import copy
import datetime
-import hashlib
import logging
from collections import defaultdict
from importlib import import_module
@@ -102,7 +101,7 @@ from xmodule.modulestore.exceptions import (
)
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.modulestore.split_mongo.mongo_connection import DuplicateKeyError, DjangoFlexPersistenceBackend
-from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
+from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES, derived_key
from xmodule.partitions.partitions_service import PartitionService
from xmodule.util.misc import get_library_or_course_attribute
@@ -2369,6 +2368,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
time this method is called for the same source block and dest_usage, the same resulting
block id will be generated.
+ Note also that this function does not override any of the attributes on the destination
+ block-- it only replaces the destination block's children.
+
:param source_keys: a list of BlockUsageLocators. Order is preserved.
:param dest_usage: The BlockUsageLocator that will become the parent of an inherited copy
@@ -2442,7 +2444,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
for usage_key in source_keys:
src_course_key = usage_key.course_key
- hashable_source_id = src_course_key.for_version(None)
block_key = BlockKey(usage_key.block_type, usage_key.block_id)
source_structure = source_structures[src_course_key]
@@ -2450,15 +2451,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
raise ItemNotFoundError(usage_key)
source_block_info = source_structure['blocks'][block_key]
- # Compute a new block ID. This new block ID must be consistent when this
- # method is called with the same (source_key, dest_structure) pair
- unique_data = "{}:{}:{}".format(
- str(hashable_source_id).encode("utf-8"),
- block_key.id,
- new_parent_block_key.id,
- )
- new_block_id = hashlib.sha1(unique_data.encode('utf-8')).hexdigest()[:20]
- new_block_key = BlockKey(block_key.type, new_block_id)
+ new_block_key = derived_key(src_course_key, block_key, new_parent_block_key)
# Now clone block_key to new_block_key:
new_block_info = copy.deepcopy(source_block_info)
diff --git a/xmodule/modulestore/store_utilities.py b/xmodule/modulestore/store_utilities.py
index 45082ff43e..ced5c9728e 100644
--- a/xmodule/modulestore/store_utilities.py
+++ b/xmodule/modulestore/store_utilities.py
@@ -1,5 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring
-
+import hashlib
import logging
import re
import uuid
@@ -7,6 +7,8 @@ from collections import namedtuple
from xblock.core import XBlock
+from xmodule.modulestore.split_mongo import BlockKey
+
DETACHED_XBLOCK_TYPES = {name for name, __ in XBlock.load_tagged_classes("detached")}
@@ -104,3 +106,31 @@ def get_draft_subtree_roots(draft_nodes):
for draft_node in draft_nodes:
if draft_node.parent_url not in urls:
yield draft_node
+
+
+def derived_key(courselike_source_key, block_key, dest_parent: BlockKey):
+ """
+ Return a new reproducible block ID for a given root, source block, and destination parent.
+
+ When recursively copying a block structure, we need to generate new block IDs for the
+ blocks. We don't want to use the exact same IDs as we might copy blocks multiple times.
+ However, we do want to be able to reproduce the same IDs when copying the same block
+ so that if we ever need to re-copy the block from its source (that is, to update it with
+ upstream changes) we don't affect any data tied to the ID, such as grades.
+
+ This is used by the copy_from_template function of the modulestore, and can be used by
+ pluggable django apps that need to copy blocks from one course to another in an
+ idempotent way. In the future, this should be created into a proper API function
+ in the spirit of OEP-49.
+ """
+ hashable_source_id = courselike_source_key.for_version(None)
+
+ # Compute a new block ID. This new block ID must be consistent when this
+ # method is called with the same (source_key, dest_structure) pair
+ unique_data = "{}:{}:{}".format(
+ str(hashable_source_id).encode("utf-8"),
+ block_key.id,
+ dest_parent.id,
+ )
+ new_block_id = hashlib.sha1(unique_data.encode('utf-8')).hexdigest()[:20]
+ return BlockKey(block_key.type, new_block_id)
diff --git a/xmodule/modulestore/tests/test_store_utilities.py b/xmodule/modulestore/tests/test_store_utilities.py
index 1c1c86f4fc..a0b5cfcbdb 100644
--- a/xmodule/modulestore/tests/test_store_utilities.py
+++ b/xmodule/modulestore/tests/test_store_utilities.py
@@ -4,11 +4,15 @@ Tests for store_utilities.py
import unittest
+from unittest import TestCase
from unittest.mock import Mock
import ddt
-from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots
+from opaque_keys.edx.keys import CourseKey
+
+from xmodule.modulestore.split_mongo import BlockKey
+from xmodule.modulestore.store_utilities import draft_node_constructor, get_draft_subtree_roots, derived_key
@ddt.ddt
@@ -82,3 +86,43 @@ class TestUtils(unittest.TestCase):
subtree_roots_urls = [root.url for root in get_draft_subtree_roots(block_nodes)]
# check that we return the expected urls
assert set(subtree_roots_urls) == set(expected_roots_urls)
+
+
+mock_block = Mock()
+mock_block.id = CourseKey.from_string('course-v1:Beeper+B33P+BOOP')
+
+
+derived_key_scenarios = [
+ {
+ 'courselike_source_key': CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'),
+ 'block_key': BlockKey('chapter', 'interactive_demonstrations'),
+ 'parent': mock_block,
+ 'expected': BlockKey(
+ 'chapter', '5793ec64e25ed870a7dd',
+ ),
+ },
+ {
+ 'courselike_source_key': CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'),
+ 'block_key': BlockKey('chapter', 'interactive_demonstrations'),
+ 'parent': BlockKey(
+ 'chapter', 'thingy',
+ ),
+ 'expected': BlockKey(
+ 'chapter', '599792a5622d85aa41e6',
+ ),
+ }
+]
+
+
+@ddt.ddt
+class TestDerivedKey(TestCase):
+ """
+ Test reproducible block ID generation.
+ """
+ @ddt.data(*derived_key_scenarios)
+ @ddt.unpack
+ def test_derived_key(self, courselike_source_key, block_key, parent, expected):
+ """
+ Test that derived_key returns the expected value.
+ """
+ self.assertEqual(derived_key(courselike_source_key, block_key, parent), expected)
diff --git a/xmodule/poll_block.py b/xmodule/poll_block.py
index b29ed710e9..0ce6d38e1e 100644
--- a/xmodule/poll_block.py
+++ b/xmodule/poll_block.py
@@ -13,7 +13,7 @@ import logging
from collections import OrderedDict
from copy import deepcopy
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from lxml import etree
@@ -86,15 +86,15 @@ class PollBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/javascript_loader.js'),
- resource_string(__name__, 'js/src/poll/poll.js'),
- resource_string(__name__, 'js/src/poll/poll_main.js')
+ resource_filename(__name__, 'js/src/javascript_loader.js'),
+ resource_filename(__name__, 'js/src/poll/poll.js'),
+ resource_filename(__name__, 'js/src/poll/poll_main.js')
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/poll/display.scss')
+ resource_filename(__name__, 'css/poll/display.scss')
],
}
@@ -102,7 +102,7 @@ class PollBlock(
# the static_content command happy.
studio_view_js = {
'js': [],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js')
}
studio_view_css = {
diff --git a/xmodule/seq_block.py b/xmodule/seq_block.py
index 0d8be411fd..250a69d83b 100644
--- a/xmodule/seq_block.py
+++ b/xmodule/seq_block.py
@@ -14,7 +14,7 @@ from django.conf import settings
from lxml import etree
from opaque_keys.edx.keys import UsageKey
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from pytz import UTC
from web_fragments.fragment import Fragment
from xblock.completable import XBlockCompletionMode
@@ -273,14 +273,14 @@ class SequenceBlock(
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/sequence/display.js'),
+ resource_filename(__name__, 'js/src/sequence/display.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js')
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/sequence/display.scss'),
+ resource_filename(__name__, 'css/sequence/display.scss'),
],
}
@@ -288,7 +288,7 @@ class SequenceBlock(
# the static_content command happy.
studio_view_js = {
'js': [],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js')
}
studio_view_css = {
diff --git a/xmodule/split_test_block.py b/xmodule/split_test_block.py
index 7feceebfee..b638eb7a50 100644
--- a/xmodule/split_test_block.py
+++ b/xmodule/split_test_block.py
@@ -12,7 +12,7 @@ from uuid import uuid4
from django.utils.functional import cached_property
from lxml import etree
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from webob import Response
from xblock.core import XBlock
@@ -160,7 +160,7 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
preview_view_js = {
'js': [],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [],
@@ -169,8 +169,8 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
mako_template = "widgets/metadata-only-edit.html"
studio_js_module_name = 'SequenceDescriptor'
studio_view_js = {
- 'js': [resource_string(__name__, 'js/src/sequence/edit.js')],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'js': [resource_filename(__name__, 'js/src/sequence/edit.js')],
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],
diff --git a/xmodule/static_content.py b/xmodule/static_content.py
index 2447347da9..58ffcb2de1 100755
--- a/xmodule/static_content.py
+++ b/xmodule/static_content.py
@@ -13,7 +13,7 @@ import os
import sys
import textwrap
from collections import defaultdict
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
import django
from docopt import docopt
@@ -43,27 +43,27 @@ class VideoBlock(HTMLSnippet): # lint-amnesty, pylint: disable=abstract-method
preview_view_js = {
'js': [
- resource_string(__name__, 'js/src/video/10_main.js'),
+ resource_filename(__name__, 'js/src/video/10_main.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js')
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/video/display.scss'),
- resource_string(__name__, 'css/video/accessible_menu.scss'),
+ resource_filename(__name__, 'css/video/display.scss'),
+ resource_filename(__name__, 'css/video/accessible_menu.scss'),
],
}
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/tabs/tabs-aggregator.js'),
+ resource_filename(__name__, 'js/src/tabs/tabs-aggregator.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [
- resource_string(__name__, 'css/tabs/tabs.scss'),
+ resource_filename(__name__, 'css/tabs/tabs.scss'),
]
}
@@ -132,7 +132,9 @@ def _write_styles(selector, output_root, classes, css_attribute, suffix):
for class_ in classes:
class_css = getattr(class_, css_attribute)()
for filetype in ('sass', 'scss', 'css'):
- for idx, fragment in enumerate(class_css.get(filetype, [])):
+ for idx, fragment_path in enumerate(class_css.get(filetype, [])):
+ with open(fragment_path, 'rb') as fragment_file:
+ fragment = fragment_file.read()
css_fragments[idx, filetype, fragment].add(class_.__name__)
css_imports = defaultdict(set)
for (idx, filetype, fragment), classes in sorted(css_fragments.items()): # lint-amnesty, pylint: disable=redefined-argument-from-local
@@ -177,10 +179,14 @@ def _write_js(output_root, classes, js_attribute):
fragment_owners = defaultdict(list)
for class_ in classes:
module_js = getattr(class_, js_attribute)()
+ with open(module_js.get('xmodule_js'), 'rb') as xmodule_js_file:
+ xmodule_js_fragment = xmodule_js_file.read()
# It will enforce 000 prefix for xmodule.js.
- fragment_owners[(0, 'js', module_js.get('xmodule_js'))].append(getattr(class_, js_attribute + '_bundle_name')())
+ fragment_owners[(0, 'js', xmodule_js_fragment)].append(getattr(class_, js_attribute + '_bundle_name')())
for filetype in ('coffee', 'js'):
- for idx, fragment in enumerate(module_js.get(filetype, [])):
+ for idx, fragment_path in enumerate(module_js.get(filetype, [])):
+ with open(fragment_path, 'rb') as fragment_file:
+ fragment = fragment_file.read()
fragment_owners[(idx + 1, filetype, fragment)].append(getattr(class_, js_attribute + '_bundle_name')())
for (idx, filetype, fragment), owners in sorted(fragment_owners.items()):
diff --git a/xmodule/template_block.py b/xmodule/template_block.py
index 70bafca775..71b2c21f14 100644
--- a/xmodule/template_block.py
+++ b/xmodule/template_block.py
@@ -6,7 +6,7 @@ from string import Template
from xblock.core import XBlock
from lxml import etree
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from xmodule.editing_block import EditingMixin
from xmodule.raw_block import RawMixin
@@ -67,17 +67,17 @@ class CustomTagBlock(CustomTagTemplateBlock): # pylint: disable=abstract-method
preview_view_js = {
'js': [],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [],
}
studio_view_js = {
- 'js': [resource_string(__name__, 'js/src/raw/edit/xml.js')],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'js': [resource_filename(__name__, 'js/src/raw/edit/xml.js')],
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
- 'scss': [resource_string(__name__, 'css/codemirror/codemirror.scss')],
+ 'scss': [resource_filename(__name__, 'css/codemirror/codemirror.scss')],
}
def studio_view(self, _context):
diff --git a/xmodule/word_cloud_block.py b/xmodule/word_cloud_block.py
index 2e7fadd0a2..d7d35dedc5 100644
--- a/xmodule/word_cloud_block.py
+++ b/xmodule/word_cloud_block.py
@@ -10,7 +10,7 @@ If student have answered - words he entered and cloud.
import json
import logging
-from pkg_resources import resource_string
+from pkg_resources import resource_filename
from web_fragments.fragment import Fragment
from xblock.core import XBlock
@@ -114,21 +114,21 @@ class WordCloudBlock( # pylint: disable=abstract-method
preview_view_js = {
'js': [
- resource_string(__name__, 'assets/word_cloud/src/js/word_cloud.js'),
+ resource_filename(__name__, 'assets/word_cloud/src/js/word_cloud.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
preview_view_css = {
'scss': [
- resource_string(__name__, 'css/word_cloud/display.scss'),
+ resource_filename(__name__, 'css/word_cloud/display.scss'),
],
}
studio_view_js = {
'js': [
- resource_string(__name__, 'js/src/raw/edit/metadata-only.js'),
+ resource_filename(__name__, 'js/src/raw/edit/metadata-only.js'),
],
- 'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
+ 'xmodule_js': resource_filename(__name__, 'js/src/xmodule.js'),
}
studio_view_css = {
'scss': [],