Merge branch 'master' into Ali-Abbas/badge-styles

This commit is contained in:
Syed Ali Abbas Zaidi
2023-05-26 10:24:55 +05:00
committed by GitHub
27 changed files with 607 additions and 360 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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',

View File

@@ -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 = '<div>test</div>'
# 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, '<div>test</div>')
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 = '<div>other stuff</div>'
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)

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
],
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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'),
]
}

View File

@@ -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': [],

View File

@@ -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')
]
}

View File

@@ -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': [],

View File

@@ -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': [],

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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': [],

View File

@@ -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()):

View File

@@ -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):

View File

@@ -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': [],