"
diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py
index 7c3a369fed6..79c722e24d5 100644
--- a/cms/djangoapps/contentstore/toggles.py
+++ b/cms/djangoapps/contentstore/toggles.py
@@ -2,6 +2,7 @@
CMS feature toggles.
"""
from edx_toggles.toggles import SettingDictToggle, WaffleFlag
+from openedx.core.djangoapps.content.search import api as search_api
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
# .. toggle_name: FEATURES['ENABLE_EXPORT_GIT']
@@ -593,3 +594,76 @@ def default_enable_flexible_peer_openassessments(course_key):
level to opt in/out of rolling forward this feature.
"""
return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key)
+
+
+# .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES']
+# .. toggle_implementation: SettingDictToggle
+# .. toggle_default: True
+# .. toggle_description: Enables use of the legacy and v2 libraries waffle flags.
+# Note that legacy content libraries are only supported in courses using split mongo.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2015-03-06
+# .. toggle_target_removal_date: 2025-04-09
+# .. toggle_warning: This flag is deprecated in Sumac, and will be removed in favor of the disable_legacy_libraries and
+# disable_new_libraries waffle flags.
+ENABLE_CONTENT_LIBRARIES = SettingDictToggle(
+ "FEATURES", "ENABLE_CONTENT_LIBRARIES", default=True, module_name=__name__
+)
+
+# .. toggle_name: contentstore.new_studio_mfe.disable_legacy_libraries
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Hides legacy (v1) Libraries tab in Authoring MFE.
+# This toggle interacts with ENABLE_CONTENT_LIBRARIES toggle: if this is disabled, then legacy libraries are also
+# disabled.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2024-10-02
+# .. toggle_target_removal_date: 2025-04-09
+# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
+# .. toggle_warning: Legacy libraries are deprecated in Sumac, cf https://github.com/openedx/edx-platform/issues/32457
+DISABLE_LEGACY_LIBRARIES = WaffleFlag(
+ f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_legacy_libraries',
+ __name__,
+ CONTENTSTORE_LOG_PREFIX,
+)
+
+
+def libraries_v1_enabled():
+ """
+ Returns a boolean if Libraries V2 is enabled in the new Studio Home.
+ """
+ return (
+ ENABLE_CONTENT_LIBRARIES.is_enabled() and
+ not DISABLE_LEGACY_LIBRARIES.is_enabled()
+ )
+
+
+# .. toggle_name: contentstore.new_studio_mfe.disable_new_libraries
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Hides new Libraries v2 tab in Authoring MFE.
+# This toggle interacts with settings.MEILISEARCH_ENABLED and ENABLE_CONTENT_LIBRARIES toggle: if these flags are
+# False, then v2 libraries are also disabled.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2024-10-02
+# .. toggle_target_removal_date: 2025-04-09
+# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
+# .. toggle_warning: Libraries v2 are in beta for Sumac, will be fully supported in Teak.
+DISABLE_NEW_LIBRARIES = WaffleFlag(
+ f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_new_libraries',
+ __name__,
+ CONTENTSTORE_LOG_PREFIX,
+)
+
+
+def libraries_v2_enabled():
+ """
+ Returns a boolean if Libraries V2 is enabled in the new Studio Home.
+
+ Requires the ENABLE_CONTENT_LIBRARIES feature flag to be enabled, plus Meilisearch.
+ """
+ return (
+ ENABLE_CONTENT_LIBRARIES.is_enabled() and
+ search_api.is_meilisearch_enabled() and
+ not DISABLE_NEW_LIBRARIES.is_enabled()
+ )
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index b268bd6fcb5..964f0c57e75 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -11,16 +11,19 @@
from urllib.parse import quote_plus
from uuid import uuid4
+from bs4 import BeautifulSoup
from django.conf import settings
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils import translation
+from django.utils.text import Truncator
from django.utils.translation import gettext as _
from eventtracking import tracker
from help_tokens.core import HelpUrlExpert
from lti_consumer.models import CourseAllowPIISharingInLTIFlag
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryLocator
+
from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME
from openedx_events.content_authoring.data import DuplicatedXBlockData
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
@@ -31,7 +34,32 @@
from pytz import UTC
from xblock.fields import Scope
-from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
+from cms.djangoapps.contentstore.toggles import (
+ exam_setting_view_enabled,
+ libraries_v1_enabled,
+ libraries_v2_enabled,
+ split_library_view_on_dashboard,
+ use_new_advanced_settings_page,
+ use_new_course_outline_page,
+ use_new_certificates_page,
+ use_new_export_page,
+ use_new_files_uploads_page,
+ use_new_grading_page,
+ use_new_group_configurations_page,
+ use_new_course_team_page,
+ use_new_home_page,
+ use_new_import_page,
+ use_new_schedule_details_page,
+ use_new_text_editor,
+ use_new_textbooks_page,
+ use_new_unit_page,
+ use_new_updates_page,
+ use_new_video_editor,
+ use_new_video_uploads_page,
+ use_new_custom_pages,
+)
+from cms.djangoapps.models.settings.course_grading import CourseGradingModel
+from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_modes.models import CourseMode
@@ -72,30 +100,7 @@
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
-from cms.djangoapps.contentstore.toggles import (
- split_library_view_on_dashboard,
- use_new_advanced_settings_page,
- use_new_course_outline_page,
- use_new_certificates_page,
- use_new_export_page,
- use_new_files_uploads_page,
- use_new_grading_page,
- use_new_group_configurations_page,
- use_new_course_team_page,
- use_new_home_page,
- use_new_import_page,
- use_new_schedule_details_page,
- use_new_text_editor,
- use_new_textbooks_page,
- use_new_unit_page,
- use_new_updates_page,
- use_new_video_editor,
- use_new_video_uploads_page,
- use_new_custom_pages,
-)
-from cms.djangoapps.models.settings.course_grading import CourseGradingModel
-from cms.djangoapps.models.settings.course_metadata import CourseMetadata
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
@@ -424,6 +429,18 @@ def get_course_outline_url(course_locator) -> str:
return course_outline_url
+def get_library_content_picker_url(course_locator) -> str:
+ """
+ Gets course authoring microfrontend library content picker URL for the given parent block.
+ """
+ content_picker_url = None
+ if libraries_v2_enabled():
+ mfe_base_url = get_course_authoring_url(course_locator)
+ content_picker_url = f'{mfe_base_url}/component-picker?variant=published'
+
+ return content_picker_url
+
+
def get_unit_url(course_locator, unit_locator) -> str:
"""
Gets course authoring microfrontend URL for unit page view.
@@ -1262,7 +1279,7 @@ def load_services_for_studio(runtime, user):
"settings": SettingsService(),
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
"teams_configuration": TeamsConfigurationService(),
- "library_tools": LibraryToolsService(modulestore(), user.id)
+ "library_tools": LegacyLibraryToolsService(modulestore(), user.id)
}
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
@@ -1533,10 +1550,10 @@ def get_library_context(request, request_is_json=False):
_format_library_for_view,
)
from cms.djangoapps.contentstore.views.library import (
- LIBRARIES_ENABLED,
+ user_can_view_create_library_button,
)
- libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else []
+ libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else []
data = {
'libraries': [_format_library_for_view(lib, request) for lib in libraries],
}
@@ -1546,8 +1563,8 @@ def get_library_context(request, request_is_json=False):
**data,
'in_process_course_actions': [],
'courses': [],
- 'libraries_enabled': LIBRARIES_ENABLED,
- 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active,
+ 'libraries_enabled': libraries_v1_enabled(),
+ 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active,
'user': request.user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
@@ -1667,9 +1684,6 @@ def get_home_context(request, no_course=False):
ENABLE_GLOBAL_STAFF_OPTIMIZATION,
)
from cms.djangoapps.contentstore.views.library import (
- LIBRARY_AUTHORING_MICROFRONTEND_URL,
- LIBRARIES_ENABLED,
- should_redirect_to_library_authoring_mfe,
user_can_view_create_library_button,
)
@@ -1685,7 +1699,7 @@ def get_home_context(request, no_course=False):
if not no_course:
active_courses, archived_courses, in_process_course_actions = get_course_context(request)
- if not split_library_view_on_dashboard() and LIBRARIES_ENABLED and not no_course:
+ if not split_library_view_on_dashboard() and libraries_v1_enabled() and not no_course:
libraries = get_library_context(request, True)['libraries']
home_context = {
@@ -1693,14 +1707,13 @@ def get_home_context(request, no_course=False):
'split_studio_home': split_library_view_on_dashboard(),
'archived_courses': archived_courses,
'in_process_course_actions': in_process_course_actions,
- 'libraries_enabled': LIBRARIES_ENABLED,
+ 'libraries_enabled': libraries_v1_enabled(),
+ 'libraries_v1_enabled': libraries_v1_enabled(),
+ 'libraries_v2_enabled': libraries_v2_enabled(),
'taxonomies_enabled': not is_tagging_feature_disabled(),
- 'redirect_to_library_authoring_mfe': should_redirect_to_library_authoring_mfe(),
- 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL,
'taxonomy_list_mfe_url': get_taxonomy_list_url(),
'libraries': libraries,
- 'show_new_library_button': user_can_view_create_library_button(user)
- and not should_redirect_to_library_authoring_mfe(),
+ 'show_new_library_button': user_can_view_create_library_button(user),
'user': user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(user),
@@ -1712,6 +1725,7 @@ def get_home_context(request, no_course=False):
'allowed_organizations': get_allowed_organizations(user),
'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user),
'can_create_organizations': user_can_create_organizations(user),
+ 'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user),
}
return home_context
@@ -2041,6 +2055,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint
'user_clipboard': user_clipboard,
'is_fullwidth_content': is_library_xblock,
'course_sequence_ids': course_sequence_ids,
+ 'library_content_picker_url': get_library_content_picker_url(course.id),
}
return context
@@ -2197,7 +2212,7 @@ class StudioPermissionsService:
Deprecated. To be replaced by a more general authorization service.
- Only used by LibraryContentBlock (and library_tools.py).
+ Only used by LegacyLibraryContentBlock (and library_tools.py).
"""
def __init__(self, user):
@@ -2239,11 +2254,21 @@ def track_course_update_event(course_key, user, course_update_content=None):
tracker.emit(event_name, event_data)
+def clean_html_body(html_body):
+ """
+ Get html body, remove tags and limit to 500 characters
+ """
+ html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
+ text_content = html_body.get_text(separator=" ").strip()
+ text_content = text_content.replace('\n', '').replace('\r', '')
+ return text_content
+
+
def send_course_update_notification(course_key, content, user):
"""
Send course update notification
"""
- text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content))
+ text_content = re.sub(r"(\s| |//)+", " ", clean_html_body(content))
course = modulestore().get_course(course_key)
extra_context = {
'author_id': user.id,
@@ -2252,10 +2277,10 @@ def send_course_update_notification(course_key, content, user):
notification_data = CourseNotificationData(
course_key=course_key,
content_context={
- "course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view",
+ "course_update_content": text_content,
**extra_context,
},
- notification_type="course_update",
+ notification_type="course_updates",
content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates",
app_name="updates",
audience_filters={},
diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py
index 4d6c17838e5..e6b41dc261d 100644
--- a/cms/djangoapps/contentstore/views/block.py
+++ b/cms/djangoapps/contentstore/views/block.py
@@ -135,6 +135,8 @@ def xblock_handler(request, usage_key_string=None):
if duplicate_source_locator is not present
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
fields except parent_locator)
+ :library_content_key: the key of the library content to add. (Incompatible with
+ all other fields except parent_locator)
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
"""
return handle_xblock(request, usage_key_string)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index ba767df78dc..46f2dd322ef 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -11,6 +11,7 @@
from django.http import Http404, HttpResponseBadRequest
from django.shortcuts import redirect
from django.utils.translation import gettext as _
+from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.http import require_GET
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
@@ -25,7 +26,7 @@
from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
from cms.djangoapps.contentstore.helpers import is_unit
-from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_unit_page
+from cms.djangoapps.contentstore.toggles import libraries_v2_enabled, use_new_problem_editor, use_new_unit_page
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
@@ -35,13 +36,24 @@
__all__ = [
'container_handler',
- 'component_handler'
+ 'component_handler',
+ 'container_embed_handler',
]
log = logging.getLogger(__name__)
# NOTE: This list is disjoint from ADVANCED_COMPONENT_TYPES
-COMPONENT_TYPES = ['discussion', 'library', 'html', 'openassessment', 'problem', 'video', 'drag-and-drop-v2']
+COMPONENT_TYPES = [
+ 'discussion',
+ 'library',
+ 'library_v2', # Not an XBlock
+ 'itembank',
+ 'html',
+ 'openassessment',
+ 'problem',
+ 'video',
+ 'drag-and-drop-v2',
+]
ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES))
@@ -56,7 +68,7 @@
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history", "tag-list",
"unit-outline", "container-message", "container-access", "license-selector", "copy-clipboard-button",
- "edit-title-button",
+ "edit-title-button", "edit-upstream-alert",
]
@@ -95,6 +107,10 @@ def _load_mixed_class(category):
"""
Load an XBlock by category name, and apply all defined mixins
"""
+ # Libraries v2 content doesn't have an XBlock.
+ if category == 'library_v2':
+ return None
+
component_class = XBlock.load_class(category)
mixologist = Mixologist(settings.XBLOCK_MIXINS)
return mixologist.mix(component_class)
@@ -141,6 +157,36 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
return HttpResponseBadRequest("Only supports HTML requests")
+@require_GET
+@login_required
+@xframe_options_exempt
+def container_embed_handler(request, usage_key_string): # pylint: disable=too-many-statements
+ """
+ Returns an HttpResponse with HTML content for the container XBlock.
+ The returned HTML is a chromeless rendering of the XBlock.
+
+ GET
+ html: returns the HTML page for editing a container
+ json: not currently supported
+ """
+
+ # Avoiding a circular dependency
+ from ..utils import get_container_handler_context
+
+ try:
+ usage_key = UsageKey.from_string(usage_key_string)
+ except InvalidKeyError: # Raise Http404 on invalid 'usage_key_string'
+ return HttpResponseBadRequest()
+ with modulestore().bulk_operations(usage_key.course_key):
+ try:
+ course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
+ except ItemNotFoundError:
+ raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
+
+ container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
+ return render_to_response('container_chromeless.html', container_handler_context)
+
+
def get_component_templates(courselike, library=False): # lint-amnesty, pylint: disable=too-many-statements
"""
Returns the applicable component templates that can be used by the specified course or library.
@@ -215,7 +261,9 @@ def create_support_legend_dict():
'problem': _("Problem"),
'video': _("Video"),
'openassessment': _("Open Response"),
- 'library': _("Library Content"),
+ 'library': _("Legacy Library"),
+ 'library_v2': _("Library Content"),
+ 'itembank': _("Problem Bank"),
'drag-and-drop-v2': _("Drag and Drop"),
}
@@ -245,7 +293,7 @@ def create_support_legend_dict():
templates_for_category = []
component_class = _load_mixed_class(category)
- if support_level_without_template and category != 'library':
+ if support_level_without_template and category not in ['library']:
# add the default template with localized display name
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
@@ -440,6 +488,9 @@ def _filter_disabled_blocks(all_blocks):
Filter out disabled xblocks from the provided list of xblock names.
"""
disabled_block_names = [block.name for block in disabled_xblocks()]
+ if not libraries_v2_enabled():
+ disabled_block_names.append('library_v2')
+ disabled_block_names.append('itembank')
return [block_name for block_name in all_blocks if block_name not in disabled_block_names]
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 870c192653d..92e4329c2f9 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -41,8 +41,8 @@
)
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
-from ..config.waffle import REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND
from ..utils import add_instructor, reverse_library_url
+from ..toggles import libraries_v1_enabled
from .component import CONTAINER_TEMPLATES, get_component_templates
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
from .user import user_with_role
@@ -51,52 +51,11 @@
log = logging.getLogger(__name__)
-LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
-ENABLE_LIBRARY_AUTHORING_MICROFRONTEND = settings.FEATURES.get('ENABLE_LIBRARY_AUTHORING_MICROFRONTEND', False)
-LIBRARY_AUTHORING_MICROFRONTEND_URL = settings.LIBRARY_AUTHORING_MICROFRONTEND_URL
-
-def should_redirect_to_library_authoring_mfe():
- """
- Boolean helper method, returns whether or not to redirect to the Library
- Authoring MFE based on settings and flags.
- """
-
- return (
- ENABLE_LIBRARY_AUTHORING_MICROFRONTEND and
- LIBRARY_AUTHORING_MICROFRONTEND_URL and
- REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND.is_enabled()
- )
-
-
-def user_can_view_create_library_button(user):
- """
- Helper method for displaying the visibilty of the create_library_button.
- """
- if not LIBRARIES_ENABLED:
- return False
- elif user.is_staff:
- return True
- elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
- is_course_creator = get_course_creator_status(user) == 'granted'
- has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists()
- has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists()
- has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists()
- return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
- else:
- # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
- disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
- disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False)
- if disable_library_creation is not None:
- return not disable_library_creation
- else:
- return not disable_course_creation
-
-
-def user_can_create_library(user, org):
+def _user_can_create_library_for_org(user, org=None):
"""
Helper method for returning the library creation status for a particular user,
- taking into account the value LIBRARIES_ENABLED.
+ taking into account the libraries_v1_enabled toggle.
if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org),
if library creation is enabled.
@@ -109,29 +68,29 @@ def user_can_create_library(user, org):
Course Staff: Can make libraries in the organization which has courses of which they are staff.
Course Admin: Can make libraries in the organization which has courses of which they are Admin.
"""
- if org is None:
- return False
- if not LIBRARIES_ENABLED:
+ if not libraries_v1_enabled():
return False
elif user.is_staff:
return True
- if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ org_filter_params = {}
+ if org:
+ org_filter_params['org'] = org
is_course_creator = get_course_creator_status(user) == 'granted'
- has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user)
+ has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists()
has_course_staff_role = (
UserBasedRole(user=user, role=CourseStaffRole.ROLE)
.courses_with_role()
- .filter(org=org)
+ .filter(**org_filter_params)
.exists()
)
has_course_admin_role = (
UserBasedRole(user=user, role=CourseInstructorRole.ROLE)
.courses_with_role()
- .filter(org=org)
+ .filter(**org_filter_params)
.exists()
)
return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
-
else:
# EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
@@ -142,6 +101,22 @@ def user_can_create_library(user, org):
return not disable_course_creation
+def user_can_view_create_library_button(user):
+ """
+ Helper method for displaying the visibilty of the create_library_button.
+ """
+ return _user_can_create_library_for_org(user)
+
+
+def user_can_create_library(user, org):
+ """
+ Helper method for to check if user can create library for given org.
+ """
+ if org is None:
+ return False
+ return _user_can_create_library_for_org(user, org)
+
+
@login_required
@ensure_csrf_cookie
@require_http_methods(('GET', 'POST'))
@@ -149,7 +124,7 @@ def library_handler(request, library_key_string=None):
"""
RESTful interface to most content library related functionality.
"""
- if not LIBRARIES_ENABLED:
+ if not libraries_v1_enabled():
log.exception("Attempted to use the content library API when the libraries feature is disabled.")
raise Http404 # Should never happen because we test the feature in urls.py also
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index acc5fc95dfe..aa7421bb87b 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -300,8 +300,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
course = modulestore().get_course(xblock.location.course_key)
can_edit = context.get('can_edit', True)
+ can_add = context.get('can_add', True)
# Is this a course or a library?
- is_course = xblock.scope_ids.usage_id.context_key.is_course
+ is_course = xblock.context_key.is_course
tags_count_map = context.get('tags_count_map')
tags_count = 0
if tags_count_map:
@@ -320,7 +321,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'is_selected': context.get('is_selected', False),
'selectable': context.get('selectable', False),
'selected_groups_label': selected_groups_label,
- 'can_add': context.get('can_add', True),
+ 'can_add': can_add,
+ # Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
+ # which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
+ 'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
'can_move': context.get('can_move', is_course),
'language': getattr(course, 'language', None),
'is_course': is_course,
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index fc119c7edd5..f3e20b45b2e 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -982,7 +982,7 @@ def test_shallow_duplicate(self):
def test_duplicate_library_content_block(self): # pylint: disable=too-many-statements
"""
- Test the LibraryContentBlock's special duplication process.
+ Test the LegacyLibraryContentBlock's special duplication process.
"""
store = modulestore()
@@ -3674,14 +3674,15 @@ def test_special_exam_xblock_info(
@patch_does_backend_support_onboarding
@patch_get_exam_by_content_id_success
@ddt.data(
- ("lti_external", False),
- ("other_proctoring_backend", True),
+ ("lti_external", False, None),
+ ("other_proctoring_backend", True, "test_url"),
)
@ddt.unpack
- def test_support_onboarding_is_correct_depending_on_lti_external(
+ def test_proctoring_values_correct_depending_on_lti_external(
self,
external_id,
- expected_value,
+ expected_supports_onboarding_value,
+ expected_proctoring_link,
mock_get_exam_by_content_id,
mock_does_backend_support_onboarding,
_mock_get_exam_configuration_dashboard_url,
@@ -3691,8 +3692,9 @@ def test_support_onboarding_is_correct_depending_on_lti_external(
category="sequential",
display_name="Test Lesson 1",
user_id=self.user.id,
- is_proctored_enabled=False,
- is_time_limited=False,
+ is_proctored_enabled=True,
+ is_time_limited=True,
+ default_time_limit_minutes=100,
is_onboarding_exam=False,
)
@@ -3709,7 +3711,8 @@ def test_support_onboarding_is_correct_depending_on_lti_external(
include_children_predicate=ALWAYS,
course=self.course,
)
- assert xblock_info["supports_onboarding"] is expected_value
+ assert xblock_info["supports_onboarding"] is expected_supports_onboarding_value
+ assert xblock_info["proctoring_exam_configuration_link"] == expected_proctoring_link
@patch_get_exam_configuration_dashboard_url
@patch_does_backend_support_onboarding
@@ -3773,6 +3776,42 @@ def test_xblock_was_never_proctortrack_proctored_exam(
assert xblock_info["was_exam_ever_linked_with_external"] is False
assert mock_get_exam_by_content_id.call_count == 1
+ @patch_get_exam_configuration_dashboard_url
+ @patch_does_backend_support_onboarding
+ @patch_get_exam_by_content_id_success
+ def test_special_exam_xblock_info_get_dashboard_error(
+ self,
+ mock_get_exam_by_content_id,
+ _mock_does_backend_support_onboarding,
+ mock_get_exam_configuration_dashboard_url,
+ ):
+ sequential = BlockFactory.create(
+ parent_location=self.chapter.location,
+ category="sequential",
+ display_name="Test Lesson 1",
+ user_id=self.user.id,
+ is_proctored_enabled=True,
+ is_time_limited=True,
+ default_time_limit_minutes=100,
+ is_onboarding_exam=False,
+ )
+ sequential = modulestore().get_item(sequential.location)
+ mock_get_exam_configuration_dashboard_url.side_effect = Exception("proctoring error")
+ xblock_info = create_xblock_info(
+ sequential,
+ include_child_info=True,
+ include_children_predicate=ALWAYS,
+ )
+
+ # no errors should be raised and proctoring_exam_configuration_link is None
+ assert xblock_info["is_proctored_exam"] is True
+ assert xblock_info["was_exam_ever_linked_with_external"] is True
+ assert xblock_info["is_time_limited"] is True
+ assert xblock_info["default_time_limit_minutes"] == 100
+ assert xblock_info["proctoring_exam_configuration_link"] is None
+ assert xblock_info["supports_onboarding"] is True
+ assert xblock_info["is_onboarding_exam"] is False
+
class TestLibraryXBlockInfo(ModuleStoreTestCase):
"""
diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
index 76dba57f1d6..a818da81d10 100644
--- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
+++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
@@ -10,7 +10,7 @@
from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
-from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
+from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
from cms.djangoapps.contentstore.utils import reverse_usage_url
from openedx.core.djangoapps.content_libraries import api as library_api
@@ -165,12 +165,12 @@ def _setup_tagged_content(self, course_key) -> dict:
publish_item=True,
).location
- library = ClipboardLibraryContentPasteTestCase.setup_library()
+ library = ClipboardPasteFromV1LibraryTestCase.setup_library()
with self.store.bulk_operations(course_key):
library_content_block_key = BlockFactory.create(
parent=self.store.get_item(unit_key),
category="library_content",
- source_library_id=str(library.key),
+ source_library_id=str(library.context_key),
display_name="LC Block",
publish_item=True,
).location
@@ -184,7 +184,7 @@ def _setup_tagged_content(self, course_key) -> dict:
tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
- Tag.objects.create(taxonomy=taxonomy_all_org, value=tag_value)
+ tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
tagging_api.tag_object(
object_id=str(unit_key),
taxonomy=taxonomy_all_org,
@@ -393,9 +393,9 @@ def test_paste_with_assets(self):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
-class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
+class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase):
"""
- Test Clipboard Paste functionality with library content
+ Test Clipboard Paste functionality with a "new" (as of Sumac) library
"""
def setUp(self):
@@ -406,14 +406,178 @@ def setUp(self):
self.client = APIClient()
self.client.login(username=self.user.username, password=self.user_password)
self.store = modulestore()
- library = self.setup_library()
+
+ self.library = library_api.create_library(
+ library_type=library_api.COMPLEX,
+ org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
+ slug="lib",
+ title="Library",
+ )
+
+ self.lib_block_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key # v==1
+ library_api.set_library_block_olx(self.lib_block_key, """
+
+
+
+
+ Wrong
+ Right
+
+
+
+ """) # v==2
+ library_api.publish_changes(self.library.key)
+ library_api.set_library_block_olx(self.lib_block_key, """
+
+
+
+
+ Wrong
+ Right
+
+
+
+ """) # v==3
+ lib_block_meta = library_api.get_library_block(self.lib_block_key)
+ assert lib_block_meta.published_version_num == 2
+ assert lib_block_meta.draft_version_num == 3
+
+ self.course = CourseFactory.create(display_name='Course')
+
+ taxonomy_all_org = tagging_api.create_taxonomy(
+ "test_taxonomy",
+ "Test Taxonomy",
+ export_id="ALL_ORGS",
+ )
+ tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
+ for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
+ tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
+
+ self.lib_block_tags = ['tag_1', 'tag_5']
+ tagging_api.tag_object(str(self.lib_block_key), taxonomy_all_org, self.lib_block_tags)
+
+ def test_paste_from_library_creates_link(self):
+ """
+ When we copy a v2 lib block into a course, the dest block should be linked up to the lib block.
+ """
+ copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json")
+ assert copy_response.status_code == 200
+
+ paste_response = self.client.post(XBLOCK_ENDPOINT, {
+ "parent_locator": str(self.course.usage_key),
+ "staged_content": "clipboard",
+ }, format="json")
+ assert paste_response.status_code == 200
+
+ new_block_key = UsageKey.from_string(paste_response.json()["locator"])
+ new_block = modulestore().get_item(new_block_key)
+ assert new_block.upstream == str(self.lib_block_key)
+ assert new_block.upstream_version == 3
+ assert new_block.upstream_display_name == "MCQ-draft"
+ assert new_block.upstream_max_attempts == 5
+
+ def test_paste_from_library_read_only_tags(self):
+ """
+ When we copy a v2 lib block into a course, the dest block should have read-only copied tags.
+ """
+
+ copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json")
+ assert copy_response.status_code == 200
+
+ paste_response = self.client.post(XBLOCK_ENDPOINT, {
+ "parent_locator": str(self.course.usage_key),
+ "staged_content": "clipboard",
+ }, format="json")
+ assert paste_response.status_code == 200
+
+ new_block_key = paste_response.json()["locator"]
+
+ object_tags = tagging_api.get_object_tags(new_block_key)
+ assert len(object_tags) == len(self.lib_block_tags)
+ for object_tag in object_tags:
+ assert object_tag.value in self.lib_block_tags
+ assert object_tag.is_copied
+
+ def test_paste_from_library_copies_asset(self):
+ """
+ Assets from a library component copied into a subdir of Files & Uploads.
+ """
+ # This is the binary for a real, 1px webp file – we need actual image
+ # data because contentstore will try to make a thumbnail and grab
+ # metadata.
+ webp_raw_data = b'RIFF\x16\x00\x00\x00WEBPVP8L\n\x00\x00\x00/\x00\x00\x00\x00E\xff#\xfa\x1f'
+
+ # First add the asset.
+ library_api.add_library_block_static_asset_file(
+ self.lib_block_key,
+ "static/1px.webp",
+ webp_raw_data,
+ ) # v==4
+
+ # Now add the reference to the asset
+ library_api.set_library_block_olx(self.lib_block_key, """
+
+
Including this totally real image:
+
+
+
+ Wrong
+ Right
+
+
+
+ """) # v==5
+
+ copy_response = self.client.post(
+ CLIPBOARD_ENDPOINT,
+ {"usage_key": str(self.lib_block_key)},
+ format="json"
+ )
+ assert copy_response.status_code == 200
+
+ paste_response = self.client.post(XBLOCK_ENDPOINT, {
+ "parent_locator": str(self.course.usage_key),
+ "staged_content": "clipboard",
+ }, format="json")
+ assert paste_response.status_code == 200
+
+ new_block_key = UsageKey.from_string(paste_response.json()["locator"])
+ new_block = modulestore().get_item(new_block_key)
+
+ # Check that the substitution worked.
+ expected_import_path = f"components/{new_block_key.block_type}/{new_block_key.block_id}/1px.webp"
+ assert f"/static/{expected_import_path}" in new_block.data
+
+ # Check that the asset was copied over properly
+ image_asset = contentstore().find(
+ self.course.id.make_asset_key("asset", expected_import_path.replace('/', '_'))
+ )
+ assert image_asset.import_path == expected_import_path
+ assert image_asset.name == "1px.webp"
+ assert image_asset.length == len(webp_raw_data)
+
+
+class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
+ """
+ Test Clipboard Paste functionality with legacy (v1) library content
+ """
+
+ def setUp(self):
+ """
+ Set up a v1 Content Library and a library content block
+ """
+ super().setUp()
+ self.client = APIClient()
+ self.client.login(username=self.user.username, password=self.user_password)
+ self.store = modulestore()
+ self.library = self.setup_library()
# Create a library content block (lc), point it out our library, and sync it.
self.course = CourseFactory.create(display_name='Course')
self.orig_lc_block = BlockFactory.create(
parent=self.course,
category="library_content",
- source_library_id=str(library.key),
+ source_library_id=str(self.library.context_key),
display_name="LC Block",
publish_item=False,
)
@@ -426,18 +590,15 @@ def setUp(self):
@classmethod
def setup_library(cls):
"""
- Creates and returns a content library.
+ Creates and returns a legacy content library with 1 problem
"""
- library = library_api.create_library(
- library_type=library_api.COMPLEX,
- org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
- slug="lib",
- title="Library",
- )
- # Populate it with a problem:
- problem_key = library_api.create_library_block(library.key, "problem", "p1").usage_key
- library_api.set_library_block_olx(problem_key, """
-
+ library = LibraryFactory.create(display_name='Library')
+ lib_block = BlockFactory.create(
+ parent_location=library.usage_key,
+ category="problem",
+ display_name="MCQ",
+ max_attempts=1,
+ data="""
@@ -445,9 +606,9 @@ def setup_library(cls):
Right
-
- """)
- library_api.publish_changes(library.key)
+ """,
+ publish_item=False,
+ )
return library
def test_paste_library_content_block(self):
diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py
index 1d5b5290535..426477e2340 100644
--- a/cms/djangoapps/contentstore/views/tests/test_container_page.py
+++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py
@@ -242,3 +242,61 @@ def test_container_page_with_valid_and_invalid_usage_key_string(self):
usage_key_string=str(self.vertical.location)
)
self.assertEqual(response.status_code, 200)
+
+
+class ContainerEmbedPageTestCase(ContainerPageTestCase): # lint-amnesty, pylint: disable=test-inherits-tests
+ """
+ Unit tests for the container embed page.
+ """
+
+ def test_container_html(self):
+ assets_url = reverse(
+ 'assets_handler', kwargs={'course_key_string': str(self.child_container.location.course_key)}
+ )
+ self._test_html_content(
+ self.child_container,
+ expected_section_tag=(
+ ''.format(
+ self.child_container.location, assets_url
+ )
+ ),
+ )
+
+ def test_container_on_container_html(self):
+ """
+ Create the scenario of an xblock with children (non-vertical) on the container page.
+ This should create a container page that is a child of another container page.
+ """
+ draft_container = self._create_block(self.child_container, "wrapper", "Wrapper")
+ self._create_block(draft_container, "html", "Child HTML")
+
+ def test_container_html(xblock):
+ assets_url = reverse(
+ 'assets_handler', kwargs={'course_key_string': str(draft_container.location.course_key)}
+ )
+ self._test_html_content(
+ xblock,
+ expected_section_tag=(
+ ''.format(
+ draft_container.location, assets_url
+ )
+ ),
+ )
+
+ # Test the draft version of the container
+ test_container_html(draft_container)
+
+ # Now publish the unit and validate again
+ self.store.publish(self.vertical.location, self.user.id)
+ draft_container = self.store.get_item(draft_container.location)
+ test_container_html(draft_container)
+
+ def _test_html_content(self, xblock, expected_section_tag): # lint-amnesty, pylint: disable=arguments-differ
+ """
+ Get the HTML for a container page and verify the section tag is correct
+ and the breadcrumbs trail is correct.
+ """
+ html = self.get_page_html(xblock)
+ self.assertIn(expected_section_tag, html)
diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
index a7ee7f0ab0c..0f38722e120 100644
--- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
+++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
@@ -162,6 +162,39 @@ def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler):
else:
assert 'To update these settings go to the Advanced Settings page.' in alert_text
+ @override_settings(
+ PROCTORING_BACKENDS={
+ 'DEFAULT': 'test_proctoring_provider',
+ 'proctortrack': {},
+ 'test_proctoring_provider': {},
+ },
+ FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED,
+ )
+ @ddt.data(
+ "advanced_settings_handler",
+ "course_handler",
+ )
+ def test_invalid_provider_alert(self, page_handler):
+ """
+ An alert should appear if the course has a proctoring provider that is not valid.
+ """
+ # create an error by setting an invalid proctoring provider
+ self.course.proctoring_provider = 'invalid_provider'
+ self.course.enable_proctored_exams = True
+ self.save_course()
+
+ url = reverse_course_url(page_handler, self.course.id)
+ resp = self.client.get(url, HTTP_ACCEPT='text/html')
+ alert_text = self._get_exam_settings_alert_text(resp.content)
+ assert (
+ 'This course has proctored exam settings that are incomplete or invalid.'
+ in alert_text
+ )
+ assert (
+ 'The proctoring provider configured for this course, \'invalid_provider\', is not valid.'
+ in alert_text
+ )
+
@ddt.data(
"advanced_settings_handler",
"course_handler",
diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py
index fa650541972..8278cd0535b 100644
--- a/cms/djangoapps/contentstore/views/tests/test_library.py
+++ b/cms/djangoapps/contentstore/views/tests/test_library.py
@@ -56,44 +56,44 @@ def setUp(self):
# Tests for /library/ - list and create libraries:
# When libraries are disabled, nobody can create libraries
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", False)
def test_library_creator_status_libraries_not_enabled(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
self.assertEqual(user_can_create_library(nostaff_user, None), False)
# When creator group is disabled, non-staff users can create libraries
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_no_course_creator_role(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True)
# When creator group is enabled, Non staff users cannot create libraries
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertEqual(user_can_create_library(nostaff_user, None), False)
# Global staff can create libraries for any org, even ones that don't exist.
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_is_staff_user(self):
print(self.user.is_staff)
self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True)
# Global staff can create libraries for any org, but an org has to be supplied.
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_is_staff_user_no_org(self):
print(self.user.is_staff)
self.assertEqual(user_can_create_library(self.user, None), False)
# When creator groups are enabled, global staff can create libraries in any org
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self):
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True)
# When creator groups are enabled, course creators can create libraries in any org.
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -102,7 +102,7 @@ def test_library_creator_status_with_course_creator_role_for_enabled_creator_gro
# When creator groups are enabled, course staff members can create libraries
# but only in the org they are course staff for.
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -112,7 +112,7 @@ def test_library_creator_status_with_course_staff_role_for_enabled_creator_group
# When creator groups are enabled, course instructor members can create libraries
# but only in the org they are course staff for.
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
@@ -134,7 +134,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library,
Ensure that the setting DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION as expected.
"""
_, nostaff_user = self.create_non_staff_authed_user_client()
- with mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True):
+ with mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True):
with mock.patch.dict(
"django.conf.settings.FEATURES",
{
@@ -145,7 +145,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library,
self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status)
@mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True})
- @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaff_course_creation(self):
"""
Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well.
@@ -161,7 +161,7 @@ def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaf
self.assertEqual(get_response.status_code, 200)
self.assertEqual(post_response.status_code, 403)
- @patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
+ @mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CONTENT_LIBRARIES': False})
def test_with_libraries_disabled(self):
"""
The library URLs should return 404 if libraries are disabled.
diff --git a/cms/djangoapps/contentstore/views/transcripts_ajax.py b/cms/djangoapps/contentstore/views/transcripts_ajax.py
index 892b76caae7..8cb7f455013 100644
--- a/cms/djangoapps/contentstore/views/transcripts_ajax.py
+++ b/cms/djangoapps/contentstore/views/transcripts_ajax.py
@@ -649,6 +649,9 @@ def _get_item(request, data):
Returns the item.
"""
usage_key = UsageKey.from_string(data.get('locator'))
+ if not usage_key.context_key.is_course:
+ # TODO: implement transcript support for learning core / content libraries.
+ raise TranscriptsRequestValidationException(_('Transcripts are not yet supported in content libraries.'))
# This is placed before has_course_author_access() to validate the location,
# because has_course_author_access() raises r if location is invalid.
item = modulestore().get_item(usage_key)
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
index e7dbec01f8e..df8d8dc6251 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
@@ -36,6 +36,7 @@
from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig
+from cms.lib.xblock.upstream_sync import BadUpstream, sync_from_upstream
from common.djangoapps.static_replace import replace_static_urls
from common.djangoapps.student.auth import (
has_studio_read_access,
@@ -539,7 +540,8 @@ def _create_block(request):
# Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key':
try:
created_xblock, notices = import_staged_content_from_user_clipboard(
- parent_key=usage_key, request=request
+ parent_key=usage_key,
+ request=request,
)
except Exception: # pylint: disable=broad-except
log.exception(
@@ -585,12 +587,28 @@ def _create_block(request):
boilerplate=request.json.get("boilerplate"),
)
- return JsonResponse(
- {
- "locator": str(created_block.location),
- "courseKey": str(created_block.location.course_key),
- }
- )
+ response = {
+ "locator": str(created_block.location),
+ "courseKey": str(created_block.location.course_key),
+ }
+ # If it contains library_content_key, the block is being imported from a v2 library
+ # so it needs to be synced with upstream block.
+ if upstream_ref := request.json.get("library_content_key"):
+ try:
+ # Set `created_block.upstream` and then sync this with the upstream (library) version.
+ created_block.upstream = upstream_ref
+ sync_from_upstream(downstream=created_block, user=request.user)
+ except BadUpstream as exc:
+ _delete_item(created_block.location, request.user)
+ log.exception(
+ f"Could not sync to new block at '{created_block.usage_key}' "
+ f"using provided library_content_key='{upstream_ref}'"
+ )
+ return JsonResponse({"error": str(exc)}, status=400)
+ modulestore().update_item(created_block, request.user.id)
+ response['upstreamRef'] = upstream_ref
+
+ return JsonResponse(response)
def _get_source_index(source_usage_key, source_parent):
@@ -1159,12 +1177,19 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements
supports_onboarding = False
proctoring_exam_configuration_link = None
- if xblock.is_proctored_exam:
- proctoring_exam_configuration_link = (
- get_exam_configuration_dashboard_url(
- course.id, xblock_info["id"]
+
+ # only call get_exam_configuration_dashboard_url if not using an LTI proctoring provider
+ if xblock.is_proctored_exam and (course.proctoring_provider != 'lti_external'):
+ try:
+ proctoring_exam_configuration_link = (
+ get_exam_configuration_dashboard_url(
+ course.id, xblock_info["id"]
+ )
+ )
+ except Exception as e: # pylint: disable=broad-except
+ log.error(
+ f"Error while getting proctoring exam configuration link: {e}"
)
- )
if course.proctoring_provider == "proctortrack":
show_review_rules = SHOW_REVIEW_RULES_FLAG.is_enabled(
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index fd5219dfb47..5d4ac5a4a33 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -217,7 +217,10 @@ def update_from_json(cls, block, jsondict, user, filter_tabs=True):
try:
val = model['value']
if hasattr(block, key) and getattr(block, key) != val:
- key_values[key] = block.fields[key].from_json(val)
+ if key == 'proctoring_provider':
+ key_values[key] = block.fields[key].from_json(val, validate_providers=True)
+ else:
+ key_values[key] = block.fields[key].from_json(val)
except (TypeError, ValueError) as err:
raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from
name=model['display_name'], detailed_message=str(err)))
@@ -253,7 +256,10 @@ def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True):
try:
val = model['value']
if hasattr(block, key) and getattr(block, key) != val:
- key_values[key] = block.fields[key].from_json(val)
+ if key == 'proctoring_provider':
+ key_values[key] = block.fields[key].from_json(val, validate_providers=True)
+ else:
+ key_values[key] = block.fields[key].from_json(val)
except (TypeError, ValueError, ValidationError) as err:
did_validate = False
errors.append({'key': key, 'message': str(err), 'model': model})
@@ -484,6 +490,24 @@ def validate_proctoring_settings(cls, block, settings_dict, user):
enable_proctoring = block.enable_proctored_exams
if enable_proctoring:
+
+ if proctoring_provider_model:
+ proctoring_provider = proctoring_provider_model.get('value')
+ else:
+ proctoring_provider = block.proctoring_provider
+
+ # If the proctoring provider stored in the course block no longer
+ # matches the available providers for this instance, show an error
+ if proctoring_provider not in available_providers:
+ message = (
+ f'The proctoring provider configured for this course, \'{proctoring_provider}\', is not valid.'
+ )
+ errors.append({
+ 'key': 'proctoring_provider',
+ 'message': message,
+ 'model': proctoring_provider_model
+ })
+
# Require a valid escalation email if Proctortrack is chosen as the proctoring provider
escalation_email_model = settings_dict.get('proctoring_escalation_email')
if escalation_email_model:
@@ -491,11 +515,6 @@ def validate_proctoring_settings(cls, block, settings_dict, user):
else:
escalation_email = block.proctoring_escalation_email
- if proctoring_provider_model:
- proctoring_provider = proctoring_provider_model.get('value')
- else:
- proctoring_provider = block.proctoring_provider
-
missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.'
if proctoring_provider_model and proctoring_provider == 'proctortrack':
if not escalation_email:
diff --git a/cms/envs/common.py b/cms/envs/common.py
index be837c51898..ea374bca8bb 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -127,6 +127,7 @@
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
from cms.lib.xblock.authoring_mixin import AuthoringMixin
+from cms.lib.xblock.upstream_sync import UpstreamSyncMixin
from xmodule.modulestore.edit_info import EditInfoMixin
from openedx.core.djangoapps.theming.helpers_dirs import (
get_themes_unchecked,
@@ -434,18 +435,6 @@
# .. toggle_tickets: https://openedx.atlassian.net/browse/DEPR-58
'DEPRECATE_OLD_COURSE_KEYS_IN_STUDIO': True,
- # .. toggle_name: FEATURES['ENABLE_LIBRARY_AUTHORING_MICROFRONTEND']
- # .. toggle_implementation: DjangoSetting
- # .. toggle_default: False
- # .. toggle_description: Set to True to enable the Library Authoring MFE
- # .. toggle_use_cases: temporary
- # .. toggle_creation_date: 2020-06-20
- # .. toggle_target_removal_date: 2020-12-31
- # .. toggle_tickets: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4106944527/Libraries+Relaunch+Proposal+For+Product+Review
- # .. toggle_warning: Also set settings.LIBRARY_AUTHORING_MICROFRONTEND_URL and see
- # REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND for rollout.
- 'ENABLE_LIBRARY_AUTHORING_MICROFRONTEND': False,
-
# .. toggle_name: FEATURES['DISABLE_COURSE_CREATION']
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
@@ -468,17 +457,6 @@
# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/26106
'ENABLE_HELP_LINK': True,
- # .. toggle_name: FEATURES['ENABLE_V2_CERT_DISPLAY_SETTINGS']
- # .. toggle_implementation: DjangoSetting
- # .. toggle_default: False
- # .. toggle_description: Whether to use the reimagined certificates_display_behavior and certificate_available_date
- # .. settings. Will eventually become the default.
- # .. toggle_use_cases: temporary
- # .. toggle_creation_date: 2021-07-26
- # .. toggle_target_removal_date: 2021-10-01
- # .. toggle_tickets: 'https://openedx.atlassian.net/browse/MICROBA-1405'
- 'ENABLE_V2_CERT_DISPLAY_SETTINGS': False,
-
# .. toggle_name: FEATURES['ENABLE_INTEGRITY_SIGNATURE']
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
@@ -612,7 +590,6 @@
COURSE_AUTHORING_MICROFRONTEND_URL = None
DISCUSSIONS_MICROFRONTEND_URL = None
DISCUSSIONS_MFE_FEEDBACK_URL = None
-LIBRARY_AUTHORING_MICROFRONTEND_URL = None
# .. toggle_name: ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
@@ -687,6 +664,7 @@
LMS_ROOT = REPO_ROOT / "lms"
ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /edx-platform is in
COURSES_ROOT = ENV_ROOT / "data"
+XMODULE_ROOT = REPO_ROOT / "xmodule"
GITHUB_REPO_ROOT = ENV_ROOT / "data"
@@ -949,7 +927,6 @@
'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'common.djangoapps.student.middleware.UserStandingMiddleware',
- 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'common.djangoapps.track.middleware.TrackMiddleware',
@@ -1020,6 +997,7 @@
XModuleMixin,
EditInfoMixin,
AuthoringMixin,
+ UpstreamSyncMixin,
)
# .. setting_name: XBLOCK_EXTRA_MIXINS
@@ -1293,6 +1271,10 @@
STATICFILES_DIRS = [
COMMON_ROOT / "static",
PROJECT_ROOT / "static",
+ # Temporarily adding the following static path as we are migrating the built-in blocks' Sass to vanilla CSS.
+ # Once all of the built-in blocks are extracted from edx-platform, we can remove this static path.
+ # Relevant ticket: https://github.com/openedx/edx-platform/issues/35300
+ XMODULE_ROOT / "static",
]
# Locale/Internationalization
@@ -1430,6 +1412,12 @@
],
'output_filename': 'css/cms-style-xmodule-annotations.css',
},
+ 'course-unit-mfe-iframe-bundle': {
+ 'source_filenames': [
+ 'css/course-unit-mfe-iframe-bundle.css',
+ ],
+ 'output_filename': 'css/course-unit-mfe-iframe-bundle.css',
+ },
}
base_vendor_js = [
@@ -1449,9 +1437,8 @@
'edx-ui-toolkit/js/utils/string-utils.js',
'edx-ui-toolkit/js/utils/html-utils.js',
- # Load Bootstrap and supporting libraries
- 'common/js/vendor/popper.js',
- 'common/js/vendor/bootstrap.js',
+ # Here we were loading Bootstrap and supporting libraries, but it no longer seems to be needed for any Studio UI.
+ # 'common/js/vendor/bootstrap.bundle.js',
# Finally load RequireJS
'common/js/vendor/require.js'
@@ -1666,6 +1653,9 @@
'corsheaders',
'openedx.core.djangoapps.cors_csrf',
+ # Provides the 'django_markup' template library so we can use 'interpolate_html' in django templates
+ 'xss_utils',
+
# History tables
'simple_history',
@@ -1880,6 +1870,7 @@
'openedx_events',
# Learning Core Apps, used by v2 content libraries (content_libraries app)
+ "openedx_learning.apps.authoring.collections",
"openedx_learning.apps.authoring.components",
"openedx_learning.apps.authoring.contents",
"openedx_learning.apps.authoring.publishing",
@@ -2791,6 +2782,7 @@
CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages"
COURSE_LIVE_HELP_URL = "https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/course_assets/course_live.html"
ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#configuring-course-level-open-response-assessment-settings"
+# pylint: enable=line-too-long
# keys for big blue button live provider
COURSE_LIVE_GLOBAL_CREDENTIALS = {}
@@ -2814,8 +2806,15 @@
BRAZE_COURSE_ENROLLMENT_CANVAS_ID = ''
+######################## Discussion Forum settings ########################
+
+# Feedback link in upgraded discussion notification alert
DISCUSSIONS_INCONTEXT_FEEDBACK_URL = ''
-DISCUSSIONS_INCONTEXT_LEARNMORE_URL = ''
+
+# Learn More link in upgraded discussion notification alert
+# pylint: disable=line-too-long
+DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html"
+# pylint: enable=line-too-long
#### django-simple-history##
# disable indexing on date field its coming django-simple-history.
@@ -2937,3 +2936,10 @@ def _should_send_learning_badge_events(settings):
# See https://www.meilisearch.com/docs/learn/security/tenant_tokens
MEILISEARCH_INDEX_PREFIX = ""
MEILISEARCH_API_KEY = "devkey"
+
+# .. setting_name: DISABLED_COUNTRIES
+# .. setting_default: []
+# .. setting_description: List of country codes that should be disabled
+# .. for now it wil impact country listing in auth flow and user profile.
+# .. eg ['US', 'CA']
+DISABLED_COUNTRIES = []
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index e944d67eda1..1200a61b061 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -174,9 +174,6 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing
################### FRONTEND APPLICATION PUBLISHER URL ###################
FEATURES['FRONTEND_APP_PUBLISHER_URL'] = 'http://localhost:18400'
-################### FRONTEND APPLICATION LIBRARY AUTHORING ###################
-LIBRARY_AUTHORING_MICROFRONTEND_URL = 'http://localhost:3001'
-
################### FRONTEND APPLICATION COURSE AUTHORING ###################
COURSE_AUTHORING_MICROFRONTEND_URL = 'http://localhost:2001'
@@ -267,7 +264,8 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing
################ Using LMS SSO for login to Studio ################
SOCIAL_AUTH_EDX_OAUTH2_KEY = 'studio-sso-key'
SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'studio-sso-secret' # in stage, prod would be high-entropy secret
-SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://edx.devstack.lms:18000' # routed internally server-to-server
+# routed internally server-to-server
+SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = ENV_TOKENS.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000')
SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in browser redirect
# Don't form the return redirect URL with HTTPS on devstack
diff --git a/cms/envs/production.py b/cms/envs/production.py
index 50519b55229..ad7667772f9 100644
--- a/cms/envs/production.py
+++ b/cms/envs/production.py
@@ -689,3 +689,10 @@ def get_env_setting(setting):
}
BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID)
+
+# .. setting_name: DISABLED_COUNTRIES
+# .. setting_default: []
+# .. setting_description: List of country codes that should be disabled
+# .. for now it wil impact country listing in auth flow and user profile.
+# .. eg ['US', 'CA']
+DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', [])
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 38b7c781714..49db5060885 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -333,3 +333,13 @@
"SECRET": "***",
"URL": "***",
}
+
+############## openedx-learning (Learning Core) config ##############
+OPENEDX_LEARNING = {
+ 'MEDIA': {
+ 'BACKEND': 'django.core.files.storage.InMemoryStorage',
+ 'OPTIONS': {
+ 'location': MEDIA_ROOT + "_private"
+ }
+ }
+}
diff --git a/cms/lib/xblock/test/test_upstream_sync.py b/cms/lib/xblock/test/test_upstream_sync.py
new file mode 100644
index 00000000000..cc3d661ca6e
--- /dev/null
+++ b/cms/lib/xblock/test/test_upstream_sync.py
@@ -0,0 +1,425 @@
+"""
+Test CMS's upstream->downstream syncing system
+"""
+import ddt
+
+from organizations.api import ensure_organization
+from organizations.models import Organization
+
+from cms.lib.xblock.upstream_sync import (
+ UpstreamLink,
+ sync_from_upstream, decline_sync, fetch_customizable_fields, sever_upstream_link,
+ NoUpstream, BadUpstream, BadDownstream,
+)
+from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangoapps.content_libraries import api as libs
+from openedx.core.djangoapps.content_tagging import api as tagging_api
+from openedx.core.djangoapps.xblock import api as xblock
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
+
+
+@ddt.ddt
+class UpstreamTestCase(ModuleStoreTestCase):
+ """
+ Tests the upstream_sync mixin, data object, and Python APIs.
+ """
+
+ def setUp(self):
+ """
+ Create a simple course with one unit, and simple V2 library with two blocks.
+ """
+ super().setUp()
+ course = CourseFactory.create()
+ chapter = BlockFactory.create(category='chapter', parent=course)
+ sequential = BlockFactory.create(category='sequential', parent=chapter)
+ self.unit = BlockFactory.create(category='vertical', parent=sequential)
+
+ ensure_organization("TestX")
+ self.library = libs.create_library(
+ org=Organization.objects.get(short_name="TestX"),
+ slug="TestLib",
+ title="Test Upstream Library",
+ )
+ self.upstream_key = libs.create_library_block(self.library.key, "html", "test-upstream").usage_key
+ libs.create_library_block(self.library.key, "video", "video-upstream")
+
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ upstream.display_name = "Upstream Title V2"
+ upstream.data = "Upstream content V2"
+ upstream.save()
+
+ libs.publish_changes(self.library.key, self.user.id)
+
+ self.taxonomy_all_org = tagging_api.create_taxonomy(
+ "test_taxonomy",
+ "Test Taxonomy",
+ export_id="ALL_ORGS",
+ )
+ tagging_api.set_taxonomy_orgs(self.taxonomy_all_org, all_orgs=True)
+ for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
+ tagging_api.add_tag_to_taxonomy(self.taxonomy_all_org, tag_value)
+
+ self.upstream_tags = ['tag_1', 'tag_5']
+ tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, self.upstream_tags)
+
+ def test_sync_bad_downstream(self):
+ """
+ Syncing into an unsupported downstream (such as a another Content Library block) raises BadDownstream, but
+ doesn't affect the block.
+ """
+ downstream_lib_block_key = libs.create_library_block(self.library.key, "html", "bad-downstream").usage_key
+ downstream_lib_block = xblock.load_block(downstream_lib_block_key, self.user)
+ downstream_lib_block.display_name = "Another lib block"
+ downstream_lib_block.data = "another lib block"
+ downstream_lib_block.upstream = str(self.upstream_key)
+ downstream_lib_block.save()
+
+ with self.assertRaises(BadDownstream):
+ sync_from_upstream(downstream_lib_block, self.user)
+
+ assert downstream_lib_block.display_name == "Another lib block"
+ assert downstream_lib_block.data == "another lib block"
+
+ def test_sync_no_upstream(self):
+ """
+ Trivial case: Syncing a block with no upstream is a no-op
+ """
+ block = BlockFactory.create(category='html', parent=self.unit)
+ block.display_name = "Block Title"
+ block.data = "Block content"
+
+ with self.assertRaises(NoUpstream):
+ sync_from_upstream(block, self.user)
+
+ assert block.display_name == "Block Title"
+ assert block.data == "Block content"
+ assert not block.upstream_display_name
+
+ @ddt.data(
+ ("not-a-key-at-all", ".*is malformed.*"),
+ ("course-v1:Oops+ItsA+CourseKey", ".*is malformed.*"),
+ ("block-v1:The+Wrong+KindOfUsageKey+type@html+block@nope", ".*is malformed.*"),
+ ("lb:TestX:NoSuchLib:html:block-id", ".*not found in the system.*"),
+ ("lb:TestX:TestLib:video:should-be-html-but-is-a-video", ".*type mismatch.*"),
+ ("lb:TestX:TestLib:html:no-such-html", ".*not found in the system.*"),
+ )
+ @ddt.unpack
+ def test_sync_bad_upstream(self, upstream, message_regex):
+ """
+ Syncing with a bad upstream raises BadUpstream, but doesn't affect the block
+ """
+ block = BlockFactory.create(category='html', parent=self.unit, upstream=upstream)
+ block.display_name = "Block Title"
+ block.data = "Block content"
+
+ with self.assertRaisesRegex(BadUpstream, message_regex):
+ sync_from_upstream(block, self.user)
+
+ assert block.display_name == "Block Title"
+ assert block.data == "Block content"
+ assert not block.upstream_display_name
+
+ def test_sync_not_accessible(self):
+ """
+ Syncing with an block that exists, but is inaccessible, raises BadUpstream
+ """
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+ user_who_cannot_read_upstream = UserFactory.create(username="rando", is_staff=False, is_superuser=False)
+ with self.assertRaisesRegex(BadUpstream, ".*could not be loaded.*") as exc:
+ sync_from_upstream(downstream, user_who_cannot_read_upstream)
+
+ def test_sync_updates_happy_path(self):
+ """
+ Can we sync updates from a content library block to a linked out-of-date course block?
+ """
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+
+ # Initial sync
+ sync_from_upstream(downstream, self.user)
+ assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
+ assert downstream.upstream_display_name == "Upstream Title V2"
+ assert downstream.display_name == "Upstream Title V2"
+ assert downstream.data == "Upstream content V2"
+
+ # Verify tags
+ object_tags = tagging_api.get_object_tags(str(downstream.location))
+ assert len(object_tags) == len(self.upstream_tags)
+ for object_tag in object_tags:
+ assert object_tag.value in self.upstream_tags
+
+ # Upstream updates
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ upstream.display_name = "Upstream Title V3"
+ upstream.data = "Upstream content V3"
+ upstream.save()
+ new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
+ tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, new_upstream_tags)
+
+ # Assert that un-published updates are not yet pulled into downstream
+ sync_from_upstream(downstream, self.user)
+ assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
+ assert downstream.upstream_display_name == "Upstream Title V2"
+ assert downstream.display_name == "Upstream Title V2"
+ assert downstream.data == "Upstream content V2"
+
+ # Publish changes
+ libs.publish_changes(self.library.key, self.user.id)
+
+ # Follow-up sync. Assert that updates are pulled into downstream.
+ sync_from_upstream(downstream, self.user)
+ assert downstream.upstream_version == 3
+ assert downstream.upstream_display_name == "Upstream Title V3"
+ assert downstream.display_name == "Upstream Title V3"
+ assert downstream.data == "Upstream content V3"
+
+ # Verify tags
+ object_tags = tagging_api.get_object_tags(str(downstream.location))
+ assert len(object_tags) == len(new_upstream_tags)
+ for object_tag in object_tags:
+ assert object_tag.value in new_upstream_tags
+
+ def test_sync_updates_to_modified_content(self):
+ """
+ If we sync to modified content, will it preserve customizable fields, but overwrite the rest?
+ """
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+
+ # Initial sync
+ sync_from_upstream(downstream, self.user)
+ assert downstream.upstream_display_name == "Upstream Title V2"
+ assert downstream.display_name == "Upstream Title V2"
+ assert downstream.data == "Upstream content V2"
+
+ # Upstream updates
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ upstream.display_name = "Upstream Title V3"
+ upstream.data = "Upstream content V3"
+ upstream.save()
+ libs.publish_changes(self.library.key, self.user.id)
+
+ # Downstream modifications
+ downstream.display_name = "Downstream Title Override" # "safe" customization
+ downstream.data = "Downstream content override" # "unsafe" override
+ downstream.save()
+
+ # Follow-up sync. Assert that updates are pulled into downstream, but customizations are saved.
+ sync_from_upstream(downstream, self.user)
+ assert downstream.upstream_display_name == "Upstream Title V3"
+ assert downstream.display_name == "Downstream Title Override" # "safe" customization survives
+ assert downstream.data == "Upstream content V3" # "unsafe" override is gone
+
+ # For the Content Libraries Relaunch Beta, we do not yet need to support this edge case.
+ # See "PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM DEFAULTS" in cms/lib/xblock/upstream_sync.py.
+ #
+ # def test_sync_to_downstream_with_subtle_customization(self):
+ # """
+ # Edge case: If our downstream customizes a field, but then the upstream is changed to match the
+ # customization do we still remember that the downstream field is customized? That is,
+ # if the upstream later changes again, do we retain the downstream customization (rather than
+ # following the upstream update?)
+ # """
+ # # Start with an uncustomized downstream block.
+ # downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+ # sync_from_upstream(downstream, self.user)
+ # assert downstream.downstream_customized == []
+ # assert downstream.display_name == downstream.upstream_display_name == "Upstream Title V2"
+ #
+ # # Then, customize our downstream title.
+ # downstream.display_name = "Title V3"
+ # downstream.save()
+ # assert downstream.downstream_customized == ["display_name"]
+ #
+ # # Syncing should retain the customization.
+ # sync_from_upstream(downstream, self.user)
+ # assert downstream.upstream_version == 2
+ # assert downstream.upstream_display_name == "Upstream Title V2"
+ # assert downstream.display_name == "Title V3"
+ #
+ # # Whoa, look at that, the upstream has updated itself to the exact same title...
+ # upstream = xblock.load_block(self.upstream_key, self.user)
+ # upstream.display_name = "Title V3"
+ # upstream.save()
+ #
+ # # ...which is reflected when we sync.
+ # sync_from_upstream(downstream, self.user)
+ # assert downstream.upstream_version == 3
+ # assert downstream.upstream_display_name == downstream.display_name == "Title V3"
+ #
+ # # But! Our downstream knows that its title is still customized.
+ # assert downstream.downstream_customized == ["display_name"]
+ # # So, if the upstream title changes again...
+ # upstream.display_name = "Title V4"
+ # upstream.save()
+ #
+ # # ...then the downstream title should remain put.
+ # sync_from_upstream(downstream, self.user)
+ # assert downstream.upstream_version == 4
+ # assert downstream.upstream_display_name == "Title V4"
+ # assert downstream.display_name == "Title V3"
+ #
+ # # Finally, if we "de-customize" the display_name field, then it should go back to syncing normally.
+ # downstream.downstream_customized = []
+ # upstream.display_name = "Title V5"
+ # upstream.save()
+ # sync_from_upstream(downstream, self.user)
+ # assert downstream.upstream_version == 5
+ # assert downstream.upstream_display_name == downstream.display_name == "Title V5"
+
+ @ddt.data(None, "Title From Some Other Upstream Version")
+ def test_fetch_customizable_fields(self, initial_upstream_display_name):
+ """
+ Can we fetch a block's upstream field values without syncing it?
+
+ Test both with and without a pre-"fetched" upstrema values on the downstream.
+ """
+ downstream = BlockFactory.create(category='html', parent=self.unit)
+ downstream.upstream_display_name = initial_upstream_display_name
+ downstream.display_name = "Some Title"
+ downstream.data = "Some content"
+
+ # Note that we're not linked to any upstream. fetch_customizable_fields shouldn't care.
+ assert not downstream.upstream
+ assert not downstream.upstream_version
+
+ # fetch!
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ fetch_customizable_fields(upstream=upstream, downstream=downstream, user=self.user)
+
+ # Ensure: fetching doesn't affect the upstream link (or lack thereof).
+ assert not downstream.upstream
+ assert not downstream.upstream_version
+
+ # Ensure: fetching doesn't affect actual content or settings.
+ assert downstream.display_name == "Some Title"
+ assert downstream.data == "Some content"
+
+ # Ensure: fetching DOES set the upstream_* fields.
+ assert downstream.upstream_display_name == "Upstream Title V2"
+
+ def test_prompt_and_decline_sync(self):
+ """
+ Is the user prompted for sync when it's available? Does declining remove the prompt until a new sync is ready?
+ """
+ # Initial conditions (pre-sync)
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced is None
+ assert link.version_declined is None
+ assert link.version_available == 2 # Library block with content starts at version 2
+ assert link.ready_to_sync is True
+
+ # Initial sync to V2
+ sync_from_upstream(downstream, self.user)
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced == 2
+ assert link.version_declined is None
+ assert link.version_available == 2
+ assert link.ready_to_sync is False
+
+ # Upstream updated to V3, but not yet published
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ upstream.data = "Upstream content V3"
+ upstream.save()
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced == 2
+ assert link.version_declined is None
+ assert link.version_available == 2
+ assert link.ready_to_sync is False
+
+ # Publish changes
+ libs.publish_changes(self.library.key, self.user.id)
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced == 2
+ assert link.version_declined is None
+ assert link.version_available == 3
+ assert link.ready_to_sync is True
+
+ # Decline to sync to V3 -- ready_to_sync becomes False.
+ decline_sync(downstream)
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced == 2
+ assert link.version_declined == 3
+ assert link.version_available == 3
+ assert link.ready_to_sync is False
+
+ # Upstream updated to V4 -- ready_to_sync becomes True again.
+ upstream = xblock.load_block(self.upstream_key, self.user)
+ upstream.data = "Upstream content V4"
+ upstream.save()
+ libs.publish_changes(self.library.key, self.user.id)
+ link = UpstreamLink.get_for_block(downstream)
+ assert link.version_synced == 2
+ assert link.version_declined == 3
+ assert link.version_available == 4
+ assert link.ready_to_sync is True
+
+ def test_sever_upstream_link(self):
+ """
+ Does sever_upstream_link correctly disconnect a block from its upstream?
+ """
+ # Start with a course block that is linked+synced to a content library block.
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
+ sync_from_upstream(downstream, self.user)
+
+ # (sanity checks)
+ assert downstream.upstream == str(self.upstream_key)
+ assert downstream.upstream_version == 2
+ assert downstream.upstream_display_name == "Upstream Title V2"
+ assert downstream.display_name == "Upstream Title V2"
+ assert downstream.data == "Upstream content V2"
+ assert downstream.copied_from_block is None
+
+ # Now, disconnect the course block.
+ sever_upstream_link(downstream)
+
+ # All upstream metadata has been wiped out.
+ assert downstream.upstream is None
+ assert downstream.upstream_version is None
+ assert downstream.upstream_display_name is None
+
+ # BUT, the content which was synced into the upstream remains.
+ assert downstream.display_name == "Upstream Title V2"
+ assert downstream.data == "Upstream content V2"
+
+ # AND, we have recorded the old upstream as our copied_from_block.
+ assert downstream.copied_from_block == str(self.upstream_key)
+
+ def test_sync_library_block_tags(self):
+ upstream_lib_block_key = libs.create_library_block(self.library.key, "html", "upstream").usage_key
+ upstream_lib_block = xblock.load_block(upstream_lib_block_key, self.user)
+ upstream_lib_block.display_name = "Another lib block"
+ upstream_lib_block.data = "another lib block"
+ upstream_lib_block.save()
+
+ libs.publish_changes(self.library.key, self.user.id)
+
+ expected_tags = self.upstream_tags
+ tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, expected_tags)
+
+ downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(upstream_lib_block_key))
+
+ # Initial sync
+ sync_from_upstream(downstream, self.user)
+
+ # Verify tags
+ object_tags = tagging_api.get_object_tags(str(downstream.location))
+ assert len(object_tags) == len(expected_tags)
+ for object_tag in object_tags:
+ assert object_tag.value in expected_tags
+
+ # Upstream updates
+ upstream_lib_block.display_name = "Upstream Title V3"
+ upstream_lib_block.data = "Upstream content V3"
+ upstream_lib_block.save()
+ new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
+ tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, new_upstream_tags)
+
+ # Follow-up sync.
+ sync_from_upstream(downstream, self.user)
+
+ #Verify tags
+ object_tags = tagging_api.get_object_tags(str(downstream.location))
+ assert len(object_tags) == len(new_upstream_tags)
+ for object_tag in object_tags:
+ assert object_tag.value in new_upstream_tags
diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py
new file mode 100644
index 00000000000..0d95931ce29
--- /dev/null
+++ b/cms/lib/xblock/upstream_sync.py
@@ -0,0 +1,492 @@
+"""
+Synchronize content and settings from upstream blocks to their downstream usages.
+
+At the time of writing, we assume that for any upstream-downstream linkage:
+* The upstream is a Component from a Learning Core-backed Content Library.
+* The downstream is a block of matching type in a SplitModuleStore-backed Course.
+* They are both on the same Open edX instance.
+
+HOWEVER, those assumptions may loosen in the future. So, we consider these to be INTERNAL ASSUMPIONS that should not be
+exposed through this module's public Python interface.
+"""
+from __future__ import annotations
+
+import logging
+import typing as t
+from dataclasses import dataclass, asdict
+
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import gettext_lazy as _
+from rest_framework.exceptions import NotFound
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.locator import LibraryUsageLocatorV2
+from xblock.exceptions import XBlockNotFoundError
+from xblock.fields import Scope, String, Integer
+from xblock.core import XBlockMixin, XBlock
+
+if t.TYPE_CHECKING:
+ from django.contrib.auth.models import User # pylint: disable=imported-auth-user
+
+
+logger = logging.getLogger(__name__)
+
+
+class UpstreamLinkException(Exception):
+ """
+ Raised whenever we try to inspect, sync-from, fetch-from, or delete a block's link to upstream content.
+
+ There are three flavors (defined below): BadDownstream, BadUpstream, NoUpstream.
+
+ Should be constructed with a human-friendly, localized, PII-free message, suitable for API responses and UI display.
+ For now, at least, the message can assume that upstreams are Content Library blocks and downstreams are Course
+ blocks, although that may need to change (see module docstring).
+ """
+
+
+class BadDownstream(UpstreamLinkException):
+ """
+ Downstream content does not support sync.
+ """
+
+
+class BadUpstream(UpstreamLinkException):
+ """
+ Reference to upstream content is malformed, invalid, and/or inaccessible.
+ """
+
+
+class NoUpstream(UpstreamLinkException):
+ """
+ The downstream content does not have an upstream link at all (...as is the case for most XBlocks usages).
+
+ (This isn't so much an "error" like the other two-- it's just a case that needs to be handled exceptionally,
+ usually by logging a message and then doing nothing.)
+ """
+ def __init__(self):
+ super().__init__(_("Content is not linked to a Content Library."))
+
+
+@dataclass(frozen=True)
+class UpstreamLink:
+ """
+ Metadata about some downstream content's relationship with its linked upstream content.
+ """
+ upstream_ref: str | None # Reference to the upstream content, e.g., a serialized library block usage key.
+ version_synced: int | None # Version of the upstream to which the downstream was last synced.
+ version_available: int | None # Latest version of the upstream that's available, or None if it couldn't be loaded.
+ version_declined: int | None # Latest version which the user has declined to sync with, if any.
+ error_message: str | None # If link is valid, None. Otherwise, a localized, human-friendly error message.
+
+ @property
+ def ready_to_sync(self) -> bool:
+ """
+ Should we invite the downstream's authors to sync the latest upstream updates?
+ """
+ return bool(
+ self.upstream_ref and
+ self.version_available and
+ self.version_available > (self.version_synced or 0) and
+ self.version_available > (self.version_declined or 0)
+ )
+
+ @property
+ def upstream_link(self) -> str | None:
+ """
+ Link to edit/view upstream block in library.
+ """
+ if self.version_available is None or self.upstream_ref is None:
+ return None
+ try:
+ usage_key = LibraryUsageLocatorV2.from_string(self.upstream_ref)
+ except InvalidKeyError:
+ return None
+ return _get_library_xblock_url(usage_key)
+
+ def to_json(self) -> dict[str, t.Any]:
+ """
+ Get an JSON-API-friendly representation of this upstream link.
+ """
+ return {
+ **asdict(self),
+ "ready_to_sync": self.ready_to_sync,
+ "upstream_link": self.upstream_link,
+ }
+
+ @classmethod
+ def try_get_for_block(cls, downstream: XBlock) -> t.Self:
+ """
+ Same as `get_for_block`, but upon failure, sets `.error_message` instead of raising an exception.
+ """
+ try:
+ return cls.get_for_block(downstream)
+ except UpstreamLinkException as exc:
+ logger.exception(
+ "Tried to inspect an unsupported, broken, or missing downstream->upstream link: '%s'->'%s'",
+ downstream.usage_key,
+ downstream.upstream,
+ )
+ return cls(
+ upstream_ref=downstream.upstream,
+ version_synced=downstream.upstream_version,
+ version_available=None,
+ version_declined=None,
+ error_message=str(exc),
+ )
+
+ @classmethod
+ def get_for_block(cls, downstream: XBlock) -> t.Self:
+ """
+ Get info on a block's relationship with its linked upstream content (without actually loading the content).
+
+ Currently, the only supported upstreams are LC-backed Library Components. This may change in the future (see
+ module docstring).
+
+ If link exists, is supported, and is followable, returns UpstreamLink.
+ Otherwise, raises an UpstreamLinkException.
+ """
+ if not downstream.upstream:
+ raise NoUpstream()
+ if not isinstance(downstream.usage_key.context_key, CourseKey):
+ raise BadDownstream(_("Cannot update content because it does not belong to a course."))
+ if downstream.has_children:
+ raise BadDownstream(_("Updating content with children is not yet supported."))
+ try:
+ upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
+ except InvalidKeyError as exc:
+ raise BadUpstream(_("Reference to linked library item is malformed")) from exc
+ downstream_type = downstream.usage_key.block_type
+ if upstream_key.block_type != downstream_type:
+ # Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
+ # It could be reasonable to relax this requirement in the future if there's product need for it.
+ # For example, there's no reason that a StaticTabBlock couldn't take updates from an HtmlBlock.
+ raise BadUpstream(
+ _("Content type mismatch: {downstream_type} cannot be linked to {upstream_type}.").format(
+ downstream_type=downstream_type, upstream_type=upstream_key.block_type
+ )
+ ) from TypeError(
+ f"downstream block '{downstream.usage_key}' is linked to "
+ f"upstream block of different type '{upstream_key}'"
+ )
+ # We import this here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
+ from openedx.core.djangoapps.content_libraries.api import (
+ get_library_block # pylint: disable=wrong-import-order
+ )
+ try:
+ lib_meta = get_library_block(upstream_key)
+ except XBlockNotFoundError as exc:
+ raise BadUpstream(_("Linked library item was not found in the system")) from exc
+ return cls(
+ upstream_ref=downstream.upstream,
+ version_synced=downstream.upstream_version,
+ version_available=(lib_meta.published_version_num if lib_meta else None),
+ version_declined=downstream.upstream_version_declined,
+ error_message=None,
+ )
+
+
+def sync_from_upstream(downstream: XBlock, user: User) -> None:
+ """
+ Update `downstream` with content+settings from the latest available version of its linked upstream content.
+
+ Preserves overrides to customizable fields; overwrites overrides to other fields.
+ Does not save `downstream` to the store. That is left up to the caller.
+
+ If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
+ """
+ link, upstream = _load_upstream_link_and_block(downstream, user)
+ _update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=False)
+ _update_non_customizable_fields(upstream=upstream, downstream=downstream)
+ _update_tags(upstream=upstream, downstream=downstream)
+ downstream.upstream_version = link.version_available
+
+
+def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
+ """
+ Fetch upstream-defined value of customizable fields and save them on the downstream.
+
+ If `upstream` is provided, use that block as the upstream.
+ Otherwise, load the block specified by `downstream.upstream`, which may raise an UpstreamLinkException.
+ """
+ if not upstream:
+ _link, upstream = _load_upstream_link_and_block(downstream, user)
+ _update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
+
+
+def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[UpstreamLink, XBlock]:
+ """
+ Load the upstream metadata and content for a downstream block.
+
+ Assumes that the upstream content is an XBlock in an LC-backed content libraries. This assumption may need to be
+ relaxed in the future (see module docstring).
+
+ If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
+ """
+ link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
+ # We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
+ from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
+ try:
+ lib_block: XBlock = load_block(
+ LibraryUsageLocatorV2.from_string(downstream.upstream),
+ user,
+ check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
+ version=LatestVersion.PUBLISHED,
+ )
+ except (NotFound, PermissionDenied) as exc:
+ raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
+ return link, lib_block
+
+
+def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fetch: bool) -> None:
+ """
+ For each customizable field:
+ * Save the upstream value to a hidden field on the downstream ("FETCH").
+ * If `not only_fetch`, and if the field *isn't* customized on the downstream, then:
+ * Update it the downstream field's value from the upstream field ("SYNC").
+
+ Concrete example: Imagine `lib_problem` is our upstream and `course_problem` is our downstream.
+
+ * Say that the customizable fields are [display_name, max_attempts].
+
+ * Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch").
+ * If `not only_fetch`, and `course_problem.display_name` wasn't customized, then:
+ * Set `course_problem.display_name = lib_problem.display_name` ("sync").
+
+ * Set `course_problem.upstream_max_attempts = lib_problem.max_attempts` ("fetch").
+ * If `not only_fetch`, and `course_problem.max_attempts` wasn't customized, then:
+ * Set `course_problem.max_attempts = lib_problem.max_attempts` ("sync").
+ """
+ syncable_field_names = _get_synchronizable_fields(upstream, downstream)
+
+ for field_name, fetch_field_name in downstream.get_customizable_fields().items():
+
+ if field_name not in syncable_field_names:
+ continue
+
+ # FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`).
+ old_upstream_value = getattr(downstream, fetch_field_name)
+ new_upstream_value = getattr(upstream, field_name)
+ setattr(downstream, fetch_field_name, new_upstream_value)
+
+ if only_fetch:
+ continue
+
+ # Okay, now for the nuanced part...
+ # We need to update the downstream field *iff it has not been customized**.
+ # Determining whether a field has been customized will differ in Beta vs Future release.
+ # (See "PRESERVING DOWNSTREAM CUSTOMIZATIONS" comment below for details.)
+
+ ## FUTURE BEHAVIOR: field is "customized" iff we have noticed that the user edited it.
+ # if field_name in downstream.downstream_customized:
+ # continue
+
+ ## BETA BEHAVIOR: field is "customized" iff we have the prev upstream value, but field doesn't match it.
+ downstream_value = getattr(downstream, field_name)
+ if old_upstream_value and downstream_value != old_upstream_value:
+ continue # Field has been customized. Don't touch it. Move on.
+
+ # Field isn't customized -- SYNC it!
+ setattr(downstream, field_name, new_upstream_value)
+
+
+def _update_non_customizable_fields(*, upstream: XBlock, downstream: XBlock) -> None:
+ """
+ For each field `downstream.blah` that isn't customizable: set it to `upstream.blah`.
+ """
+ syncable_fields = _get_synchronizable_fields(upstream, downstream)
+ customizable_fields = set(downstream.get_customizable_fields().keys())
+ for field_name in syncable_fields - customizable_fields:
+ new_upstream_value = getattr(upstream, field_name)
+ setattr(downstream, field_name, new_upstream_value)
+
+
+def _update_tags(*, upstream: XBlock, downstream: XBlock) -> None:
+ """
+ Update tags from `upstream` to `downstream`
+ """
+ from openedx.core.djangoapps.content_tagging.api import copy_tags_as_read_only
+ # For any block synced with an upstream, copy the tags as read_only
+ # This keeps tags added locally.
+ copy_tags_as_read_only(
+ str(upstream.location),
+ str(downstream.location),
+ )
+
+
+def _get_synchronizable_fields(upstream: XBlock, downstream: XBlock) -> set[str]:
+ """
+ The syncable fields are the ones which are content- or settings-scoped AND are defined on both (up,down)stream.
+ """
+ return set.intersection(*[
+ set(
+ field_name
+ for (field_name, field) in block.__class__.fields.items()
+ if field.scope in [Scope.settings, Scope.content]
+ )
+ for block in [upstream, downstream]
+ ])
+
+
+def decline_sync(downstream: XBlock) -> None:
+ """
+ Given an XBlock that is linked to upstream content, mark the latest available update as 'declined' so that its
+ authors are not prompted (until another upstream version becomes available).
+
+ Does not save `downstream` to the store. That is left up to the caller.
+
+ If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
+ """
+ upstream_link = UpstreamLink.get_for_block(downstream) # Can raise UpstreamLinkException
+ downstream.upstream_version_declined = upstream_link.version_available
+
+
+def sever_upstream_link(downstream: XBlock) -> None:
+ """
+ Given an XBlock that is linked to upstream content, disconnect the link, such that authors are never again prompted
+ to sync upstream updates. Erase all `.upstream*` fields from the downtream block.
+
+ However, before nulling out the `.upstream` field, we copy its value over to `.copied_from_block`. This makes sense,
+ because once a downstream block has been de-linked from source (e.g., a Content Library block), it is no different
+ than if the block had just been copy-pasted in the first place.
+
+ Does not save `downstream` to the store. That is left up to the caller.
+
+ If `downstream` lacks a link, then this raises NoUpstream (though it is reasonable for callers to handle such
+ exception and ignore it, as the end result is the same: `downstream.upstream is None`).
+ """
+ if not downstream.upstream:
+ raise NoUpstream()
+ downstream.copied_from_block = downstream.upstream
+ downstream.upstream = None
+ downstream.upstream_version = None
+ for _, fetched_upstream_field in downstream.get_customizable_fields().items():
+ setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al.
+
+
+def _get_library_xblock_url(usage_key: LibraryUsageLocatorV2):
+ """
+ Gets authoring url for given library_key.
+ """
+ library_url = None
+ if mfe_base_url := settings.COURSE_AUTHORING_MICROFRONTEND_URL: # type: ignore
+ library_key = usage_key.lib_key
+ library_url = f'{mfe_base_url}/library/{library_key}/components?usageKey={usage_key}'
+ return library_url
+
+
+class UpstreamSyncMixin(XBlockMixin):
+ """
+ Allows an XBlock in the CMS to be associated & synced with an upstream.
+
+ Mixed into CMS's XBLOCK_MIXINS, but not LMS's.
+ """
+
+ # Upstream synchronization metadata fields
+ upstream = String(
+ help=(
+ "The usage key of a block (generally within a content library) which serves as a source of upstream "
+ "updates for this block, or None if there is no such upstream. Please note: It is valid for this "
+ "field to hold a usage key for an upstream block that does not exist (or does not *yet* exist) on "
+ "this instance, particularly if this downstream block was imported from a different instance."
+ ),
+ default=None, scope=Scope.settings, hidden=True, enforce_type=True
+ )
+ upstream_version = Integer(
+ help=(
+ "Record of the upstream block's version number at the time this block was created from it. If this "
+ "upstream_version is smaller than the upstream block's latest published version, then the author will be "
+ "invited to sync updates into this downstream block, presuming that they have not already declined to sync "
+ "said version."
+ ),
+ default=None, scope=Scope.settings, hidden=True, enforce_type=True,
+ )
+ upstream_version_declined = Integer(
+ help=(
+ "Record of the latest upstream version for which the author declined to sync updates, or None if they have "
+ "never declined an update."
+ ),
+ default=None, scope=Scope.settings, hidden=True, enforce_type=True,
+ )
+
+ # Store the fetched upstream values for customizable fields.
+ upstream_display_name = String(
+ help=("The value of display_name on the linked upstream block."),
+ default=None, scope=Scope.settings, hidden=True, enforce_type=True,
+ )
+ upstream_max_attempts = Integer(
+ help=("The value of max_attempts on the linked upstream block."),
+ default=None, scope=Scope.settings, hidden=True, enforce_type=True,
+ )
+
+ @classmethod
+ def get_customizable_fields(cls) -> dict[str, str]:
+ """
+ Mapping from each customizable field to the field which can be used to restore its upstream value.
+
+ XBlocks outside of edx-platform can override this in order to set up their own customizable fields.
+ """
+ return {
+ "display_name": "upstream_display_name",
+ "max_attempts": "upstream_max_attempts",
+ }
+
+ # PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM VALUES
+ #
+ # For the full Content Libraries Relaunch, we would like to keep track of which customizable fields the user has
+ # actually customized. The idea is: once an author has customized a customizable field....
+ #
+ # - future upstream syncs will NOT blow away the customization,
+ # - but future upstream syncs WILL fetch the upstream values and tuck them away in a hidden field,
+ # - and the author can can revert back to said fetched upstream value at any point.
+ #
+ # Now, whether field is "customized" (and thus "revertible") is dependent on whether they have ever edited it.
+ # To instrument this, we need to keep track of which customizable fields have been edited using a new XBlock field:
+ # `downstream_customized`
+ #
+ # Implementing `downstream_customized` has proven difficult, because there is no simple way to keep it up-to-date
+ # with the many different ways XBlock fields can change. The `.save()` and `.editor_saved()` methods are promising,
+ # but we need to do more due diligence to be sure that they cover all cases, including API edits, import/export,
+ # copy/paste, etc. We will figure this out in time for the full Content Libraries Relaunch (related ticket:
+ # https://github.com/openedx/frontend-app-authoring/issues/1317). But, for the Beta realease, we're going to
+ # implement something simpler:
+ #
+ # - We fetch upstream values for customizable fields and tuck them away in a hidden field (same as above).
+ # - If a customizable field DOES match the fetched upstream value, then future upstream syncs DO update it.
+ # - If a customizable field does NOT the fetched upstream value, then future upstream syncs DO NOT update it.
+ # - There is no UI option for explicitly reverting back to the fetched upstream value.
+ #
+ # For future reference, here is a partial implementation of what we are thinking for the full Content Libraries
+ # Relaunch::
+ #
+ # downstream_customized = List(
+ # help=(
+ # "Names of the fields which have values set on the upstream block yet have been explicitly "
+ # "overridden on this downstream block. Unless explicitly cleared by the user, these customizations "
+ # "will persist even when updates are synced from the upstream."
+ # ),
+ # default=[], scope=Scope.settings, hidden=True, enforce_type=True,
+ # )
+ #
+ # def save(self, *args, **kwargs):
+ # """
+ # Update `downstream_customized` when a customizable field is modified.
+ #
+ # NOTE: This does not work, because save() isn't actually called in all the cases that we'd want it to be.
+ # """
+ # super().save(*args, **kwargs)
+ # customizable_fields = self.get_customizable_fields()
+ #
+ # # Loop through all the fields that are potentially cutomizable.
+ # for field_name, restore_field_name in self.get_customizable_fields():
+ #
+ # # If the field is already marked as customized, then move on so that we don't
+ # # unneccessarily query the block for its current value.
+ # if field_name in self.downstream_customized:
+ # continue
+ #
+ # # If this field's value doesn't match the synced upstream value, then mark the field
+ # # as customized so that we don't clobber it later when syncing.
+ # # NOTE: Need to consider the performance impact of all these field lookups.
+ # if getattr(self, field_name) != getattr(self, restore_field_name):
+ # self.downstream_customized.append(field_name)
diff --git a/cms/static/images/large-itembank-icon.png b/cms/static/images/large-itembank-icon.png
new file mode 100644
index 00000000000..655ef153133
Binary files /dev/null and b/cms/static/images/large-itembank-icon.png differ
diff --git a/cms/static/images/large-library_v2-icon.png b/cms/static/images/large-library_v2-icon.png
new file mode 100644
index 00000000000..5242104c4ce
Binary files /dev/null and b/cms/static/images/large-library_v2-icon.png differ
diff --git a/cms/static/images/pencil-icon.svg b/cms/static/images/pencil-icon.svg
new file mode 100644
index 00000000000..1744c884dcc
--- /dev/null
+++ b/cms/static/images/pencil-icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/cms/static/js/spec_helpers/edit_helpers.js b/cms/static/js/spec_helpers/edit_helpers.js
index acfdeff3234..4c7e7d5a581 100644
--- a/cms/static/js/spec_helpers/edit_helpers.js
+++ b/cms/static/js/spec_helpers/edit_helpers.js
@@ -92,6 +92,7 @@ installEditTemplates = function(append) {
TemplateHelpers.installTemplate('edit-xblock-modal');
TemplateHelpers.installTemplate('editor-mode-button');
TemplateHelpers.installTemplate('edit-title-button');
+ TemplateHelpers.installTemplate('edit-upstream-alert');
// Add templates needed by the settings editor
TemplateHelpers.installTemplate('metadata-editor');
diff --git a/cms/static/js/views/components/add_library_content.js b/cms/static/js/views/components/add_library_content.js
new file mode 100644
index 00000000000..278717ba921
--- /dev/null
+++ b/cms/static/js/views/components/add_library_content.js
@@ -0,0 +1,75 @@
+/**
+ * Provides utilities to open and close the library content picker.
+ * This is for adding a single, selected, non-randomized component (XBlock)
+ * from the library into the course. It achieves the same effect as copy-pasting
+ * the block from a library into the course. The block will remain synced with
+ * the "upstream" library version.
+ *
+ * Compare cms/static/js/views/modals/select_v2_library_content.js which uses
+ * a multi-select modal to add component(s) to a Problem Bank (for
+ * randomization).
+ */
+define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
+function($, _, gettext, BaseModal) {
+ 'use strict';
+
+ var AddLibraryContent = BaseModal.extend({
+ options: $.extend({}, BaseModal.prototype.options, {
+ modalName: 'add-component-from-library',
+ modalSize: 'lg',
+ view: 'studio_view',
+ viewSpecificClasses: 'modal-add-component-picker confirm',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext('Add library content'),
+ addPrimaryActionButton: false,
+ }),
+
+ initialize: function() {
+ BaseModal.prototype.initialize.call(this);
+ // Add event listen to close picker when the iframe tells us to
+ const handleMessage = (event) => {
+ if (event.data?.type === 'pickerComponentSelected') {
+ var requestData = {
+ library_content_key: event.data.usageKey,
+ category: event.data.category,
+ }
+ this.refreshFunction(requestData);
+ this.hide();
+ }
+ };
+ this.messageListener = window.addEventListener("message", handleMessage);
+ this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
+ },
+
+ hide: function() {
+ BaseModal.prototype.hide.call(this);
+ this.cleanupListener();
+ },
+
+ /**
+ * Adds the action buttons to the modal.
+ */
+ addActionButtons: function() {
+ this.addActionButton('cancel', gettext('Cancel'));
+ },
+
+ /**
+ * Show a component picker modal from library.
+ * @param contentPickerUrl Url for component picker
+ * @param refreshFunction A function to refresh the block after it has been updated
+ */
+ showComponentPicker: function(contentPickerUrl, refreshFunction) {
+ this.contentPickerUrl = contentPickerUrl;
+ this.refreshFunction = refreshFunction;
+
+ this.render();
+ this.show();
+ },
+
+ getContentHtml: function() {
+ return ``;
+ },
+ });
+
+ return AddLibraryContent;
+});
diff --git a/cms/static/js/views/components/add_xblock.js b/cms/static/js/views/components/add_xblock.js
index 5f491116693..29ce5eec767 100644
--- a/cms/static/js/views/components/add_xblock.js
+++ b/cms/static/js/views/components/add_xblock.js
@@ -3,8 +3,9 @@
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
'js/views/components/add_xblock_button', 'js/views/components/add_xblock_menu',
+ 'js/views/components/add_library_content',
'edx-ui-toolkit/js/utils/html-utils'],
-function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, HtmlUtils) {
+function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, AddLibraryContent, HtmlUtils) {
'use strict';
var AddXBlockComponent = BaseView.extend({
@@ -67,14 +68,32 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Htm
oldOffset = ViewUtils.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
- ViewUtils.runOperationShowingMessage(
- gettext('Adding'),
- _.bind(this.options.createComponent, this, saveData, $element)
- ).always(function() {
- // Restore the scroll position of the buttons so that the new
- // component appears above them.
- ViewUtils.setScrollOffset(self.$el, oldOffset);
- });
+
+ if (saveData.type === 'library_v2') {
+ var modal = new AddLibraryContent();
+ modal.showComponentPicker(
+ this.options.libraryContentPickerUrl,
+ function(data) {
+ ViewUtils.runOperationShowingMessage(
+ gettext('Adding'),
+ _.bind(this.options.createComponent, this, data, $element),
+ ).always(function() {
+ // Restore the scroll position of the buttons so that the new
+ // component appears above them.
+ ViewUtils.setScrollOffset(self.$el, oldOffset);
+ });
+ }.bind(this)
+ );
+ } else {
+ ViewUtils.runOperationShowingMessage(
+ gettext('Adding'),
+ _.bind(this.options.createComponent, this, saveData, $element),
+ ).always(function() {
+ // Restore the scroll position of the buttons so that the new
+ // component appears above them.
+ ViewUtils.setScrollOffset(self.$el, oldOffset);
+ });
+ }
}
});
diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js
index 7182d8b0e51..b5b69c721b1 100644
--- a/cms/static/js/views/modals/edit_xblock.js
+++ b/cms/static/js/views/modals/edit_xblock.js
@@ -75,6 +75,27 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
this.$('.modal-window-title').html(this.loadTemplate('edit-title-button')({title: title}));
},
+ createWarningToast: function(upstreamLink) {
+ // xss-lint: disable=javascript-jquery-insertion
+ this.$('.modal-header').before(this.loadTemplate('edit-upstream-alert')({
+ upstreamLink: upstreamLink,
+ }));
+ },
+
+ getXBlockUpstreamLink: function() {
+ const usageKey = this.xblockElement.data('locator');
+ $.ajax({
+ url: '/api/contentstore/v2/downstreams/' + usageKey,
+ type: 'GET',
+ success: function(data) {
+ if (data?.upstream_link) {
+ this.createWarningToast(data.upstream_link);
+ }
+ }.bind(this),
+ notifyOnError: false,
+ })
+ },
+
onDisplayXBlock: function() {
var editorView = this.editorView,
title = this.getTitle(),
@@ -101,6 +122,7 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
} else {
this.$('.modal-window-title').text(title);
}
+ this.getXBlockUpstreamLink();
// If the xblock is not using custom buttons then choose which buttons to show
if (!editorView.hasCustomButtons()) {
diff --git a/cms/static/js/views/modals/preview_v2_library_changes.js b/cms/static/js/views/modals/preview_v2_library_changes.js
new file mode 100644
index 00000000000..28213289889
--- /dev/null
+++ b/cms/static/js/views/modals/preview_v2_library_changes.js
@@ -0,0 +1,112 @@
+/**
+ * The PreviewLibraryChangesModal is a Backbone view that shows an iframe in a
+ * modal window. The iframe embeds a view from the Authoring MFE that allows
+ * authors to preview the new version of a library-sourced XBlock, and decide
+ * whether to accept ("sync") or reject ("ignore") the changes.
+ */
+define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal',
+ 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils'],
+function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils) {
+ 'use strict';
+
+ var PreviewLibraryChangesModal = BaseModal.extend({
+ events: _.extend({}, BaseModal.prototype.events, {
+ 'click .action-accept': 'acceptChanges',
+ 'click .action-ignore': 'ignoreChanges',
+ }),
+
+ options: $.extend({}, BaseModal.prototype.options, {
+ modalName: 'preview-lib-changes',
+ modalSize: 'lg',
+ view: 'studio_view',
+ viewSpecificClasses: 'modal-lib-preview confirm',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext('Preview changes to: {title}'),
+ addPrimaryActionButton: false,
+ }),
+
+ initialize: function() {
+ BaseModal.prototype.initialize.call(this);
+ },
+
+ /**
+ * Adds the action buttons to the modal.
+ */
+ addActionButtons: function() {
+ this.addActionButton('accept', gettext('Accept changes'), true);
+ this.addActionButton('ignore', gettext('Ignore changes'));
+ this.addActionButton('cancel', gettext('Cancel'));
+ },
+
+ /**
+ * Show an edit modal for the specified xblock
+ * @param xblockElement The element that contains the xblock to be edited.
+ * @param rootXBlockInfo An XBlockInfo model that describes the root xblock on the page.
+ * @param refreshFunction A function to refresh the block after it has been updated
+ */
+ showPreviewFor: function(xblockElement, rootXBlockInfo, refreshFunction) {
+ this.xblockElement = xblockElement;
+ this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
+ this.courseAuthoringMfeUrl = rootXBlockInfo.attributes.course_authoring_url;
+ const headerElement = xblockElement.find('.xblock-header-primary');
+ this.downstreamBlockId = this.xblockInfo.get('id');
+ this.upstreamBlockId = headerElement.data('upstream-ref');
+ this.upstreamBlockVersionSynced = headerElement.data('version-synced');
+ this.refreshFunction = refreshFunction;
+
+ this.render();
+ this.show();
+ },
+
+ getContentHtml: function() {
+ return `
+