feat!: Remove outdated Libraries Relaunch cruft (#35644)

The V2 libraries project had a few past iterations which were never
launched. This commit cleans up pieces from those which we don't need
for the real Libraries Relaunch MVP in Sumac:

* Remove ENABLE_LIBRARY_AUTHORING_MICROFRONTEND,
  LIBRARY_AUTHORING_FRONTEND_URL, and
  REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND, all of which are obsolete
  now that library authoring has been merged into
  https://github.com/openedx/frontend-app-authoring.
  More details on the new Content Libraries configuration settings are
  here: https://github.com/openedx/frontend-app-authoring/issues/1334

* Remove dangling support for syncing V2 (learning core-backed) library
  content using the LibraryContentBlock. This code was all based on an
  older understanding of V2 Content Libraries, where the libraries were
  smaller and versioned as a whole rather then versioned by-item.
  Reference to V2 libraries will be done on a per-block basis using
  the upstream/downstream system, described here:
  https://github.com/openedx/edx-platform/blob/master/docs/decisions/0020-upstream-downstream.rst
  It's important that we remove this support now so that OLX course
  authors don't stuble upon it and use it, which would be buggy and
  complicate future migrations.

* Remove the "mode" parameter from LibraryContentBlock. The only
  supported mode was and is "random". We will not be adding any further
  modes. Going forward for V2, we will have an ItemBank block for
  randomizing items (regardless of source), which can be synthesized
  with upstream referenced as described above. Existing
  LibraryContentBlocks will be migrated.

* Finally, some renamings:

  * LibraryContentBlock -> LegacyLibraryContentBlock
  * LibraryToolsService -> LegacyLibraryToolsService
  * LibrarySummary -> LegacyLibrarySummary

  Module names and the old OLX tag (library_content) are unchanged.

Closes: https://github.com/openedx/frontend-app-authoring/issues/1115
This commit is contained in:
Kyle McCormick
2024-10-15 11:32:01 -04:00
committed by GitHub
parent 70df3deea6
commit 2bbd8ecd18
31 changed files with 180 additions and 604 deletions

View File

@@ -4,7 +4,7 @@ waffle switches for the contentstore app.
""" """
from edx_toggles.toggles import WaffleFlag, WaffleSwitch from edx_toggles.toggles import WaffleSwitch
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
@@ -26,20 +26,6 @@ SHOW_REVIEW_RULES_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=togg
f'{WAFFLE_NAMESPACE}.show_review_rules', __name__, LOG_PREFIX f'{WAFFLE_NAMESPACE}.show_review_rules', __name__, LOG_PREFIX
) )
# Waffle flag to redirect to the library authoring MFE.
# .. toggle_name: contentstore.library_authoring_mfe
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: Toggles the new micro-frontend-based implementation of the library authoring experience.
# .. toggle_use_cases: temporary, open_edx
# .. toggle_creation_date: 2020-08-03
# .. toggle_target_removal_date: 2020-12-31
# .. toggle_warning: Also set settings.LIBRARY_AUTHORING_MICROFRONTEND_URL and ENABLE_LIBRARY_AUTHORING_MICROFRONTEND.
# .. toggle_tickets: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4106944527/Libraries+Relaunch+Proposal+For+Product+Review
REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = WaffleFlag(
f'{WAFFLE_NAMESPACE}.library_authoring_mfe', __name__, LOG_PREFIX
)
# .. toggle_name: studio.custom_relative_dates # .. toggle_name: studio.custom_relative_dates
# .. toggle_implementation: CourseWaffleFlag # .. toggle_implementation: CourseWaffleFlag

View File

@@ -59,10 +59,8 @@ class CourseHomeSerializer(serializers.Serializer):
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True) libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
libraries_enabled = serializers.BooleanField() libraries_enabled = serializers.BooleanField()
taxonomies_enabled = serializers.BooleanField() taxonomies_enabled = serializers.BooleanField()
library_authoring_mfe_url = serializers.CharField()
taxonomy_list_mfe_url = serializers.CharField() taxonomy_list_mfe_url = serializers.CharField()
optimization_enabled = serializers.BooleanField() optimization_enabled = serializers.BooleanField()
redirect_to_library_authoring_mfe = serializers.BooleanField()
request_course_creator_url = serializers.CharField() request_course_creator_url = serializers.CharField()
rerun_creator_status = serializers.BooleanField() rerun_creator_status = serializers.BooleanField()
show_new_library_button = serializers.BooleanField() show_new_library_button = serializers.BooleanField()

View File

@@ -59,9 +59,7 @@ class HomePageView(APIView):
"in_process_course_actions": [], "in_process_course_actions": [],
"libraries": [], "libraries": [],
"libraries_enabled": true, "libraries_enabled": true,
"library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023",
"optimization_enabled": true, "optimization_enabled": true,
"redirect_to_library_authoring_mfe": false,
"request_course_creator_url": "/request_course_creator", "request_course_creator_url": "/request_course_creator",
"rerun_creator_status": true, "rerun_creator_status": true,
"show_new_library_button": true, "show_new_library_button": true,

View File

@@ -52,10 +52,8 @@ class HomePageViewTest(CourseTestCase):
"libraries": [], "libraries": [],
"libraries_enabled": True, "libraries_enabled": True,
"taxonomies_enabled": True, "taxonomies_enabled": True,
"library_authoring_mfe_url": settings.LIBRARY_AUTHORING_MICROFRONTEND_URL,
"taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies', "taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies',
"optimization_enabled": False, "optimization_enabled": False,
"redirect_to_library_authoring_mfe": False,
"request_course_creator_url": "/request_course_creator", "request_course_creator_url": "/request_course_creator",
"rerun_creator_status": True, "rerun_creator_status": True,
"show_new_library_button": True, "show_new_library_button": True,

View File

@@ -98,7 +98,7 @@ from cms.djangoapps.contentstore.toggles import (
) )
from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata 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.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors from xmodule.data import CertificatesDisplayBehaviors
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
@@ -1265,7 +1265,7 @@ def load_services_for_studio(runtime, user):
"settings": SettingsService(), "settings": SettingsService(),
"lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag), "lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag),
"teams_configuration": TeamsConfigurationService(), "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 runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
@@ -1671,9 +1671,7 @@ def get_home_context(request, no_course=False):
ENABLE_GLOBAL_STAFF_OPTIMIZATION, ENABLE_GLOBAL_STAFF_OPTIMIZATION,
) )
from cms.djangoapps.contentstore.views.library import ( from cms.djangoapps.contentstore.views.library import (
LIBRARY_AUTHORING_MICROFRONTEND_URL,
LIBRARIES_ENABLED, LIBRARIES_ENABLED,
should_redirect_to_library_authoring_mfe,
user_can_view_create_library_button, user_can_view_create_library_button,
) )
@@ -1699,12 +1697,9 @@ def get_home_context(request, no_course=False):
'in_process_course_actions': in_process_course_actions, 'in_process_course_actions': in_process_course_actions,
'libraries_enabled': LIBRARIES_ENABLED, 'libraries_enabled': LIBRARIES_ENABLED,
'taxonomies_enabled': not is_tagging_feature_disabled(), '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(), 'taxonomy_list_mfe_url': get_taxonomy_list_url(),
'libraries': libraries, 'libraries': libraries,
'show_new_library_button': user_can_view_create_library_button(user) 'show_new_library_button': user_can_view_create_library_button(user),
and not should_redirect_to_library_authoring_mfe(),
'user': user, 'user': user,
'request_course_creator_url': reverse('request_course_creator'), 'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(user), 'course_creator_status': _get_course_creator_status(user),
@@ -2202,7 +2197,7 @@ class StudioPermissionsService:
Deprecated. To be replaced by a more general authorization service. 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): def __init__(self, user):

View File

@@ -41,7 +41,6 @@ from common.djangoapps.student.roles import (
) )
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json 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 ..utils import add_instructor, reverse_library_url
from .component import CONTAINER_TEMPLATES, get_component_templates from .component import CONTAINER_TEMPLATES, get_component_templates
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
@@ -52,21 +51,6 @@ __all__ = ['library_handler', 'manage_library_users']
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False) 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_create_library_for_org(user, org=None): def _user_can_create_library_for_org(user, org=None):

View File

@@ -982,7 +982,7 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
def test_duplicate_library_content_block(self): # pylint: disable=too-many-statements 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() store = modulestore()

View File

