diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index e8d3039b0b29..6fe829ce0e3a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -8,7 +8,7 @@ from .course_team import CourseTeamSerializer from .grading import CourseGradingModelSerializer, CourseGradingSerializer from .group_configurations import CourseGroupConfigurationsSerializer -from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer +from .home import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer from .proctoring import ( LimitedProctoredExamSettingsSerializer, ProctoredExamConfigurationSerializer, diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index 4c3e2a4321d3..a81d391b3f69 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -41,8 +41,8 @@ class LibraryTabSerializer(serializers.Serializer): libraries = LibraryViewSerializer(many=True, required=False, allow_null=True) -class CourseHomeSerializer(serializers.Serializer): - """Serializer for course home""" +class StudioHomeSerializer(serializers.Serializer): + """Serializer for Studio home""" allow_course_reruns = serializers.BooleanField() allow_to_create_new_org = serializers.BooleanField() allow_unicode_course_id = serializers.BooleanField() @@ -58,6 +58,8 @@ class CourseHomeSerializer(serializers.Serializer): in_process_course_actions = UnsucceededCourseSerializer(many=True, required=False, allow_null=True) libraries = LibraryViewSerializer(many=True, required=False, allow_null=True) libraries_enabled = serializers.BooleanField() + libraries_v1_enabled = serializers.BooleanField() + libraries_v2_enabled = serializers.BooleanField() taxonomies_enabled = serializers.BooleanField() taxonomy_list_mfe_url = serializers.CharField() optimization_enabled = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index ff476090ee7a..06433d9f42d5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -8,7 +8,7 @@ from openedx.core.lib.api.view_utils import view_auth_classes from ....utils import get_home_context, get_course_context, get_library_context -from ..serializers import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer +from ..serializers import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer @view_auth_classes(is_authenticated=True) @@ -24,7 +24,7 @@ class HomePageView(APIView): description="Query param to filter by course org", )], responses={ - 200: CourseHomeSerializer, + 200: StudioHomeSerializer, 401: "The requester is not authenticated.", }, ) @@ -59,6 +59,9 @@ def get(self, request: Request): "in_process_course_actions": [], "libraries": [], "libraries_enabled": true, + "libraries_v1_enabled": true, + "libraries_v2_enabled": true, + "library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023", "optimization_enabled": true, "request_course_creator_url": "/request_course_creator", "rerun_creator_status": true, @@ -84,7 +87,7 @@ def get(self, request: Request): 'platform_name': settings.PLATFORM_NAME, 'user_is_active': request.user.is_active, }) - serializer = CourseHomeSerializer(home_context) + serializer = StudioHomeSerializer(home_context) return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index c3c9652e5d9e..69eee524373c 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -33,12 +33,7 @@ class HomePageViewTest(CourseTestCase): def setUp(self): super().setUp() self.url = reverse("cms.djangoapps.contentstore:v1:home") - - def test_home_page_courses_response(self): - """Check successful response content""" - response = self.client.get(self.url) - - expected_response = { + self.expected_response = { "allow_course_reruns": True, "allow_to_create_new_org": False, "allow_unicode_course_id": False, @@ -51,6 +46,8 @@ def test_home_page_courses_response(self): "in_process_course_actions": [], "libraries": [], "libraries_enabled": True, + "libraries_v1_enabled": True, + "libraries_v2_enabled": False, "taxonomies_enabled": True, "taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies', "optimization_enabled": False, @@ -66,6 +63,21 @@ def test_home_page_courses_response(self): "user_is_active": True, } + def test_home_page_studio_response(self): + """Check successful response content""" + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(self.expected_response, response.data) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_home_page_studio_with_meilisearch_enabled(self): + """Check response content when Meilisearch is enabled""" + response = self.client.get(self.url) + + expected_response = self.expected_response + expected_response["libraries_v2_enabled"] = True + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 7c3a369fed62..79c722e24d52 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -2,6 +2,7 @@ CMS feature toggles. """ from edx_toggles.toggles import SettingDictToggle, WaffleFlag +from openedx.core.djangoapps.content.search import api as search_api from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # .. toggle_name: FEATURES['ENABLE_EXPORT_GIT'] @@ -593,3 +594,76 @@ def default_enable_flexible_peer_openassessments(course_key): level to opt in/out of rolling forward this feature. """ return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key) + + +# .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES'] +# .. toggle_implementation: SettingDictToggle +# .. toggle_default: True +# .. toggle_description: Enables use of the legacy and v2 libraries waffle flags. +# Note that legacy content libraries are only supported in courses using split mongo. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2015-03-06 +# .. toggle_target_removal_date: 2025-04-09 +# .. toggle_warning: This flag is deprecated in Sumac, and will be removed in favor of the disable_legacy_libraries and +# disable_new_libraries waffle flags. +ENABLE_CONTENT_LIBRARIES = SettingDictToggle( + "FEATURES", "ENABLE_CONTENT_LIBRARIES", default=True, module_name=__name__ +) + +# .. toggle_name: contentstore.new_studio_mfe.disable_legacy_libraries +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Hides legacy (v1) Libraries tab in Authoring MFE. +# This toggle interacts with ENABLE_CONTENT_LIBRARIES toggle: if this is disabled, then legacy libraries are also +# disabled. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-10-02 +# .. toggle_target_removal_date: 2025-04-09 +# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334 +# .. toggle_warning: Legacy libraries are deprecated in Sumac, cf https://github.com/openedx/edx-platform/issues/32457 +DISABLE_LEGACY_LIBRARIES = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_legacy_libraries', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + +def libraries_v1_enabled(): + """ + Returns a boolean if Libraries V2 is enabled in the new Studio Home. + """ + return ( + ENABLE_CONTENT_LIBRARIES.is_enabled() and + not DISABLE_LEGACY_LIBRARIES.is_enabled() + ) + + +# .. toggle_name: contentstore.new_studio_mfe.disable_new_libraries +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Hides new Libraries v2 tab in Authoring MFE. +# This toggle interacts with settings.MEILISEARCH_ENABLED and ENABLE_CONTENT_LIBRARIES toggle: if these flags are +# False, then v2 libraries are also disabled. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2024-10-02 +# .. toggle_target_removal_date: 2025-04-09 +# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334 +# .. toggle_warning: Libraries v2 are in beta for Sumac, will be fully supported in Teak. +DISABLE_NEW_LIBRARIES = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_new_libraries', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + +def libraries_v2_enabled(): + """ + Returns a boolean if Libraries V2 is enabled in the new Studio Home. + + Requires the ENABLE_CONTENT_LIBRARIES feature flag to be enabled, plus Meilisearch. + """ + return ( + ENABLE_CONTENT_LIBRARIES.is_enabled() and + search_api.is_meilisearch_enabled() and + not DISABLE_NEW_LIBRARIES.is_enabled() + ) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 035e44d5ce31..aeb3e975d1e2 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -34,7 +34,11 @@ from pytz import UTC from xblock.fields import Scope -from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled +from cms.djangoapps.contentstore.toggles import ( + exam_setting_view_enabled, + libraries_v1_enabled, + libraries_v2_enabled, +) from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError from common.djangoapps.course_modes.models import CourseMode @@ -1536,11 +1540,10 @@ def get_library_context(request, request_is_json=False): _format_library_for_view, ) from cms.djangoapps.contentstore.views.library import ( - LIBRARIES_ENABLED, user_can_view_create_library_button, ) - libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else [] + libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else [] data = { 'libraries': [_format_library_for_view(lib, request) for lib in libraries], } @@ -1550,7 +1553,7 @@ def get_library_context(request, request_is_json=False): **data, 'in_process_course_actions': [], 'courses': [], - 'libraries_enabled': LIBRARIES_ENABLED, + 'libraries_enabled': libraries_v1_enabled(), 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), @@ -1671,7 +1674,6 @@ def get_home_context(request, no_course=False): ENABLE_GLOBAL_STAFF_OPTIMIZATION, ) from cms.djangoapps.contentstore.views.library import ( - LIBRARIES_ENABLED, user_can_view_create_library_button, ) @@ -1687,7 +1689,7 @@ def get_home_context(request, no_course=False): if not no_course: active_courses, archived_courses, in_process_course_actions = get_course_context(request) - if not split_library_view_on_dashboard() and LIBRARIES_ENABLED and not no_course: + if not split_library_view_on_dashboard() and libraries_v1_enabled() and not no_course: libraries = get_library_context(request, True)['libraries'] home_context = { @@ -1695,7 +1697,9 @@ def get_home_context(request, no_course=False): 'split_studio_home': split_library_view_on_dashboard(), 'archived_courses': archived_courses, 'in_process_course_actions': in_process_course_actions, - 'libraries_enabled': LIBRARIES_ENABLED, + 'libraries_enabled': libraries_v1_enabled(), + 'libraries_v1_enabled': libraries_v1_enabled(), + 'libraries_v2_enabled': libraries_v2_enabled(), 'taxonomies_enabled': not is_tagging_feature_disabled(), 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 340cadb4e244..92e4329c2f94 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -42,6 +42,7 @@ from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from ..utils import add_instructor, reverse_library_url +from ..toggles import libraries_v1_enabled from .component import CONTAINER_TEMPLATES, get_component_templates from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from .user import user_with_role @@ -50,13 +51,11 @@ log = logging.getLogger(__name__) -LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False) - def _user_can_create_library_for_org(user, org=None): """ Helper method for returning the library creation status for a particular user, - taking into account the value LIBRARIES_ENABLED. + taking into account the libraries_v1_enabled toggle. if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org), if library creation is enabled. @@ -69,7 +68,7 @@ def _user_can_create_library_for_org(user, org=None): Course Staff: Can make libraries in the organization which has courses of which they are staff. Course Admin: Can make libraries in the organization which has courses of which they are Admin. """ - if not LIBRARIES_ENABLED: + if not libraries_v1_enabled(): return False elif user.is_staff: return True @@ -125,7 +124,7 @@ def library_handler(request, library_key_string=None): """ RESTful interface to most content library related functionality. """ - if not LIBRARIES_ENABLED: + if not libraries_v1_enabled(): log.exception("Attempted to use the content library API when the libraries feature is disabled.") raise Http404 # Should never happen because we test the feature in urls.py also diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index fa6505419725..8278cd0535bb 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -56,44 +56,44 @@ def setUp(self): # Tests for /library/ - list and create libraries: # When libraries are disabled, nobody can create libraries - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", False) def test_library_creator_status_libraries_not_enabled(self): _, nostaff_user = self.create_non_staff_authed_user_client() self.assertEqual(user_can_create_library(nostaff_user, None), False) # When creator group is disabled, non-staff users can create libraries - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_no_course_creator_role(self): _, nostaff_user = self.create_non_staff_authed_user_client() self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True) # When creator group is enabled, Non staff users cannot create libraries - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertEqual(user_can_create_library(nostaff_user, None), False) # Global staff can create libraries for any org, even ones that don't exist. - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_is_staff_user(self): print(self.user.is_staff) self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True) # Global staff can create libraries for any org, but an org has to be supplied. - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_is_staff_user_no_org(self): print(self.user.is_staff) self.assertEqual(user_can_create_library(self.user, None), False) # When creator groups are enabled, global staff can create libraries in any org - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self): with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True) # When creator groups are enabled, course creators can create libraries in any org. - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): @@ -102,7 +102,7 @@ def test_library_creator_status_with_course_creator_role_for_enabled_creator_gro # When creator groups are enabled, course staff members can create libraries # but only in the org they are course staff for. - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): @@ -112,7 +112,7 @@ def test_library_creator_status_with_course_staff_role_for_enabled_creator_group # When creator groups are enabled, course instructor members can create libraries # but only in the org they are course staff for. - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): @@ -134,7 +134,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library, Ensure that the setting DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION as expected. """ _, nostaff_user = self.create_non_staff_authed_user_client() - with mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True): + with mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True): with mock.patch.dict( "django.conf.settings.FEATURES", { @@ -145,7 +145,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library, self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status) @mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True}) - @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + @mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True) def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaff_course_creation(self): """ Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well. @@ -161,7 +161,7 @@ def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaf self.assertEqual(get_response.status_code, 200) self.assertEqual(post_response.status_code, 403) - @patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False) + @mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CONTENT_LIBRARIES': False}) def test_with_libraries_disabled(self): """ The library URLs should return 404 if libraries are disabled. diff --git a/cms/urls.py b/cms/urls.py index 4082ce446f84..d72189445883 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -222,7 +222,7 @@ path('openassessment/fileupload/', include('openassessment.fileupload.urls')), ] -if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'): +if toggles.ENABLE_CONTENT_LIBRARIES: urlpatterns += [ re_path(fr'^library/{LIBRARY_KEY_PATTERN}?$', contentstore_views.library_handler, name='library_handler'),