@@ -7,13 +7,11 @@ import ddt
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from rest_framework.test import APIClient from rest_framework.test import APIClient
from openedx_tagging.core.tagging.models import Tag from openedx_tagging.core.tagging.models import Tag
from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course 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 cms.djangoapps.contentstore.utils import reverse_usage_url
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_tagging import api as tagging_api
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
@@ -165,12 +163,12 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
publish_item=True, publish_item=True,
).location ).location
library = ClipboardLibraryContentPasteTestCase.setup_library() library = ClipboardPasteFromV1LibraryTestCase.setup_library()
with self.store.bulk_operations(course_key): with self.store.bulk_operations(course_key):
library_content_block_key = BlockFactory.create( library_content_block_key = BlockFactory.create(
parent=self.store.get_item(unit_key), parent=self.store.get_item(unit_key),
category="library_content", category="library_content",
source_library_id=str(library.key), source_library_id=str(library.context_key),
display_name="LC Block", display_name="LC Block",
publish_item=True, publish_item=True,
).location ).location
@@ -393,27 +391,27 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase): class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
""" """
Test Clipboard Paste functionality with library content Test Clipboard Paste functionality with legacy (v1) library content
""" """
def setUp(self): def setUp(self):
""" """
Set up a v2 Content Library and a library content block Set up a v1 Content Library and a library content block
""" """
super().setUp() super().setUp()
self.client = APIClient() self.client = APIClient()
self.client.login(username=self.user.username, password=self.user_password) self.client.login(username=self.user.username, password=self.user_password)
self.store = modulestore() self.store = modulestore()
library = self.setup_library() self.library = self.setup_library()
# Create a library content block (lc), point it out our library, and sync it. # Create a library content block (lc), point it out our library, and sync it.
self.course = CourseFactory.create(display_name='Course') self.course = CourseFactory.create(display_name='Course')
self.orig_lc_block = BlockFactory.create( self.orig_lc_block = BlockFactory.create(
parent=self.course, parent=self.course,
category="library_content", category="library_content",
source_library_id=str(library.key), source_library_id=str(self.library.context_key),
display_name="LC Block", display_name="LC Block",
publish_item=False, publish_item=False,
) )
@@ -426,18 +424,15 @@ class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
@classmethod @classmethod
def setup_library(cls): 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 = LibraryFactory.create(display_name='Library')
library_type=library_api.COMPLEX, lib_block = BlockFactory.create(
org=Organization.objects.create(name="Test Org", short_name="CL-TEST"), parent_location=library.usage_key,
slug="lib", category="problem",
title="Library", display_name="MCQ",
) max_attempts=1,
# Populate it with a problem: data="""
problem_key = library_api.create_library_block(library.key, "problem", "p1").usage_key
library_api.set_library_block_olx(problem_key, """
<problem display_name="MCQ" max_attempts="1">
<multiplechoiceresponse> <multiplechoiceresponse>
<label>Q</label> <label>Q</label>
<choicegroup type="MultipleChoice"> <choicegroup type="MultipleChoice">
@@ -445,9 +440,9 @@ class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
<choice correct="true">Right</choice> <choice correct="true">Right</choice>
</choicegroup> </choicegroup>
</multiplechoiceresponse> </multiplechoiceresponse>
</problem> """,
""") publish_item=False,
library_api.publish_changes(library.key) )
return library return library
def test_paste_library_content_block(self): def test_paste_library_content_block(self):

View File

@@ -434,18 +434,6 @@ FEATURES = {
# .. toggle_tickets: https://openedx.atlassian.net/browse/DEPR-58 # .. toggle_tickets: https://openedx.atlassian.net/browse/DEPR-58
'DEPRECATE_OLD_COURSE_KEYS_IN_STUDIO': True, '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_name: FEATURES['DISABLE_COURSE_CREATION']
# .. toggle_implementation: DjangoSetting # .. toggle_implementation: DjangoSetting
# .. toggle_default: False # .. toggle_default: False
@@ -601,7 +589,6 @@ IDA_LOGOUT_URI_LIST = []
COURSE_AUTHORING_MICROFRONTEND_URL = None COURSE_AUTHORING_MICROFRONTEND_URL = None
DISCUSSIONS_MICROFRONTEND_URL = None DISCUSSIONS_MICROFRONTEND_URL = None
DISCUSSIONS_MFE_FEEDBACK_URL = None DISCUSSIONS_MFE_FEEDBACK_URL = None
LIBRARY_AUTHORING_MICROFRONTEND_URL = None
# .. toggle_name: ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY # .. toggle_name: ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY
# .. toggle_implementation: DjangoSetting # .. toggle_implementation: DjangoSetting
# .. toggle_default: False # .. toggle_default: False
@@ -2779,6 +2766,7 @@ WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-runni
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" 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" 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" 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 # keys for big blue button live provider
COURSE_LIVE_GLOBAL_CREDENTIALS = {} COURSE_LIVE_GLOBAL_CREDENTIALS = {}
@@ -2810,6 +2798,7 @@ DISCUSSIONS_INCONTEXT_FEEDBACK_URL = ''
# Learn More link in upgraded discussion notification alert # Learn More link in upgraded discussion notification alert
# pylint: disable=line-too-long # 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" 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## #### django-simple-history##
# disable indexing on date field its coming django-simple-history. # disable indexing on date field its coming django-simple-history.

View File

@@ -174,9 +174,6 @@ FEATURES['ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES'] = True
################### FRONTEND APPLICATION PUBLISHER URL ################### ################### FRONTEND APPLICATION PUBLISHER URL ###################
FEATURES['FRONTEND_APP_PUBLISHER_URL'] = 'http://localhost:18400' FEATURES['FRONTEND_APP_PUBLISHER_URL'] = 'http://localhost:18400'
################### FRONTEND APPLICATION LIBRARY AUTHORING ###################
LIBRARY_AUTHORING_MICROFRONTEND_URL = 'http://localhost:3001'
################### FRONTEND APPLICATION COURSE AUTHORING ################### ################### FRONTEND APPLICATION COURSE AUTHORING ###################
COURSE_AUTHORING_MICROFRONTEND_URL = 'http://localhost:2001' COURSE_AUTHORING_MICROFRONTEND_URL = 'http://localhost:2001'

View File

@@ -348,9 +348,6 @@ from openedx.core.djangolib.js_utils import (
% endif % endif
% if libraries_enabled: % if libraries_enabled:
% if redirect_to_library_authoring_mfe:
<li><a href="${library_authoring_mfe_url}">${_("Libraries")}</a></li>
%else:
<li class="libraries-tab ${ 'active' if active_tab == 'libraries' else ''}"> <li class="libraries-tab ${ 'active' if active_tab == 'libraries' else ''}">
% if split_studio_home: % if split_studio_home:
<a href="${reverse('home_library')}">${_("Libraries")}</a> <a href="${reverse('home_library')}">${_("Libraries")}</a>
@@ -358,7 +355,6 @@ from openedx.core.djangolib.js_utils import (
<a href="#" >${_("Libraries")}</a> <a href="#" >${_("Libraries")}</a>
% endif % endif
</li> </li>
% endif
% endif % endif
% if taxonomies_enabled: % if taxonomies_enabled:
<li><a href="${taxonomy_list_mfe_url}">${_("Taxonomies")}</li> <li><a href="${taxonomy_list_mfe_url}">${_("Taxonomies")}</li>

View File

@@ -14,7 +14,6 @@ from cms.envs.common import ( # lint-amnesty, pylint: disable=unused-import
ADVANCED_PROBLEM_TYPES, ADVANCED_PROBLEM_TYPES,
COURSE_IMPORT_EXPORT_STORAGE, COURSE_IMPORT_EXPORT_STORAGE,
GIT_EXPORT_DEFAULT_IDENT, GIT_EXPORT_DEFAULT_IDENT,
LIBRARY_AUTHORING_MICROFRONTEND_URL,
SCRAPE_YOUTUBE_THUMBNAILS_JOB_QUEUE, SCRAPE_YOUTUBE_THUMBNAILS_JOB_QUEUE,
VIDEO_TRANSCRIPT_MIGRATIONS_JOB_QUEUE, VIDEO_TRANSCRIPT_MIGRATIONS_JOB_QUEUE,
UPDATE_SEARCH_INDEX_JOB_QUEUE, UPDATE_SEARCH_INDEX_JOB_QUEUE,

View File

@@ -14,7 +14,7 @@ from openedx.core.djangoapps.content.block_structure.transformer import (
BlockStructureTransformer, BlockStructureTransformer,
FilteringTransformerMixin FilteringTransformerMixin
) )
from xmodule.library_content_block import LibraryContentBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.library_content_block import LegacyLibraryContentBlock # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from ..utils import get_student_module_as_dict from ..utils import get_student_module_as_dict
@@ -47,7 +47,6 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
Collects any information that's necessary to execute this Collects any information that's necessary to execute this
transformer's transform method. transformer's transform method.
""" """
block_structure.request_xblock_fields('mode')
block_structure.request_xblock_fields('max_count') block_structure.request_xblock_fields('max_count')
block_structure.request_xblock_fields('category') block_structure.request_xblock_fields('category')
store = modulestore() store = modulestore()
@@ -83,7 +82,6 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
if library_children: if library_children:
all_library_children.update(library_children) all_library_children.update(library_children)
selected = [] selected = []
mode = block_structure.get_xblock_field(block_key, 'mode')
max_count = block_structure.get_xblock_field(block_key, 'max_count') max_count = block_structure.get_xblock_field(block_key, 'max_count')
if max_count < 0: if max_count < 0:
max_count = len(library_children) max_count = len(library_children)
@@ -100,7 +98,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
# Update selected # Update selected
previous_count = len(selected) previous_count = len(selected)
block_keys = LibraryContentBlock.make_selection(selected, library_children, max_count, mode) block_keys = LegacyLibraryContentBlock.make_selection(selected, library_children, max_count)
selected = block_keys['selected'] selected = block_keys['selected']
# Save back any changes # Save back any changes
@@ -176,7 +174,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
with tracker.get_tracker().context(full_event_name, context): with tracker.get_tracker().context(full_event_name, context):
tracker.emit(full_event_name, event_data) tracker.emit(full_event_name, event_data)
LibraryContentBlock.publish_selected_children_events( LegacyLibraryContentBlock.publish_selected_children_events(
block_keys, block_keys,
format_block_keys, format_block_keys,
publish_event, publish_event,

View File

@@ -45,7 +45,7 @@ from lms.djangoapps.teams.services import TeamsService
from openedx.core.lib.xblock_services.call_to_action import CallToActionService from openedx.core.lib.xblock_services.call_to_action import CallToActionService
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.django import XBlockI18nService, modulestore from xmodule.modulestore.django import XBlockI18nService, modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.partitions.partitions_service import PartitionService from xmodule.partitions.partitions_service import PartitionService
@@ -626,7 +626,7 @@ def prepare_runtime_for_user(
), ),
'completion': CompletionService(user=user, context_key=course_id) if user and user.is_authenticated else None, 'completion': CompletionService(user=user, context_key=course_id) if user and user.is_authenticated else None,
'i18n': XBlockI18nService, 'i18n': XBlockI18nService,
'library_tools': LibraryToolsService(store, user_id=user.id if user else None), 'library_tools': LegacyLibraryToolsService(store, user_id=user.id if user else None),
'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data), 'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data),
'settings': SettingsService(), 'settings': SettingsService(),
'user_tags': UserTagsService(user=user, course_id=course_id), 'user_tags': UserTagsService(user=user, course_id=course_id),

View File

@@ -378,7 +378,7 @@ class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView):
def get(self, request, course_id=None): def get(self, request, course_id=None):
""" """
Get submission history details. This submission history is related to only Get submission history details. This submission history is related to only
ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries ProblemBlock and it doesn't support LegacyLibraryContentBlock or ContentLibraries
as of now. as of now.
**Usecases**: **Usecases**:
@@ -463,7 +463,7 @@ class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView):
@staticmethod @staticmethod
def get_problem_blocks(course): def get_problem_blocks(course):
""" Get a list of problem xblock for the course. """ Get a list of problem xblock for the course.
This doesn't support LibraryContentBlock or ContentLibraries This doesn't support LegacyLibraryContentBlock or ContentLibraries
as of now as of now
""" """
blocks = [] blocks = []

View File

@@ -76,7 +76,6 @@ from opaque_keys.edx.locator import (
LibraryLocator as LibraryLocatorV1, LibraryLocator as LibraryLocatorV1,
LibraryCollectionLocator, LibraryCollectionLocator,
) )
from opaque_keys import InvalidKeyError
from openedx_events.content_authoring.data import ( from openedx_events.content_authoring.data import (
ContentLibraryData, ContentLibraryData,
LibraryBlockData, LibraryBlockData,
@@ -99,10 +98,7 @@ from xblock.exceptions import XBlockNotFoundError
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key, xblock_type_display_name from openedx.core.djangoapps.xblock.api import get_component_from_usage_key, xblock_type_display_name
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from . import permissions, tasks from . import permissions, tasks
from .constants import ALL_RIGHTS_RESERVED, COMPLEX from .constants import ALL_RIGHTS_RESERVED, COMPLEX
@@ -421,8 +417,8 @@ def get_library(library_key):
# updated version of content that a course could pull in. But more recently, # updated version of content that a course could pull in. But more recently,
# we've decided to do those version references at the level of the # we've decided to do those version references at the level of the
# individual blocks being used, since a Learning Core backed library is # individual blocks being used, since a Learning Core backed library is
# intended to be used for many LibraryContentBlocks and not 1:1 like v1 # intended to be referenced in multiple course locations and not 1:1 like v1
# libraries. The top level version stays for now because LibraryContentBlock # libraries. The top level version stays for now because LegacyLibraryContentBlock
# uses it, but that should hopefully change before the Redwood release. # uses it, but that should hopefully change before the Redwood release.
version = 0 if last_publish_log is None else last_publish_log.pk version = 0 if last_publish_log is None else last_publish_log.pk
published_by = None published_by = None
@@ -1340,77 +1336,6 @@ def get_library_collection_from_usage_key(
raise ContentLibraryCollectionNotFound from exc raise ContentLibraryCollectionNotFound from exc
# V1/V2 Compatibility Helpers
# (Should be removed as part of
# https://github.com/openedx/edx-platform/issues/32457)
# ======================================================
def get_v1_or_v2_library(
library_id: str | LibraryLocatorV1 | LibraryLocatorV2,
version: str | int | None,
) -> LibraryRootV1 | ContentLibraryMetadata | None:
"""
Fetch either a V1 or V2 content library from a V1/V2 key (or key string) and version.
V1 library versions are Mongo ObjectID strings.
V2 library versions can be positive ints, or strings of positive ints.
Passing version=None will return the latest version the library.
Returns None if not found.
If key is invalid, raises InvalidKeyError.
For V1, if key has a version, it is ignored in favor of `version`.
For V2, if version is provided but it isn't an int or parseable to one, we raise a ValueError.
Examples:
* get_v1_or_v2_library("library-v1:ProblemX+PR0B", None) -> <LibraryRootV1>
* get_v1_or_v2_library("library-v1:ProblemX+PR0B", "65ff...") -> <LibraryRootV1>
* get_v1_or_v2_library("lib:RG:rg-1", None) -> <ContentLibraryMetadata>
* get_v1_or_v2_library("lib:RG:rg-1", "36") -> <ContentLibraryMetadata>
* get_v1_or_v2_library("lib:RG:rg-1", "xyz") -> <ValueError>
* get_v1_or_v2_library("notakey", "xyz") -> <InvalidKeyError>
If you just want to get a V2 library, use `get_library` instead.
"""
library_key: LibraryLocatorV1 | LibraryLocatorV2
if isinstance(library_id, str):
try:
library_key = LibraryLocatorV1.from_string(library_id)
except InvalidKeyError:
library_key = LibraryLocatorV2.from_string(library_id)
else:
library_key = library_id
if isinstance(library_key, LibraryLocatorV2):
v2_version: int | None
if version:
v2_version = int(version)
else:
v2_version = None
try:
library = get_library(library_key)
if v2_version is not None and library.version != v2_version:
raise NotImplementedError(
f"Tried to load version {v2_version} of learning_core-based library {library_key}. "
f"Currently, only the latest version ({library.version}) may be loaded. "
"This is a known issue. "
"It will be fixed before the production release of learning_core-based (V2) content libraries. "
)
return library
except ContentLibrary.DoesNotExist:
return None
elif isinstance(library_key, LibraryLocatorV1):
v1_version: str | None
if version:
v1_version = str(version)
else:
v1_version = None
store = modulestore()
library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(v1_version)
try:
return store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError:
return None
# Import from Courseware # Import from Courseware
# ====================== # ======================

View File

@@ -21,33 +21,20 @@ import logging
from celery import shared_task from celery import shared_task
from celery_utils.logged_task import LoggedTask from celery_utils.logged_task import LoggedTask
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import PermissionDenied
from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import (
BlockUsageLocator,
LibraryLocatorV2,
LibraryUsageLocatorV2,
LibraryLocator as LibraryLocatorV1
)
from user_tasks.tasks import UserTask, UserTaskStatus from user_tasks.tasks import UserTask, UserTaskStatus
from xblock.fields import Scope from xblock.fields import Scope
from common.djangoapps.student.auth import has_studio_write_access
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content_libraries import api as library_api from opaque_keys.edx.locator import BlockUsageLocator
from openedx.core.djangoapps.xblock.api import load_block
from openedx.core.lib import ensure_cms from openedx.core.lib import ensure_cms
from xmodule.capa_block import ProblemBlock from xmodule.capa_block import ProblemBlock
from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1 from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.mixed import MixedModuleStore from xmodule.modulestore.mixed import MixedModuleStore
from xmodule.modulestore.split_mongo import BlockKey
from xmodule.util.keys import derive_key
from . import api from . import api
from .models import ContentLibraryBlockImportTask from .models import ContentLibraryBlockImportTask
@@ -84,77 +71,6 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_
) )
def _import_block(store, user_id, source_block, dest_parent_key):
"""
Recursively import a learning core block and its children.`
"""
def generate_block_key(source_key, dest_parent_key):
"""
Deterministically generate an ID for the new block and return the key.
Keys are generated such that they appear identical to a v1 library with
the same input block_id, library name, library organization, and parent block using derive_key
"""
if not isinstance(source_key.lib_key, LibraryLocatorV2):
raise TypeError(f"Expected source library key of type LibraryLocatorV2, got {source_key.lib_key} instead.")
source_key_as_v1_course_key = LibraryLocatorV1(
org=source_key.lib_key.org,
library=source_key.lib_key.slug,
branch='library'
)
derived_block_key = derive_key(
source=source_key_as_v1_course_key.make_usage_key(source_key.block_type, source_key.block_id),
dest_parent=BlockKey(dest_parent_key.block_type, dest_parent_key.block_id),
)
return dest_parent_key.context_key.make_usage_key(*derived_block_key)
source_key = source_block.scope_ids.usage_id
new_block_key = generate_block_key(source_key, dest_parent_key)
try:
new_block = store.get_item(new_block_key)
if new_block.parent.block_id != dest_parent_key.block_id:
raise ValueError(
"Expected existing block {} to be a child of {} but instead it's a child of {}".format(
new_block_key, dest_parent_key, new_block.parent,
)
)
except ItemNotFoundError:
new_block = store.create_child(
user_id,
dest_parent_key,
source_key.block_type,
block_id=new_block_key.block_id,
)
# Prepare a list of this block's static assets; any assets that are referenced as /static/{path} (the
# recommended way for referencing them) will stop working, and so we rewrite the url when importing.
# Copying assets not advised because modulestore doesn't namespace assets to each block like learning core, which
# might cause conflicts when the same filename is used across imported blocks.
if isinstance(source_key, LibraryUsageLocatorV2):
all_assets = library_api.get_library_block_static_asset_files(source_key)
else:
all_assets = []
for field_name, field in source_block.fields.items():
if field.scope not in (Scope.settings, Scope.content):
continue # Only copy authored field data
if field.is_set_on(source_block) or field.is_set_on(new_block):
field_value = getattr(source_block, field_name)
setattr(new_block, field_name, field_value)
new_block.save()
store.update_item(new_block, user_id)
if new_block.has_children:
# Delete existing children in the new block, which can be reimported again if they still exist in the
# source library
for existing_child_key in new_block.children:
store.delete_item(existing_child_key, user_id)
# Now import the children
for child in source_block.get_children():
_import_block(store, user_id, child, new_block_key)
return new_block_key
def _filter_child(store, usage_key, capa_type): def _filter_child(store, usage_key, capa_type):
""" """
Return whether this block is both a problem and has a `capa_type` which is included in the filter. Return whether this block is both a problem and has a `capa_type` which is included in the filter.
@@ -172,49 +88,6 @@ def _problem_type_filter(store, library, capa_type):
return [key for key in library.children if _filter_child(store, key, capa_type)] return [key for key in library.children if _filter_child(store, key, capa_type)]
def _import_from_learning_core(user_id, store, dest_block, source_block_ids):
"""
Imports a block from a learning-core-based learning context (usually a
content library) into modulestore, as a new child of dest_block.
Any existing children of dest_block are replaced.
"""
dest_key = dest_block.scope_ids.usage_id
if not isinstance(dest_key, BlockUsageLocator):
raise TypeError(f"Destination {dest_key} should be a modulestore course.")
if user_id is None:
raise ValueError("Cannot check user permissions - LibraryTools user_id is None")
if len(set(source_block_ids)) != len(source_block_ids):
# We don't support importing the exact same block twice because it would break the way we generate new IDs
# for each block and then overwrite existing copies of blocks when re-importing the same blocks.
raise ValueError("One or more library component IDs is a duplicate.")
dest_course_key = dest_key.context_key
user = User.objects.get(id=user_id)
if not has_studio_write_access(user, dest_course_key):
raise PermissionDenied()
# Read the source block; this will also confirm that user has permission to read it.
# (This could be slow and use lots of memory, except for the fact that LibraryContentBlock which calls this
# should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a
# time in order to raise any errors before we start actually copying blocks over.)
orig_blocks = [load_block(UsageKey.from_string(key), user) for key in source_block_ids]
with store.bulk_operations(dest_course_key):
child_ids_updated = set()
for block in orig_blocks:
new_block_id = _import_block(store, user_id, block, dest_key)
child_ids_updated.add(new_block_id)
# Remove any existing children that are no longer used
for old_child_id in set(dest_block.children) - child_ids_updated:
store.delete_item(old_child_id, user_id)
# If this was called from a handler, it will save dest_block at the end, so we must update
# dest_block.children to avoid it saving the old value of children and deleting the new ones.
dest_block.children = store.get_item(dest_key).children
class LibrarySyncChildrenTask(UserTask): # pylint: disable=abstract-method class LibrarySyncChildrenTask(UserTask): # pylint: disable=abstract-method
""" """
Base class for tasks which operate upon library_content children. Base class for tasks which operate upon library_content children.
@@ -244,7 +117,7 @@ def sync_from_library(
self: LibrarySyncChildrenTask, self: LibrarySyncChildrenTask,
user_id: int, user_id: int,
dest_block_id: str, dest_block_id: str,
library_version: str | int | None, library_version: str | None,
) -> None: ) -> None:
""" """
Celery task to update the children of the library_content block at `dest_block_id`. Celery task to update the children of the library_content block at `dest_block_id`.
@@ -300,8 +173,8 @@ def _sync_children(
task: LibrarySyncChildrenTask, task: LibrarySyncChildrenTask,
store: MixedModuleStore, store: MixedModuleStore,
user_id: int, user_id: int,
dest_block: LibraryContentBlock, dest_block: LegacyLibraryContentBlock,
library_version: int | str | None, library_version: str | None,
) -> None: ) -> None:
""" """
Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks. Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks.
@@ -309,41 +182,29 @@ def _sync_children(
Can update children with a specific library `library_version`, or latest (`library_version=None`). Can update children with a specific library `library_version`, or latest (`library_version=None`).
""" """
source_blocks = [] source_blocks = []
library_key = dest_block.source_library_key library_key = dest_block.source_library_key.for_branch(
filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE) ModuleStoreEnum.BranchName.library
library = library_api.get_v1_or_v2_library(library_key, version=library_version) ).for_version(library_version)
if not library: try:
library = store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError:
task.status.fail(f"Requested library {library_key} not found.") task.status.fail(f"Requested library {library_key} not found.")
elif isinstance(library, LibraryRootV1): return
if filter_children: filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
# Apply simple filtering based on CAPA problem types: if filter_children:
source_blocks.extend(_problem_type_filter(store, library, dest_block.capa_type)) # Apply simple filtering based on CAPA problem types:
else: source_blocks.extend(_problem_type_filter(store, library, dest_block.capa_type))
source_blocks.extend(library.children) else:
with store.bulk_operations(dest_block.scope_ids.usage_id.context_key): source_blocks.extend(library.children)
try: with store.bulk_operations(dest_block.scope_ids.usage_id.context_key):
dest_block.source_library_version = str(library.location.library_key.version_guid)
store.update_item(dest_block, user_id)
dest_block.children = store.copy_from_template(
source_blocks, dest_block.location, user_id, head_validation=True
)
# ^-- copy_from_template updates the children in the DB
# but we must also set .children here to avoid overwriting the DB again
except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
if task.status.state != UserTaskStatus.FAILED:
task.status.fail({'raw_error_msg': str(exception)})
raise
elif isinstance(library, library_api.ContentLibraryMetadata):
# TODO: add filtering by capa_type when V2 library will support different problem types
try: try:
source_block_ids = [ dest_block.source_library_version = str(library.location.library_key.version_guid)
str(library_api.LibraryXBlockMetadata.from_component(library_key, component).usage_key)
for component in library_api.get_library_components(library_key)
]
_import_from_learning_core(user_id, store, dest_block, source_block_ids)
dest_block.source_library_version = str(library.version)
store.update_item(dest_block, user_id) store.update_item(dest_block, user_id)
dest_block.children = store.copy_from_template(
source_blocks, dest_block.location, user_id, head_validation=True
)
# ^-- copy_from_template updates the children in the DB
# but we must also set .children here to avoid overwriting the DB again
except Exception as exception: # pylint: disable=broad-except except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True) TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
if task.status.state != UserTaskStatus.FAILED: if task.status.state != UserTaskStatus.FAILED:
@@ -354,8 +215,8 @@ def _sync_children(
def _copy_overrides( def _copy_overrides(
store: MixedModuleStore, store: MixedModuleStore,
user_id: int, user_id: int,
source_block: LibraryContentBlock, source_block: LegacyLibraryContentBlock,
dest_block: LibraryContentBlock dest_block: LegacyLibraryContentBlock
) -> None: ) -> None:
""" """
Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively. Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively.

View File

@@ -452,7 +452,7 @@ def xblock_resource_pkg(block):
ProblemBlock, and most other built-in blocks currently. Handling for these ProblemBlock, and most other built-in blocks currently. Handling for these
assets does not interact with this function. assets does not interact with this function.
2. The (preferred) standard XBlock runtime resource loading system, used by 2. The (preferred) standard XBlock runtime resource loading system, used by
LibraryContentBlock. Handling for these assets *does* interact with this LegacyLibraryContentBlock. Handling for these assets *does* interact with this
function. function.
We hope to migrate to (2) eventually, tracked by: We hope to migrate to (2) eventually, tracked by:

View File

@@ -10,7 +10,7 @@ from completion.test_utils import CompletionWaffleTestMixin
from django.conf import settings from django.conf import settings
from django.test import override_settings from django.test import override_settings
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory
from xmodule.tests import prepare_block_runtime from xmodule.tests import prepare_block_runtime
@@ -122,7 +122,7 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
Bind a block (part of self.course) so we can access student-specific data. Bind a block (part of self.course) so we can access student-specific data.
""" """
prepare_block_runtime(block.runtime, course_id=block.location.course_key) prepare_block_runtime(block.runtime, course_id=block.location.course_key)
block.runtime._services.update({'library_tools': LibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access block.runtime._services.update({'library_tools': LegacyLibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access
def get_block(descriptor): def get_block(descriptor):
"""Mocks module_system get_block_for_descriptor function""" """Mocks module_system get_block_for_descriptor function"""

View File

@@ -21,7 +21,7 @@ XBLOCKS = [
"html = xmodule.html_block:HtmlBlock", "html = xmodule.html_block:HtmlBlock",
"image = xmodule.template_block:TranslateCustomTagBlock", "image = xmodule.template_block:TranslateCustomTagBlock",
"library = xmodule.library_root_xblock:LibraryRoot", "library = xmodule.library_root_xblock:LibraryRoot",
"library_content = xmodule.library_content_block:LibraryContentBlock", "library_content = xmodule.library_content_block:LegacyLibraryContentBlock",
"lti = xmodule.lti_block:LTIBlock", "lti = xmodule.lti_block:LTIBlock",
"poll_question = xmodule.poll_block:PollBlock", "poll_question = xmodule.poll_block:PollBlock",
"problem = xmodule.capa_block:ProblemBlock", "problem = xmodule.capa_block:ProblemBlock",

View File

@@ -38,6 +38,10 @@ module.exports = {
'./xmodule/js/src/xmodule.js', './xmodule/js/src/xmodule.js',
'./xmodule/js/src/html/edit.js' './xmodule/js/src/html/edit.js'
], ],
LibraryContentBlockEditor: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/vertical/edit.js'
],
LTIBlockDisplay: [ LTIBlockDisplay: [
'./xmodule/js/src/xmodule.js', './xmodule/js/src/xmodule.js',
'./xmodule/js/src/lti/lti.js' './xmodule/js/src/lti/lti.js'
@@ -46,11 +50,6 @@ module.exports = {
'./xmodule/js/src/xmodule.js', './xmodule/js/src/xmodule.js',
'./xmodule/js/src/raw/edit/metadata-only.js' './xmodule/js/src/raw/edit/metadata-only.js'
], ],
LibraryContentBlockDisplay: './xmodule/js/src/xmodule.js',
LibraryContentBlockEditor: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/vertical/edit.js'
],
PollBlockDisplay: [ PollBlockDisplay: [
'./xmodule/js/src/xmodule.js', './xmodule/js/src/xmodule.js',
'./xmodule/js/src/javascript_loader.js', './xmodule/js/src/javascript_loader.js',

View File

@@ -1,54 +0,0 @@
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
// This is a temporary UI improvements that will be removed when V2 content libraries became
// fully functional
/**
* Toggle the "Problem Type" settings section depending on selected library type.
* As for now, the V2 libraries don't support different problem types, so they can't be
* filtered by it. We're hiding the Problem Type field for them.
*/
function checkProblemTypeShouldBeVisible(editor) {
var libraries = editor.find('.wrapper-comp-settings.metadata_edit.is-active')
.data().metadata.source_library_id.options;
var selectedIndex = $("select[name='Library']", editor)[0].selectedIndex;
var libraryKey = libraries[selectedIndex].value;
var url = URI('/xblock')
.segment(editor.find('.xblock.xblock-studio_view.xblock-studio_view-library_content.xblock-initialized')
.data('usage-id'))
.segment('handler')
.segment('is_v2_library');
$.ajax({
type: 'POST',
url: url,
data: JSON.stringify({'library_key': libraryKey}),
success: function(data) {
var problemTypeSelect = editor.find("select[name='Problem Type']")
.parents("li.field.comp-setting-entry.metadata_entry");
data.is_v2 ? problemTypeSelect.hide() : problemTypeSelect.show();
}
});
}
/**
* Waits untill editor html loaded, than calls checks for Program Type field toggling.
*/
function waitForEditorLoading() {
var checkContent = setInterval(function() {
var $modal = $('.xblock-editor');
var content = $modal.html();
if (content) {
clearInterval(checkContent);
checkProblemTypeShouldBeVisible($modal);
}
}, 10);
}
// Initial call
waitForEditorLoading();
var $librarySelect = $("select[name='Library']");
$(document).on('change', $librarySelect, waitForEditorLoading)
var $libraryContentEditors = $('.xblock-header.xblock-header-library_content');
var $editBtns = $libraryContentEditors.find('.action-item.action-edit');
$(document).on('click', $editBtns, waitForEditorLoading)

View File

@@ -1,5 +1,12 @@
""" """
LibraryContent: The XBlock used to include blocks from a library in a course. LegacyLibraryContent: The XBlock used to randomly select a subset of blocks from a "v1" (modulestore-backed) library.
In Studio, it's called the "Randomized Content Module".
In the long-term, this block is deprecated in favor of "v2" (learning core-backed) library references:
https://github.com/openedx/edx-platform/issues/32457
We need to retain backwards-compatibility, but please do not build any new features into this.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,8 +22,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.utils.functional import classproperty from django.utils.functional import classproperty
from lxml import etree from lxml import etree
from lxml.etree import XMLSyntaxError from lxml.etree import XMLSyntaxError
from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocator
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
from rest_framework import status from rest_framework import status
from web_fragments.fragment import Fragment from web_fragments.fragment import Fragment
from webob import Response from webob import Response
@@ -78,7 +84,7 @@ class LibraryToolsUnavailable(ValueError):
@XBlock.wants('studio_user_permissions') # Only available in CMS. @XBlock.wants('studio_user_permissions') # Only available in CMS.
@XBlock.wants('user') @XBlock.wants('user')
@XBlock.needs('mako') @XBlock.needs('mako')
class LibraryContentBlock( class LegacyLibraryContentBlock(
MakoTemplateBlockBase, MakoTemplateBlockBase,
XmlMixin, XmlMixin,
XModuleToXBlockMixin, XModuleToXBlockMixin,
@@ -87,7 +93,7 @@ class LibraryContentBlock(
StudioEditableBlock, StudioEditableBlock,
): ):
""" """
An XBlock whose children are chosen dynamically from a content library. An XBlock whose children are chosen dynamically from a legacy (v1) content library.
Can be used to create randomized assessments among other things. Can be used to create randomized assessments among other things.
Note: technically, all matching blocks from the content library are added Note: technically, all matching blocks from the content library are added
@@ -135,17 +141,6 @@ class LibraryContentBlock(
display_name=_("Library Version"), display_name=_("Library Version"),
scope=Scope.settings, scope=Scope.settings,
) )
mode = String(
display_name=_("Mode"),
help=_("Determines how content is drawn from the library"),
default="random",
values=[
{"display_name": _("Choose n at random"), "value": "random"}
# Future addition: Choose a new random set of n every time the student refreshes the block, for self tests
# Future addition: manually selected blocks
],
scope=Scope.settings,
)
max_count = Integer( max_count = Integer(
display_name=_("Count"), display_name=_("Count"),
help=_("Enter the number of components to display to each student. Set it to -1 to display all components."), help=_("Enter the number of components to display to each student. Set it to -1 to display all components."),
@@ -179,15 +174,12 @@ class LibraryContentBlock(
""" """
Convenience method to get the library ID as a LibraryLocator and not just a string. Convenience method to get the library ID as a LibraryLocator and not just a string.
Supports either library v1 or library v2 locators. Supports only v1 libraries.
""" """
try: return LibraryLocator.from_string(self.source_library_id)
return LibraryLocator.from_string(self.source_library_id)
except InvalidKeyError:
return LibraryLocatorV2.from_string(self.source_library_id)
@classmethod @classmethod
def make_selection(cls, selected, children, max_count, mode): def make_selection(cls, selected, children, max_count):
""" """
Dynamically selects block_ids indicating which of the possible children are displayed to the current user. Dynamically selects block_ids indicating which of the possible children are displayed to the current user.
@@ -195,7 +187,6 @@ class LibraryContentBlock(
selected - list of (block_type, block_id) tuples assigned to this student selected - list of (block_type, block_id) tuples assigned to this student
children - children of this block children - children of this block
max_count - number of components to display to each student max_count - number of components to display to each student
mode - how content is drawn from the library
Returns: Returns:
A dict containing the following keys: A dict containing the following keys:
@@ -231,12 +222,9 @@ class LibraryContentBlock(
if num_to_add > 0: if num_to_add > 0:
# We need to select [more] blocks to display to this user: # We need to select [more] blocks to display to this user:
pool = valid_block_keys - selected_keys pool = valid_block_keys - selected_keys
if mode == "random": num_to_add = min(len(pool), num_to_add)
num_to_add = min(len(pool), num_to_add) added_block_keys = set(rand.sample(list(pool), num_to_add))
added_block_keys = set(rand.sample(list(pool), num_to_add)) # We now have the correct n random children to show for this user.
# We now have the correct n random children to show for this user.
else:
raise NotImplementedError("Unsupported mode.")
selected_keys |= added_block_keys selected_keys |= added_block_keys
if any((invalid_block_keys, overlimit_block_keys, added_block_keys)): if any((invalid_block_keys, overlimit_block_keys, added_block_keys)):
@@ -334,7 +322,7 @@ class LibraryContentBlock(
if max_count < 0: if max_count < 0:
max_count = len(self.children) max_count = len(self.children)
block_keys = self.make_selection(self.selected, self.children, max_count, "random") # pylint: disable=no-member block_keys = self.make_selection(self.selected, self.children, max_count) # pylint: disable=no-member
# Publish events for analytics purposes: # Publish events for analytics purposes:
lib_tools = self.get_tools() lib_tools = self.get_tools()
@@ -467,7 +455,6 @@ class LibraryContentBlock(
fragment = Fragment( fragment = Fragment(
self.runtime.service(self, 'mako').render_cms_template(self.mako_template, self.get_context()) self.runtime.service(self, 'mako').render_cms_template(self.mako_template, self.get_context())
) )
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit_helpers.js'))
add_webpack_js_to_fragment(fragment, 'LibraryContentBlockEditor') add_webpack_js_to_fragment(fragment, 'LibraryContentBlockEditor')
shim_xmodule_js(fragment, self.studio_js_module_name) shim_xmodule_js(fragment, self.studio_js_module_name)
return fragment return fragment
@@ -481,16 +468,12 @@ class LibraryContentBlock(
@property @property
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
non_editable_fields = super().non_editable_metadata_fields non_editable_fields = super().non_editable_metadata_fields
# The only supported mode is currently 'random'.
# Add the mode field to non_editable_metadata_fields so that it doesn't
# render in the edit form.
non_editable_fields.extend([ non_editable_fields.extend([
LibraryContentBlock.mode, LegacyLibraryContentBlock.source_library_version,
LibraryContentBlock.source_library_version,
]) ])
return non_editable_fields return non_editable_fields
def get_tools(self, to_read_library_content: bool = False) -> 'LibraryToolsService': def get_tools(self, to_read_library_content: bool = False) -> 'LegacyLibraryToolsService':
""" """
Grab the library tools service and confirm that it'll work for us. Else, raise LibraryToolsUnavailable. Grab the library tools service and confirm that it'll work for us. Else, raise LibraryToolsUnavailable.
""" """
@@ -564,22 +547,6 @@ class LibraryContentBlock(
library_version=(None if upgrade_to_latest else self.source_library_version), library_version=(None if upgrade_to_latest else self.source_library_version),
) )
@XBlock.json_handler
def is_v2_library(self, data, suffix=''): # pylint: disable=unused-argument
"""
Check the library version by library_id.
This is a temporary handler needed for hiding the Problem Type xblock editor field for V2 libraries.
"""
lib_key = data.get('library_key')
try:
LibraryLocatorV2.from_string(lib_key)
except InvalidKeyError:
is_v2 = False
else:
is_v2 = True
return {'is_v2': is_v2}
@XBlock.handler @XBlock.handler
def children_are_syncing(self, request, suffix=''): # pylint: disable=unused-argument def children_are_syncing(self, request, suffix=''): # pylint: disable=unused-argument
""" """
@@ -809,14 +776,14 @@ class LibraryContentBlock(
return xml_object return xml_object
class LibrarySummary: class LegacyLibrarySummary:
""" """
A library summary object which contains the fields required for library listing on studio. A library summary object which contains the fields required for library listing on studio.
""" """
def __init__(self, library_locator, display_name): def __init__(self, library_locator, display_name):
""" """
Initialize LibrarySummary Initialize LegacyLibrarySummary
Arguments: Arguments:
library_locator (LibraryLocator): LibraryLocator object of the library. library_locator (LibraryLocator): LibraryLocator object of the library.

View File

@@ -1,18 +1,17 @@
""" """
XBlock runtime services for LibraryContentBlock XBlock runtime services for LegacyLibraryContentBlock
""" """
from __future__ import annotations from __future__ import annotations
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from opaque_keys.edx.locator import LibraryLocator
from user_tasks.models import UserTaskStatus from user_tasks.models import UserTaskStatus
from openedx.core.lib import ensure_cms from openedx.core.lib import ensure_cms
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_libraries import tasks as library_tasks from openedx.core.djangoapps.content_libraries import tasks as library_tasks
from xmodule.library_content_block import LibraryContentBlock from xmodule.library_content_block import LegacyLibraryContentBlock
from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1 from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -21,9 +20,9 @@ def normalize_key_for_search(library_key):
return library_key.replace(version_guid=None, branch=None) return library_key.replace(version_guid=None, branch=None)
class LibraryToolsService: class LegacyLibraryToolsService:
""" """
Service for LibraryContentBlock. Service for LegacyLibraryContentBlock.
Allows to interact with libraries in the modulestore and learning core. Allows to interact with libraries in the modulestore and learning core.
@@ -33,24 +32,31 @@ class LibraryToolsService:
self.store = modulestore self.store = modulestore
self.user_id = user_id self.user_id = user_id
def get_latest_library_version(self, lib_key) -> str | None: def get_latest_library_version(self, library_id: str | LibraryLocator) -> str | None:
""" """
Get the version of the given library as string. Get the version of the given library as string.
The return value (library version) could be: The return value (library version) could be:
str(<ObjectID>) - for V1 library; str(<ObjectID>) - for V1 library;
str(<int>) - for V2 library.
None - if the library does not exist. None - if the library does not exist.
""" """
library = library_api.get_v1_or_v2_library(lib_key, version=None) library_key: LibraryLocator
if isinstance(library_id, str):
library_key = LibraryLocator.from_string(library_id)
else:
library_key = library_id
library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(None)
try:
library = self.store.get_library(
library_key, remove_version=False, remove_branch=False, head_validation=False
)
except ItemNotFoundError:
return None
if not library: if not library:
return None return None
elif isinstance(library, LibraryRootV1): # We need to know the library's version so ensure it's set in library.location.library_key.version_guid
# We need to know the library's version so ensure it's set in library.location.library_key.version_guid assert library.location.library_key.version_guid is not None
assert library.location.library_key.version_guid is not None return str(library.location.library_key.version_guid)
return str(library.location.library_key.version_guid)
elif isinstance(library, library_api.ContentLibraryMetadata):
return str(library.version)
def create_block_analytics_summary(self, course_key, block_keys): def create_block_analytics_summary(self, course_key, block_keys):
""" """
@@ -96,7 +102,7 @@ class LibraryToolsService:
""" """
return self.store.check_supports(block.location.course_key, 'copy_from_template') return self.store.check_supports(block.location.course_key, 'copy_from_template')
def trigger_library_sync(self, dest_block: LibraryContentBlock, library_version: str | int | None) -> None: def trigger_library_sync(self, dest_block: LegacyLibraryContentBlock, library_version: str | None) -> None:
""" """
Queue task to synchronize the children of `dest_block` with it source library (at `library_version` or latest). Queue task to synchronize the children of `dest_block` with it source library (at `library_version` or latest).
@@ -118,16 +124,20 @@ class LibraryToolsService:
`dest_block.children`. `dest_block.children`.
""" """
ensure_cms("library_content block children may only be synced in a CMS context") ensure_cms("library_content block children may only be synced in a CMS context")
if not isinstance(dest_block, LibraryContentBlock): if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only sync children for library_content blocks, not {dest_block.tag} blocks.") raise ValueError(f"Can only sync children for library_content blocks, not {dest_block.tag} blocks.")
if not dest_block.source_library_id: if not dest_block.source_library_id:
dest_block.source_library_version = "" dest_block.source_library_version = ""
return return
library_key = dest_block.source_library_key library_key = dest_block.source_library_key.for_branch(
if not library_api.get_v1_or_v2_library(library_key, version=library_version): ModuleStoreEnum.BranchName.library
).for_version(library_version)
try:
self.store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
except ItemNotFoundError as exc:
if library_version: if library_version:
raise ObjectDoesNotExist(f"Version {library_version} of library {library_key} not found.") raise ObjectDoesNotExist(f"Version {library_version} of library {library_key} not found.") from exc
raise ObjectDoesNotExist(f"Library {library_key} not found.") raise ObjectDoesNotExist(f"Library {library_key} not found.") from exc
# TODO: This task is synchronous until we can figure out race conditions with import. # TODO: This task is synchronous until we can figure out race conditions with import.
# These race conditions lead to failed imports of library content from course import. # These race conditions lead to failed imports of library content from course import.
@@ -140,12 +150,14 @@ class LibraryToolsService:
), ),
) )
def trigger_duplication(self, source_block: LibraryContentBlock, dest_block: LibraryContentBlock) -> None: def trigger_duplication(
self, source_block: LegacyLibraryContentBlock, dest_block: LegacyLibraryContentBlock
) -> None:
""" """
Queue a task to duplicate the children of `source_block` to `dest_block`. Queue a task to duplicate the children of `source_block` to `dest_block`.
""" """
ensure_cms("library_content block children may only be duplicated in a CMS context") ensure_cms("library_content block children may only be duplicated in a CMS context")
if not isinstance(dest_block, LibraryContentBlock): if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only duplicate children for library_content blocks, not {dest_block.tag} blocks.") raise ValueError(f"Can only duplicate children for library_content blocks, not {dest_block.tag} blocks.")
if source_block.scope_ids.usage_id.context_key != source_block.scope_ids.usage_id.context_key: if source_block.scope_ids.usage_id.context_key != source_block.scope_ids.usage_id.context_key:
raise ValueError( raise ValueError(
@@ -163,7 +175,7 @@ class LibraryToolsService:
dest_block_id=str(dest_block.scope_ids.usage_id), dest_block_id=str(dest_block.scope_ids.usage_id),
) )
def are_children_syncing(self, library_content_block: LibraryContentBlock) -> bool: def are_children_syncing(self, library_content_block: LegacyLibraryContentBlock) -> bool:
""" """
Is a task currently running to sync the children of `library_content_block`? Is a task currently running to sync the children of `library_content_block`?
@@ -179,21 +191,12 @@ class LibraryToolsService:
def list_available_libraries(self): def list_available_libraries(self):
""" """
List all known libraries. List all known legacy libraries.
Collects Only V2 Libaries if the FEATURES[ENABLE_LIBRARY_AUTHORING_MICROFRONTEND] setting is True.
Otherwise, return all v1 and v2 libraries.
Returns tuples of (library key, display_name). Returns tuples of (library key, display_name).
""" """
user = User.objects.get(id=self.user_id) user = User.objects.get(id=self.user_id)
v1_libs = [ return [
(lib.location.library_key.replace(version_guid=None, branch=None), lib.display_name) (lib.location.library_key.replace(version_guid=None, branch=None), lib.display_name)
for lib in self.store.get_library_summaries() for lib in self.store.get_library_summaries()
] ]
v2_query = library_api.get_libraries_for_user(user)
v2_libs_with_meta = library_api.get_metadata(v2_query)
v2_libs = [(lib.key, lib.title) for lib in v2_libs_with_meta]
if settings.FEATURES.get('ENABLE_LIBRARY_AUTHORING_MICROFRONTEND'):
return v2_libs
return v1_libs + v2_libs

View File

@@ -343,7 +343,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
@strip_key @strip_key
def get_library_summaries(self, **kwargs): def get_library_summaries(self, **kwargs):
""" """
Returns a list of LibrarySummary objects. Returns a list of LegacyLibrarySummary objects.
Information contains `location`, `display_name`, `locator` of the libraries in this modulestore. Information contains `location`, `display_name`, `locator` of the libraries in this modulestore.
""" """
library_summaries = {} library_summaries = {}

View File

@@ -13,7 +13,7 @@ from xblock.runtime import KeyValueStore, KvsFieldData
from xmodule.error_block import ErrorBlock from xmodule.error_block import ErrorBlock
from xmodule.errortracker import exc_info_to_str from xmodule.errortracker import exc_info_to_str
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.mako_block import MakoDescriptorSystem from xmodule.mako_block import MakoDescriptorSystem
from xmodule.modulestore import BlockData from xmodule.modulestore import BlockData
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
@@ -78,7 +78,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): # li
user = get_current_user() user = get_current_user()
user_id = user.id if user else None user_id = user.id if user else None
self._services['library_tools'] = LibraryToolsService(modulestore, user_id=user_id) self._services['library_tools'] = LegacyLibraryToolsService(modulestore, user_id=user_id)
# Cache of block field datas, keyed by the XBlock instance (since the ScopeId changes!) # Cache of block field datas, keyed by the XBlock instance (since the ScopeId changes!)
self.block_field_datas = weakref.WeakKeyDictionary() self.block_field_datas = weakref.WeakKeyDictionary()

View File

@@ -81,7 +81,7 @@ from xmodule.assetstore import AssetMetadata
from xmodule.course_block import CourseSummary from xmodule.course_block import CourseSummary
from xmodule.error_block import ErrorBlock from xmodule.error_block import ErrorBlock
from xmodule.errortracker import null_error_tracker from xmodule.errortracker import null_error_tracker
from xmodule.library_content_block import LibrarySummary from xmodule.library_content_block import LegacyLibrarySummary
from xmodule.modulestore import ( from xmodule.modulestore import (
BlockData, BlockData,
BulkOperationsMixin, BulkOperationsMixin,
@@ -1029,7 +1029,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
@autoretry_read() @autoretry_read()
def get_library_summaries(self, **kwargs): def get_library_summaries(self, **kwargs):
""" """
Returns a list of `LibrarySummary` objects. Returns a list of `LegacyLibrarySummary` objects.
kwargs can be valid db fields to match against active_versions kwargs can be valid db fields to match against active_versions
collection e.g org='example_org'. collection e.g org='example_org'.
""" """
@@ -1057,7 +1057,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
display_name = library_block_fields['display_name'] display_name = library_block_fields['display_name']
libraries_summaries.append( libraries_summaries.append(
LibrarySummary(library_locator, display_name) LegacyLibrarySummary(library_locator, display_name)
) )
return libraries_summaries return libraries_summaries

View File

@@ -1,7 +1,5 @@
""" """
Basic unit tests for LibraryContentBlock Basic unit tests for LegacyLibraryContentBlock
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
""" """
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
@@ -9,15 +7,15 @@ import ddt
from bson.objectid import ObjectId from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
from lxml import etree from lxml import etree
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 from opaque_keys.edx.locator import LibraryLocator
from rest_framework import status from rest_framework import status
from search.search_engine_base import SearchEngine from search.search_engine_base import SearchEngine
from web_fragments.fragment import Fragment from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime from xblock.runtime import Runtime as VanillaRuntime
from openedx.core.djangolib.testing.utils import skip_unless_cms from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.modulestore.tests.utils import MixedSplitTestCase
@@ -33,15 +31,15 @@ dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-
@skip_unless_cms @skip_unless_cms
class LibraryContentTest(MixedSplitTestCase): class LegacyLibraryContentTest(MixedSplitTestCase):
""" """
Base class for tests of LibraryContentBlock (library_content_block.py) Base class for tests of LegacyLibraryContentBlock (library_content_block.py)
""" """
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.user_id = UserFactory().id self.user_id = UserFactory().id
self.tools = LibraryToolsService(self.store, self.user_id) self.tools = LegacyLibraryToolsService(self.store, self.user_id)
self.library = LibraryFactory.create(modulestore=self.store) self.library = LibraryFactory.create(modulestore=self.store)
self.lib_blocks = [ self.lib_blocks = [
self.make_block("html", self.library, data=f"Hello world from block {i}") self.make_block("html", self.library, data=f"Hello world from block {i}")
@@ -88,29 +86,22 @@ class LibraryContentTest(MixedSplitTestCase):
@ddt.ddt @ddt.ddt
class LibraryContentGeneralTest(LibraryContentTest): class LegacyLibraryContentGeneralTest(LegacyLibraryContentTest):
""" """
Test the base functionality of the LibraryContentBlock. Test the base functionality of the LegacyLibraryContentBlock.
""" """
@ddt.data( def test_source_library_key(self):
('library-v1:ProblemX+PR0B', LibraryLocator),
('lib:ORG:test-1', LibraryLocatorV2)
)
@ddt.unpack
def test_source_library_key(self, library_key, expected_locator_type):
""" """
Test the source_library_key property of the xblock. Test the source_library_key property of the xblock.
The method should correctly work either with V1 or V2 libraries.
""" """
library = self.make_block( library = self.make_block(
"library_content", "library_content",
self.vertical, self.vertical,
max_count=1, max_count=1,
source_library_id=library_key source_library_id='library-v1:ProblemX+PR0B',
) )
assert isinstance(library.source_library_key, expected_locator_type) assert isinstance(library.source_library_key, LibraryLocator)
def test_initial_sync_from_library(self): def test_initial_sync_from_library(self):
""" """
@@ -133,9 +124,9 @@ class LibraryContentGeneralTest(LibraryContentTest):
assert len(self.lc_block.children) == len(self.lib_blocks) assert len(self.lc_block.children) == len(self.lib_blocks)
class TestLibraryContentExportImport(LibraryContentTest): class TestLibraryContentExportImport(LegacyLibraryContentTest):
""" """
Export and import tests for LibraryContentBlock Export and import tests for LegacyLibraryContentBlock
""" """
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -173,7 +164,6 @@ class TestLibraryContentExportImport(LibraryContentTest):
assert imported_lc_block.display_name == self.lc_block.display_name assert imported_lc_block.display_name == self.lc_block.display_name
assert imported_lc_block.source_library_id == self.lc_block.source_library_id assert imported_lc_block.source_library_id == self.lc_block.source_library_id
assert imported_lc_block.source_library_version == self.lc_block.source_library_version assert imported_lc_block.source_library_version == self.lc_block.source_library_version
assert imported_lc_block.mode == self.lc_block.mode
assert imported_lc_block.max_count == self.lc_block.max_count assert imported_lc_block.max_count == self.lc_block.max_count
assert imported_lc_block.capa_type == self.lc_block.capa_type assert imported_lc_block.capa_type == self.lc_block.capa_type
assert len(imported_lc_block.children) == len(self.lc_block.children) assert len(imported_lc_block.children) == len(self.lc_block.children)
@@ -195,13 +185,13 @@ class TestLibraryContentExportImport(LibraryContentTest):
# Now import it. # Now import it.
olx_element = etree.fromstring(exported_olx) olx_element = etree.fromstring(exported_olx)
imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None) imported_lc_block = LegacyLibraryContentBlock.parse_xml(olx_element, self.runtime, None)
self._verify_xblock_properties(imported_lc_block) self._verify_xblock_properties(imported_lc_block)
def test_xml_import_with_comments(self): def test_xml_import_with_comments(self):
""" """
Test that XML comments within LibraryContentBlock are ignored during the import. Test that XML comments within LegacyLibraryContentBlock are ignored during the import.
""" """
olx_with_comments = ( olx_with_comments = (
'<!-- Comment -->\n' '<!-- Comment -->\n'
@@ -219,15 +209,15 @@ class TestLibraryContentExportImport(LibraryContentTest):
# Import the olx. # Import the olx.
olx_element = etree.fromstring(olx_with_comments) olx_element = etree.fromstring(olx_with_comments)
imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None) imported_lc_block = LegacyLibraryContentBlock.parse_xml(olx_element, self.runtime, None)
self._verify_xblock_properties(imported_lc_block) self._verify_xblock_properties(imported_lc_block)
@ddt.ddt @ddt.ddt
class LibraryContentBlockTestMixin: class LegacyLibraryContentBlockTestMixin:
""" """
Basic unit tests for LibraryContentBlock Basic unit tests for LegacyLibraryContentBlock
""" """
problem_types = [ problem_types = [
["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"], ["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
@@ -424,8 +414,7 @@ class LibraryContentBlockTestMixin:
Test the settings that are marked as "non-editable". Test the settings that are marked as "non-editable".
""" """
non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields
assert LibraryContentBlock.mode in non_editable_metadata_fields assert LegacyLibraryContentBlock.display_name not in non_editable_metadata_fields
assert LibraryContentBlock.display_name not in non_editable_metadata_fields
def test_overlimit_blocks_chosen_randomly(self): def test_overlimit_blocks_chosen_randomly(self):
""" """
@@ -503,7 +492,7 @@ search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name
@patch.object(SearchEngine, 'get_search_engine', Mock(return_value=None, autospec=True)) @patch.object(SearchEngine, 'get_search_engine', Mock(return_value=None, autospec=True))
class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest): class TestLegacyLibraryContentBlockWithSearchIndex(LegacyLibraryContentBlockTestMixin, LegacyLibraryContentTest):
""" """
Tests for library container with mocked search engine response. Tests for library container with mocked search engine response.
""" """
@@ -532,9 +521,9 @@ class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, Libra
) )
@patch('xmodule.html_block.HtmlBlock.author_view', dummy_render, create=True) @patch('xmodule.html_block.HtmlBlock.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []) @patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
class TestLibraryContentRender(LibraryContentTest): class TestLibraryContentRender(LegacyLibraryContentTest):
""" """
Rendering unit tests for LibraryContentBlock Rendering unit tests for LegacyLibraryContentBlock
""" """
def setUp(self): def setUp(self):
@@ -559,9 +548,9 @@ class TestLibraryContentRender(LibraryContentTest):
# but some js initialization should happen # but some js initialization should happen
class TestLibraryContentAnalytics(LibraryContentTest): class TestLibraryContentAnalytics(LegacyLibraryContentTest):
""" """
Test analytics features of LibraryContentBlock Test analytics features of LegacyLibraryContentBlock
""" """
def setUp(self): def setUp(self):
@@ -573,7 +562,7 @@ class TestLibraryContentAnalytics(LibraryContentTest):
def _assert_event_was_published(self, event_type): def _assert_event_was_published(self, event_type):
""" """
Check that a LibraryContentBlock analytics event was published by self.lc_block. Check that a LegacyLibraryContentBlock analytics event was published by self.lc_block.
""" """
assert self.publisher.called assert self.publisher.called
assert len(self.publisher.call_args[0]) == 3 # pylint:disable=unsubscriptable-object assert len(self.publisher.call_args[0]) == 3 # pylint:disable=unsubscriptable-object

View File

@@ -1,23 +1,20 @@
""" """
Tests for library tools service (only used by CMS) Tests for legacy library tools service (only used by CMS)
Currently, the only known user of the LibraryToolsService is the The only known user of the LegacyLibraryToolsService is the
LibraryContentBlock, so these tests are all written with only that LegacyLibraryContentBlock, so these tests are all written with only that
block type in mind. block type in mind.
""" """
from unittest import mock from unittest import mock
import ddt import ddt
from django.conf import settings from opaque_keys.edx.locator import LibraryLocator
from django.test import override_settings
from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
from common.djangoapps.student.roles import CourseInstructorRole
from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms from openedx.core.djangolib.testing.utils import skip_unless_cms
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
from xmodule.library_tools import LibraryToolsService from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase from xmodule.modulestore.tests.utils import MixedSplitTestCase
@@ -26,34 +23,23 @@ from xmodule.modulestore.tests.utils import MixedSplitTestCase
@ddt.ddt @ddt.ddt
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
""" """
Tests for LibraryToolsService. Tests for LegacyLibraryToolsService.
Tests interaction with learning-core-based (V2) and mongo-based (V1) content libraries.
""" """
def setUp(self): def setUp(self):
super().setUp() super().setUp()
UserFactory(is_staff=True, id=self.user_id) UserFactory(is_staff=True, id=self.user_id)
self.tools = LibraryToolsService(self.store, self.user_id) self.tools = LegacyLibraryToolsService(self.store, self.user_id)
def test_list_available_libraries(self): def test_list_available_libraries(self):
""" """
Test listing of libraries. Test listing of v1 libraries.
Collects Only V2 Libaries if the FEATURES[ENABLE_LIBRARY_AUTHORING_MICROFRONTEND] setting is True.
Otherwise, return all v1 and v2 libraries.
""" """
# create V1 library # create V1 library
_ = LibraryFactory.create(modulestore=self.store) _ = LibraryFactory.create(modulestore=self.store)
# create V2 library # create V2 library (should not be included in this list)
self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks") self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
all_libraries = self.tools.list_available_libraries() all_libraries = self.tools.list_available_libraries()
assert all_libraries assert len(all_libraries) == 1
assert len(all_libraries) == 2
with override_settings(FEATURES={**settings.FEATURES, "ENABLE_LIBRARY_AUTHORING_MICROFRONTEND": True}):
all_libraries = self.tools.list_available_libraries()
assert all_libraries
assert len(all_libraries) == 1
@mock.patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_library_summaries') @mock.patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_library_summaries')
def test_list_available_libraries_fetch(self, mock_get_library_summaries): def test_list_available_libraries_fetch(self, mock_get_library_summaries):
@@ -63,7 +49,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
_ = self.tools.list_available_libraries() _ = self.tools.list_available_libraries()
assert mock_get_library_summaries.called assert mock_get_library_summaries.called
def test_get_latest_v1_library_version(self): def test_get_latest_library_version(self):
""" """
Test get_v1_library_version for V1 libraries. Test get_v1_library_version for V1 libraries.
@@ -84,49 +70,16 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
assert result == str(lib.location.library_key.version_guid) assert result == str(lib.location.library_key.version_guid)
@ddt.data( @ddt.data(
'library-v1:Fake+Key', # V1 library key 'library-v1:Fake+Key',
'lib:Fake:V-2', # V2 library key
LibraryLocator.from_string('library-v1:Fake+Key'), LibraryLocator.from_string('library-v1:Fake+Key'),
LibraryLocatorV2.from_string('lib:Fake:V-2'),
) )
def test_get_latest_library_version_no_library(self, lib_key): def test_get_latest_library_version_no_library(self, lib_key):
""" """
Test get_latest_library_version result when the library does not exist. Test get_latest_library_version result when the library does not exist.
Provided lib_key's are valid V1 or V2 keys.
""" """
assert self.tools.get_latest_library_version(lib_key) is None assert self.tools.get_latest_library_version(lib_key) is None
def test_update_children_for_v2_lib(self): def test_update_children(self):
"""
Test update_children with V2 library as a source.
"""
library = self._create_library(
slug="cool-v2-lib", title="The best Library", description="Spectacular description"
)
self._add_block_to_library(library["id"], "unit", "unit1_id")
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
CourseInstructorRole(course.id).add_users(self.user)
content_block = self.make_block(
"library_content",
course,
max_count=1,
source_library_id=library['id']
)
assert len(content_block.children) == 0
# Populate children from library
self.tools.trigger_library_sync(content_block, library_version=None)
# The updates happen in a Celery task, so this particular content_block instance is no updated.
# We must re-instantiate it from modulstore in order to see the updated children list.
content_block = self.store.get_item(content_block.location)
assert len(content_block.children) == 1
def test_update_children_for_v1_lib(self):
""" """
Test update_children with V1 library as a source. Test update_children with V1 library as a source.

View File

@@ -16,7 +16,7 @@ from .test_course_block import DummySystem as TestImportSystem
class RandomizeBlockTest(MixedSplitTestCase): class RandomizeBlockTest(MixedSplitTestCase):
""" """
Base class for tests of LibraryContentBlock (library_content_block.py) Base class for tests of RandomizeBlock (randomize_block.py)
""" """
maxDiff = None maxDiff = None

View File

@@ -188,7 +188,7 @@ class VerticalBlock(
if has_access_error: if has_access_error:
return True return True
# Check child nodes if they exist (e.g. randomized library question aka LibraryContentBlock) # Check child nodes if they exist (e.g. randomized library question aka LegacyLibraryContentBlock)
for child in block.get_children(): for child in block.get_children():
has_access_error = getattr(child, 'has_access_error', False) has_access_error = getattr(child, 'has_access_error', False)
if has_access_error: if has_access_error: