diff --git a/cms/djangoapps/contentstore/rest_api/v1/mixins.py b/cms/djangoapps/contentstore/rest_api/v1/mixins.py index 849a4834905e..3ac4795680ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/mixins.py +++ b/cms/djangoapps/contentstore/rest_api/v1/mixins.py @@ -2,10 +2,16 @@ Common mixins for module. """ import json +import logging from unittest.mock import patch +from django.http import Http404 +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey from rest_framework import status +log = logging.getLogger(__name__) + class PermissionAccessMixin: """ @@ -30,7 +36,7 @@ def test_permissions_unauthenticated(self): self.assertEqual(error, "Authentication credentials were not provided.") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - @patch.dict('django.conf.settings.FEATURES', {'DISABLE_ADVANCED_SETTINGS': True}) + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_ADVANCED_SETTINGS": True}) def test_permissions_unauthorized(self): """ Test that an error is returned if the user is unauthorised. @@ -40,3 +46,20 @@ def test_permissions_unauthorized(self): error = self.get_and_check_developer_response(response) self.assertEqual(error, "You do not have permission to perform this action.") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class ContainerHandlerMixin: + """ + A mixin providing common functionality for container handler views. + """ + + def get_object(self, usage_key_string): + """ + Get an object by usage-id of the block + """ + try: + usage_key = UsageKey.from_string(usage_key_string) + return usage_key + except InvalidKeyError as err: + log.error(f"Invalid usage key: {usage_key_string}", exc_info=True) + raise Http404(f"Object not found for usage key: {usage_key_string}") from err diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index d5b41dd5b99a..e8d3039b0b29 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -1,24 +1,27 @@ """ Serializers for v1 contentstore API. """ +from .certificates import CourseCertificatesSerializer from .course_details import CourseDetailsSerializer +from .course_index import CourseIndexSerializer from .course_rerun import CourseRerunSerializer from .course_team import CourseTeamSerializer -from .course_index import CourseIndexSerializer from .grading import CourseGradingModelSerializer, CourseGradingSerializer +from .group_configurations import CourseGroupConfigurationsSerializer from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer from .proctoring import ( LimitedProctoredExamSettingsSerializer, ProctoredExamConfigurationSerializer, ProctoredExamSettingsSerializer, - ProctoringErrorsSerializer + ProctoringErrorsSerializer, ) from .settings import CourseSettingsSerializer +from .textbooks import CourseTextbooksSerializer +from .vertical_block import ContainerHandlerSerializer, VerticalContainerSerializer from .videos import ( CourseVideosSerializer, - VideoUploadSerializer, + VideoDownloadSerializer, VideoImageSerializer, + VideoUploadSerializer, VideoUsageSerializer, - VideoDownloadSerializer ) -from .vertical_block import ContainerHandlerSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py new file mode 100644 index 000000000000..9e536efa4550 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/certificates.py @@ -0,0 +1,52 @@ +""" +API Serializers for certificates page +""" + +from rest_framework import serializers + + +class CertificateSignatorySerializer(serializers.Serializer): + """ + Serializer for representing certificate's signatory. + """ + + id = serializers.IntegerField() + name = serializers.CharField() + organization = serializers.CharField(required=False) + signature_image_path = serializers.CharField() + title = serializers.CharField() + + +class CertificateItemSerializer(serializers.Serializer): + """ + Serializer for representing certificate item created for current course. + """ + + course_title = serializers.CharField(required=False) + description = serializers.CharField() + editing = serializers.BooleanField(required=False) + id = serializers.IntegerField() + is_active = serializers.BooleanField() + name = serializers.CharField() + signatories = CertificateSignatorySerializer(many=True) + version = serializers.IntegerField() + + +class CourseCertificatesSerializer(serializers.Serializer): + """ + Serializer for representing course's certificates. + """ + + certificate_activation_handler_url = serializers.CharField() + certificate_web_view_url = serializers.CharField(allow_null=True) + certificates = CertificateItemSerializer(many=True, allow_null=True) + course_modes = serializers.ListField(child=serializers.CharField()) + has_certificate_modes = serializers.BooleanField() + is_active = serializers.BooleanField() + is_global_staff = serializers.BooleanField() + mfe_proctored_exam_settings_url = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + course_number = serializers.CharField(source="context_course.number") + course_title = serializers.CharField(source="context_course.display_name_with_default") + course_number_override = serializers.CharField(source="context_course.display_coursenumber") diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py new file mode 100644 index 000000000000..fefeac8e748e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/group_configurations.py @@ -0,0 +1,59 @@ +""" +API Serializers for course's settings group configurations. +""" + +from rest_framework import serializers + + +class GroupConfigurationUsageSerializer(serializers.Serializer): + """ + Serializer for representing nested usage inside configuration. + """ + + label = serializers.CharField() + url = serializers.CharField() + validation = serializers.DictField(required=False) + + +class GroupConfigurationGroupSerializer(serializers.Serializer): + """ + Serializer for representing nested group inside configuration. + """ + + id = serializers.IntegerField() + name = serializers.CharField() + usage = GroupConfigurationUsageSerializer(required=False, allow_null=True, many=True) + version = serializers.IntegerField() + + +class GroupConfigurationItemSerializer(serializers.Serializer): + """ + Serializer for representing group configurations item. + """ + + active = serializers.BooleanField() + description = serializers.CharField() + groups = GroupConfigurationGroupSerializer(allow_null=True, many=True) + id = serializers.IntegerField() + usage = GroupConfigurationUsageSerializer(required=False, allow_null=True, many=True) + name = serializers.CharField() + parameters = serializers.DictField() + read_only = serializers.BooleanField(required=False) + scheme = serializers.CharField() + version = serializers.IntegerField() + + +class CourseGroupConfigurationsSerializer(serializers.Serializer): + """ + Serializer for representing course's settings group configurations. + """ + + all_group_configurations = GroupConfigurationItemSerializer(many=True) + experiment_group_configurations = GroupConfigurationItemSerializer( + allow_null=True, many=True + ) + mfe_proctored_exam_settings_url = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + should_show_enrollment_track = serializers.BooleanField() + should_show_experiment_groups = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/textbooks.py new file mode 100644 index 000000000000..1ee5b73fd198 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/textbooks.py @@ -0,0 +1,32 @@ +""" +API Serializers for textbooks page +""" + +from rest_framework import serializers + + +class CourseTextbookChapterSerializer(serializers.Serializer): + """ + Serializer for representing textbook chapter. + """ + + title = serializers.CharField() + url = serializers.CharField() + + +class CourseTextbookItemSerializer(serializers.Serializer): + """ + Serializer for representing textbook item. + """ + + id = serializers.CharField() + chapters = CourseTextbookChapterSerializer(many=True) + tab_title = serializers.CharField() + + +class CourseTextbooksSerializer(serializers.Serializer): + """ + Serializer for representing course's textbooks. + """ + + textbooks = serializers.ListField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index c5b54e200e75..43abd051ea82 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -5,12 +5,22 @@ from django.urls import reverse from rest_framework import serializers +from cms.djangoapps.contentstore.toggles import use_tagging_taxonomy_list_page from cms.djangoapps.contentstore.helpers import ( xblock_studio_url, xblock_type_display_name, ) +class MessageValidation(serializers.Serializer): + """ + Serializer for representing XBlock error. + """ + + text = serializers.CharField() + type = serializers.CharField() + + class ChildAncestorSerializer(serializers.Serializer): """ Serializer for representing child blocks in the ancestor XBlock. @@ -78,6 +88,7 @@ class ContainerHandlerSerializer(serializers.Serializer): assets_url = serializers.SerializerMethodField() unit_block_id = serializers.CharField(source="unit.location.block_id") subsection_location = serializers.CharField(source="subsection.location") + course_sequence_ids = serializers.ListField(child=serializers.CharField()) def get_assets_url(self, obj): """ @@ -90,3 +101,46 @@ def get_assets_url(self, obj): "assets_handler", kwargs={"course_key_string": context_course.id} ) return None + + +class ChildVerticalContainerSerializer(serializers.Serializer): + """ + Serializer for representing a xblock child of vertical container. + """ + + name = serializers.CharField() + block_id = serializers.CharField() + block_type = serializers.CharField() + actions = serializers.SerializerMethodField() + user_partition_info = serializers.DictField() + user_partitions = serializers.ListField() + validation_messages = MessageValidation(many=True) + render_error = serializers.CharField() + + def get_actions(self, obj): # pylint: disable=unused-argument + """ + Method to get actions for each child xlock of the unit. + """ + + can_manage_tags = use_tagging_taxonomy_list_page() + # temporary decision defining the default value 'True' for each xblock. + actions = { + "can_copy": True, + "can_duplicate": True, + "can_move": True, + "can_manage_access": True, + "can_delete": True, + "can_manage_tags": can_manage_tags, + } + + return actions + + +class VerticalContainerSerializer(serializers.Serializer): + """ + Serializer for representing a vertical container with state and children. + """ + + children = ChildVerticalContainerSerializer(many=True) + is_published = serializers.BooleanField() + can_paste_component = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 66760ea3c303..d04cdd88a973 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -7,10 +7,13 @@ from .views import ( ContainerHandlerView, + CourseCertificatesView, CourseDetailsView, CourseTeamView, + CourseTextbooksView, CourseIndexView, CourseGradingView, + CourseGroupConfigurationsView, CourseRerunView, CourseSettingsView, CourseVideosView, @@ -21,7 +24,8 @@ ProctoringErrorsView, HelpUrlsView, VideoUsageView, - VideoDownloadView + VideoDownloadView, + VerticalContainerView, ) app_name = 'v1' @@ -102,11 +106,31 @@ CourseRerunView.as_view(), name="course_rerun" ), + re_path( + fr'^certificates/{COURSE_ID_PATTERN}$', + CourseCertificatesView.as_view(), + name="certificates" + ), + re_path( + fr'^textbooks/{COURSE_ID_PATTERN}$', + CourseTextbooksView.as_view(), + name="textbooks" + ), + re_path( + fr'^group_configurations/{COURSE_ID_PATTERN}$', + CourseGroupConfigurationsView.as_view(), + name="group_configurations" + ), re_path( fr'^container_handler/{settings.USAGE_KEY_PATTERN}$', ContainerHandlerView.as_view(), name="container_handler" ), + re_path( + fr'^container/vertical/{settings.USAGE_KEY_PATTERN}/children$', + VerticalContainerView.as_view(), + name="container_vertical" + ), # Authoring API # Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index b7415b78c29a..23de2d2107e7 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -1,18 +1,17 @@ """ Views for v1 contentstore API. """ +from .certificates import CourseCertificatesView from .course_details import CourseDetailsView from .course_index import CourseIndexView -from .course_team import CourseTeamView from .course_rerun import CourseRerunView +from .course_team import CourseTeamView from .grading import CourseGradingView +from .group_configurations import CourseGroupConfigurationsView +from .help_urls import HelpUrlsView +from .home import HomePageCoursesView, HomePageLibrariesView, HomePageView from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView -from .home import HomePageView, HomePageCoursesView, HomePageLibrariesView from .settings import CourseSettingsView -from .videos import ( - CourseVideosView, - VideoUsageView, - VideoDownloadView -) -from .help_urls import HelpUrlsView -from .vertical_block import ContainerHandlerView +from .textbooks import CourseTextbooksView +from .vertical_block import ContainerHandlerView, VerticalContainerView +from .videos import CourseVideosView, VideoDownloadView, VideoUsageView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py new file mode 100644 index 000000000000..db93d65da68a --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py @@ -0,0 +1,106 @@ +""" API Views for course certificates """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_certificates_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseCertificatesSerializer, +) +from common.djangoapps.student.auth import has_studio_write_access +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseCertificatesView(DeveloperErrorViewMixin, APIView): + """ + View for course certificate page. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ), + ], + responses={ + 200: CourseCertificatesSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course's certificates. + + **Example Request** + + GET /api/contentstore/v1/certificates/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's certificates. + + **Example Response** + + ```json + { + "certificate_activation_handler_url": "/certificates/activation/course-v1:org+101+101/", + "certificate_web_view_url": "///certificates/course/course-v1:org+101+101?preview=honor", + "certificates": [ + { + "course_title": "Course title", + "description": "Description of the certificate", + "editing": false, + "id": 1622146085, + "is_active": false, + "name": "Name of the certificate", + "signatories": [ + { + "id": 268550145, + "name": "name_sign", + "organization": "org", + "signature_image_path": "/asset-v1:org+101+101+type@asset+block@camera.png", + "title": "title_sign" + } + ], + "version": 1 + }, + ], + "course_modes": [ + "honor" + ], + "has_certificate_modes": true, + "is_active": false, + "is_global_staff": true, + "mfe_proctored_exam_settings_url": "", + "course_number": "DemoX", + "course_title": "Demonstration Course", + "course_number_override": "Course Number Display String" + } + ``` + """ + course_key = CourseKey.from_string(course_id) + store = modulestore() + + if not has_studio_write_access(request.user, course_key): + self.permission_denied(request) + + with store.bulk_operations(course_key): + course = modulestore().get_course(course_key) + certificates_context = get_certificates_context(course, request.user) + serializer = CourseCertificatesSerializer(certificates_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py new file mode 100644 index 000000000000..49fc76850d6e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py @@ -0,0 +1,152 @@ +""" API Views for course's settings group configurations """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_group_configurations_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseGroupConfigurationsSerializer, +) +from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseGroupConfigurationsView(DeveloperErrorViewMixin, APIView): + """ + View for course's settings group configurations. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ), + ], + responses={ + 200: CourseGroupConfigurationsSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course's settings group configurations. + + **Example Request** + + GET /api/contentstore/v1/group_configurations/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's settings group configurations. + + **Example Response** + + ```json + { + "all_group_configurations": [ + { + "active": true, + "description": "Partition for segmenting users by enrollment track", + "groups": [ + { + "id": 2, + "name": "Enroll", + "usage": [ + { + "label": "Subsection / Unit", + "url": "/container/block-v1:org+101+101+type@vertical+block@08772238547242848cef9" + } + ], + "version": 1 + } + ], + "id": 50, + "usage": null, + "name": "Enrollment Track Groups", + "parameters": { + "course_id": "course-v1:org+101+101" + }, + "read_only": true, + "scheme": "enrollment_track", + "version": 3 + }, + { + "active": true, + "description": "The groups in this configuration can be mapped to cohorts in the Instructor.", + "groups": [ + { + "id": 593758473, + "name": "My Content Group", + "usage": [], + "version": 1 + } + ], + "id": 1791848226, + "name": "Content Groups", + "parameters": {}, + "read_only": false, + "scheme": "cohort", + "version": 3 + } + ], + "experiment_group_configurations": [ + { + "active": true, + "description": "desc", + "groups": [ + { + "id": 276408623, + "name": "Group A", + "usage": null, + "version": 1 + }, + ... + ], + "id": 875961582, + "usage": [ + { + "label": "Unit / Content Experiment", + "url": "/container/block-v1:org+101+101+type@split_test+block@90ccbbad0dac48b18c5c80", + "validation": null + }, + ... + ], + "name": "Experiment Group Configurations 5", + "parameters": {}, + "scheme": "random", + "version": 3 + }, + ... + ], + "mfe_proctored_exam_settings_url": "", + "should_show_enrollment_track": true, + "should_show_experiment_groups": true, + } + ``` + """ + course_key = CourseKey.from_string(course_id) + store = modulestore() + + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + with store.bulk_operations(course_key): + course = modulestore().get_course(course_key) + group_configurations_context = get_group_configurations_context(course, store) + serializer = CourseGroupConfigurationsSerializer(group_configurations_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py new file mode 100644 index 000000000000..db9d1dacca90 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py @@ -0,0 +1,38 @@ +""" +Unit tests for the course's certificate. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods + +from ...mixins import PermissionAccessMixin + + +class CourseCertificatesViewTest(CourseTestCase, PermissionAccessMixin, HelperMethods): + """ + Tests for CourseCertificatesView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:certificates", + kwargs={"course_id": self.course.id}, + ) + + def test_success_response(self): + """ + Check that endpoint is valid and success response. + """ + self._add_course_certificates(count=2, signatory_count=2) + response = self.client.get(self.url) + response_data = response.data + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response_data["certificates"]), 2) + self.assertEqual(len(response_data["certificates"][0]["signatories"]), 2) + self.assertEqual(len(response_data["certificates"][1]["signatories"]), 2) + self.assertEqual(response_data["course_number_override"], self.course.display_coursenumber) + self.assertEqual(response_data["course_title"], self.course.display_name_with_default) + self.assertEqual(response_data["course_number"], self.course.number) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py new file mode 100644 index 000000000000..4d7092841b8d --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_group_configurations.py @@ -0,0 +1,58 @@ +""" +Unit tests for the course's setting group configuration. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.course_group_config import ( + CONTENT_GROUP_CONFIGURATION_NAME, +) +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods +from xmodule.partitions.partitions import ( + Group, + UserPartition, +) # lint-amnesty, pylint: disable=wrong-import-order + +from ...mixins import PermissionAccessMixin + + +class CourseGroupConfigurationsViewTest( + CourseTestCase, PermissionAccessMixin, HelperMethods +): + """ + Tests for CourseGroupConfigurationsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:group_configurations", + kwargs={"course_id": self.course.id}, + ) + + def test_success_response(self): + """ + Check that endpoint is valid and success response. + """ + self.course.user_partitions = [ + UserPartition( + 0, + "First name", + "First description", + [Group(0, "Group A"), Group(1, "Group B"), Group(2, "Group C")], + ), # lint-amnesty, pylint: disable=line-too-long + ] + self.save_course() + + if "split_test" not in self.course.advanced_modules: + self.course.advanced_modules.append("split_test") + self.store.update_item(self.course, self.user.id) + + response = self.client.get(self.url) + self.assertEqual(len(response.data["all_group_configurations"]), 1) + self.assertEqual(len(response.data["experiment_group_configurations"]), 1) + self.assertContains(response, "First name", count=1) + self.assertContains(response, "Group C") + self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py new file mode 100644 index 000000000000..d4dd80f8ab05 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_textbooks.py @@ -0,0 +1,43 @@ +""" +Unit tests for the course's textbooks. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase + +from ...mixins import PermissionAccessMixin + + +class CourseTextbooksViewTest(CourseTestCase, PermissionAccessMixin): + """ + Tests for CourseTextbooksView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:textbooks", + kwargs={"course_id": self.course.id}, + ) + + def test_success_response(self): + """ + Check that endpoint is valid and success response. + """ + expected_textbook = [ + { + "tab_title": "Textbook Name", + "chapters": [ + {"title": "Chapter 1", "url": "/static/book.pdf"}, + {"title": "Chapter 2", "url": "/static/story.pdf"}, + ], + "id": "Textbook_Name", + } + ] + self.course.pdf_textbooks = expected_textbook + self.save_course() + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["textbooks"], expected_textbook) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index ff117c5ecfe6..0216c86da2e2 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -1,67 +1,287 @@ """ Unit tests for the vertical block. """ + from django.urls import reverse from rest_framework import status +from edx_toggles.toggles.testutils import override_waffle_flag +from xblock.validation import ValidationMessage from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from cms.djangoapps.contentstore.toggles import ENABLE_TAGGING_TAXONOMY_LIST_PAGE +from xmodule.partitions.partitions import ( + ENROLLMENT_TRACK_PARTITION_ID, + Group, + UserPartition, +) +from xmodule.modulestore.django import ( + modulestore, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( + BlockFactory, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ( + ModuleStoreEnum, +) # lint-amnesty, pylint: disable=wrong-import-order -class ContainerHandlerViewTest(CourseTestCase): +class BaseXBlockContainer(CourseTestCase): """ - Unit tests for the ContainerHandlerView. + Base xBlock container handler. + + Contains common function for processing course xblocks. """ + view_name = None + def setUp(self): super().setUp() - self.chapter = BlockFactory.create( - parent=self.course, category="chapter", display_name="Week 1" - ) - self.sequential = BlockFactory.create( - parent=self.chapter, category="sequential", display_name="Lesson 1" - ) - self.vertical = self._create_block(self.sequential, "vertical", "Unit") - self.store = modulestore() - self.store.publish(self.vertical.location, self.user.id) + self.setup_xblock() - def _get_reverse_url(self, location): + def setup_xblock(self): """ - Creates url to current handler view api + Set up XBlock objects for testing purposes. + + This method creates XBlock objects representing a course structure with chapters, + sequentials, verticals and others. """ - return reverse( - "cms.djangoapps.contentstore:v1:container_handler", - kwargs={"usage_key_string": location}, + self.chapter = self.create_block( + parent=self.course.location, + category="chapter", + display_name="Week 1", ) - def _create_block(self, parent, category, display_name, **kwargs): + self.sequential = self.create_block( + parent=self.chapter.location, + category="sequential", + display_name="Lesson 1", + ) + + self.vertical = self.create_block(self.sequential.location, "vertical", "Unit") + + self.html_unit_first = self.create_block( + parent=self.vertical.location, + category="html", + display_name="Html Content 1", + ) + + self.html_unit_second = self.create_block( + parent=self.vertical.location, + category="html", + display_name="Html Content 2", + ) + + def create_block(self, parent, category, display_name, **kwargs): """ Creates a block without publishing it. """ return BlockFactory.create( - parent=parent, + parent_location=parent, category=category, display_name=display_name, + modulestore=self.store, publish_item=False, user_id=self.user.id, - **kwargs + **kwargs, + ) + + def get_reverse_url(self, location): + """ + Creates url to current view api name + """ + return reverse( + f"cms.djangoapps.contentstore:v1:{self.view_name}", + kwargs={"usage_key_string": location}, ) + def publish_item(self, store, item_location): + """ + Publish the item at the given location + """ + with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): + store.publish(item_location, ModuleStoreEnum.UserID.test) + + def set_group_access(self, xblock, value): + """ + Sets group_access to specified value and calls update_item to persist the change. + """ + xblock.group_access = value + self.store.update_item(xblock, self.user.id) + + +class ContainerHandlerViewTest(BaseXBlockContainer): + """ + Unit tests for the ContainerHandlerView. + """ + + view_name = "container_handler" + def test_success_response(self): """ Check that endpoint is valid and success response. """ - url = self._get_reverse_url(self.vertical.location) + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_not_valid_usage_key_string(self): + """ + Check that invalid 'usage_key_string' raises Http404. + """ + usage_key_string = ( + "i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent" + ) + url = self.get_reverse_url(usage_key_string) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class ContainerVerticalViewTest(BaseXBlockContainer): + """ + Unit tests for the ContainerVerticalViewTest. + """ + + view_name = "container_vertical" + + def test_success_response(self): + """ + Check that endpoint returns valid response data. + """ + url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["children"]), 2) + self.assertFalse(response.data["is_published"]) + + def test_xblock_is_published(self): + """ + Check that published xBlock container returns. + """ + self.publish_item(self.store, self.vertical.location) + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(url) + self.assertTrue(response.data["is_published"]) + + @override_waffle_flag(ENABLE_TAGGING_TAXONOMY_LIST_PAGE, True) + def test_children_content(self): + """ + Check that returns valid response with children of vertical container. + """ + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(url) + + expected_user_partition_info = { + "selectable_partitions": [], + "selected_partition_index": -1, + "selected_groups_label": "", + } + + expected_user_partitions = [ + { + "id": ENROLLMENT_TRACK_PARTITION_ID, + "name": "Enrollment Track Groups", + "scheme": "enrollment_track", + "groups": [ + {"id": 1, "name": "Audit", "selected": False, "deleted": False} + ], + } + ] + + expected_response = [ + { + "name": self.html_unit_first.display_name_with_default, + "block_id": str(self.html_unit_first.location), + "block_type": self.html_unit_first.location.block_type, + "actions": { + "can_copy": True, + "can_duplicate": True, + "can_move": True, + "can_manage_access": True, + "can_delete": True, + "can_manage_tags": True, + }, + "user_partition_info": expected_user_partition_info, + "user_partitions": expected_user_partitions, + "validation_messages": [], + "render_error": "", + }, + { + "name": self.html_unit_second.display_name_with_default, + "block_id": str(self.html_unit_second.location), + "block_type": self.html_unit_second.location.block_type, + "actions": { + "can_copy": True, + "can_duplicate": True, + "can_move": True, + "can_manage_access": True, + "can_delete": True, + "can_manage_tags": True, + }, + "user_partition_info": expected_user_partition_info, + "user_partitions": expected_user_partitions, + "validation_messages": [], + "render_error": "", + }, + ] + self.assertEqual(response.data["children"], expected_response) def test_not_valid_usage_key_string(self): """ Check that invalid 'usage_key_string' raises Http404. """ - usage_key_string = "i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent" - url = self._get_reverse_url(usage_key_string) + usage_key_string = ( + "i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent" + ) + url = self.get_reverse_url(usage_key_string) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @override_waffle_flag(ENABLE_TAGGING_TAXONOMY_LIST_PAGE, False) + def test_actions_with_turned_off_taxonomy_flag(self): + """ + Check that action manage_tags for each child item has the same value as taxonomy flag. + """ + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(url) + for children in response.data["children"]: + self.assertFalse(children["actions"]["can_manage_tags"]) + + def test_validation_errors(self): + """ + Check that child has an error. + """ + self.course.user_partitions = [ + UserPartition( + 0, + "first_partition", + "Test Partition", + [Group("0", "alpha"), Group("1", "beta")], + ), + ] + self.store.update_item(self.course, self.user.id) + + user_partition = self.course.user_partitions[0] + vertical = self.store.get_item(self.vertical.location) + html_unit_first = self.store.get_item(self.html_unit_first.location) + + group_first = user_partition.groups[0] + group_second = user_partition.groups[1] + + # Set access settings so html will contradict vertical + self.set_group_access(vertical, {user_partition.id: [group_second.id]}) + self.set_group_access(html_unit_first, {user_partition.id: [group_first.id]}) + + # update vertical/html + vertical = self.store.get_item(self.vertical.location) + html_unit_first = self.store.get_item(self.html_unit_first.location) + + url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) - self.assertEqual(response.status_code, 404) + children_response = response.data["children"] + + # Verify that html_unit_first access settings contradict its parent's access settings. + self.assertEqual(children_response[0]["validation_messages"][0]["type"], ValidationMessage.ERROR) + + # Verify that html_unit_second has no validation messages. + self.assertFalse(children_response[1]["validation_messages"]) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py new file mode 100644 index 000000000000..620b40235b73 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py @@ -0,0 +1,90 @@ +""" API Views for course textbooks """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_textbooks_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + CourseTextbooksSerializer, +) +from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + verify_course_exists, + view_auth_classes, +) +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseTextbooksView(DeveloperErrorViewMixin, APIView): + """ + View for course textbooks page. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + ), + ], + responses={ + 200: CourseTextbooksSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course's textbooks. + + **Example Request** + + GET /api/contentstore/v1/textbooks/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's textbooks. + + **Example Response** + + ```json + { + "textbooks": [ + { + "tab_title": "Textbook Name", + "chapters": [ + { + "title": "Chapter 1", + "url": "/static/Present_Perfect.pdf" + }, + { + "title": "Chapter 2", + "url": "/static/Lear.pdf" + } + ], + "id": "Textbook_Name" + } + ] + } + ``` + """ + course_key = CourseKey.from_string(course_id) + store = modulestore() + + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + with store.bulk_operations(course_key): + course = modulestore().get_course(course_key) + textbooks_context = get_textbooks_context(course) + serializer = CourseTextbooksSerializer(textbooks_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 3f9a04851173..d92844cb471c 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -1,37 +1,37 @@ """ API Views for unit page """ import edx_api_doc_tools as apidocs -from django.http import Http404, HttpResponseBadRequest -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey +from django.http import HttpResponseBadRequest from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from cms.djangoapps.contentstore.utils import get_container_handler_context +from cms.djangoapps.contentstore.utils import ( + get_container_handler_context, + get_user_partition_info, + get_visibility_partition_info, + get_xblock_validation_messages, + get_xblock_render_error, +) from cms.djangoapps.contentstore.views.component import _get_item_in_course -from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerHandlerSerializer +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock +from cms.djangoapps.contentstore.rest_api.v1.serializers import ( + ContainerHandlerSerializer, + VerticalContainerSerializer, +) from openedx.core.lib.api.view_utils import view_auth_classes from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order +from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin + @view_auth_classes(is_authenticated=True) -class ContainerHandlerView(APIView): +class ContainerHandlerView(APIView, ContainerHandlerMixin): """ View for container xblock requests to get vertical data. """ - def get_object(self, usage_key_string): - """ - Get an object by usage-id of the block - """ - try: - usage_key = UsageKey.from_string(usage_key_string) - except InvalidKeyError: - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from - return usage_key - @apidocs.schema( parameters=[ apidocs.string_parameter( @@ -123,6 +123,11 @@ def get(self, request: Request, usage_key_string: str): "assets_url": "/assets/course-v1:edX+DemoX+Demo_Course/", "unit_block_id": "d6cee45205a449369d7ef8f159b22bdf", "subsection_location": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations" + "course_sequence_ids": [ + "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations", + "block-v1:edX+DemoX+Demo_Course+type@sequential+block@something_else", + ... + ], } ``` """ @@ -141,3 +146,118 @@ def get(self, request: Request, usage_key_string: str): }) serializer = ContainerHandlerSerializer(context) return Response(serializer.data) + + +@view_auth_classes(is_authenticated=True) +class VerticalContainerView(APIView, ContainerHandlerMixin): + """ + View for container xblock requests to get vertical state and children data. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "usage_key_string", + apidocs.ParameterLocation.PATH, + description="Vertical usage key", + ), + ], + responses={ + 200: VerticalContainerSerializer, + 401: "The requester is not authenticated.", + 404: "The requested locator does not exist.", + }, + ) + def get(self, request: Request, usage_key_string: str): + """ + Get an object containing vertical state with children data. + + **Example Request** + + GET /api/contentstore/v1/container/vertical/{usage_key_string}/children + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the vertical's container children data. + + **Example Response** + + ```json + { + "children": [ + { + "name": "Video", + "block_id": "block-v1:org+101+101+type@video+block@0e3d39b12d7c4345981bda6b3511a9bf", + "block_type": "video", + "actions": { + "can_copy": true, + "can_duplicate": true, + "can_move": true, + "can_manage_access": true, + "can_delete": true, + "can_manage_tags": true, + } + "validation_messages": [], + "render_error": "", + }, + { + "name": "Text", + "block_id": "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690", + "block_type": "html", + "actions": { + "can_copy": true, + "can_duplicate": true, + "can_move": true, + "can_manage_access": true, + "can_delete": true, + "can_manage_tags": true, + }, + "validation_messages": [ + { + "text": "This component's access settings contradict its parent's access settings.", + "type": "error" + } + ], + "render_error": "Unterminated control keyword: 'if' in file '../problem.html'", + }, + ], + "is_published": false, + "can_paste_component": true, + } + ``` + """ + usage_key = self.get_object(usage_key_string) + current_xblock = get_xblock(usage_key, request.user) + + with modulestore().bulk_operations(usage_key.course_key): + # load course once to reuse it for user_partitions query + course = modulestore().get_course(current_xblock.location.course_key) + children = [] + for child in current_xblock.children: + child_info = modulestore().get_item(child) + user_partition_info = get_visibility_partition_info(child_info, course=course) + user_partitions = get_user_partition_info(child_info, course=course) + validation_messages = get_xblock_validation_messages(child_info) + render_error = get_xblock_render_error(request, child_info) + + children.append({ + "name": child_info.display_name_with_default, + "block_id": child_info.location, + "block_type": child_info.location.block_type, + "user_partition_info": user_partition_info, + "user_partitions": user_partitions, + "validation_messages": validation_messages, + "render_error": render_error, + }) + + is_published = not modulestore().has_changes(current_xblock) + container_data = { + "children": children, + "is_published": is_published, + "can_paste_component": True, + } + serializer = VerticalContainerSerializer(container_data) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index ac8469678f78..ed077ec22844 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -497,6 +497,66 @@ def use_new_course_team_page(course_key): return ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE.is_enabled(course_key) +# .. toggle_name: contentstore.new_studio_mfe.use_new_certificates_page +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the use of the new studio course certificates page mfe +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2024-1-18 +# .. toggle_target_removal_date: 2023-4-31 +# .. toggle_tickets: ... +# .. toggle_warning: +ENABLE_NEW_STUDIO_CERTIFICATES_PAGE = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_certificates_page', __name__) + + +def use_new_certificates_page(course_key): + """ + Returns a boolean if new studio certificates mfe is enabled + """ + return ENABLE_NEW_STUDIO_CERTIFICATES_PAGE.is_enabled(course_key) + + +# .. toggle_name: contentstore.new_studio_mfe.use_new_textbooks_page +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the use of the new studio course textbooks page mfe +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2024-1-18 +# .. toggle_target_removal_date: 2023-4-31 +# .. toggle_tickets: ... +# .. toggle_warning: +ENABLE_NEW_STUDIO_TEXTBOOKS_PAGE = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_textbooks_page', __name__) + + +def use_new_textbooks_page(course_key): + """ + Returns a boolean if new studio textbooks mfe is enabled + """ + return ENABLE_NEW_STUDIO_TEXTBOOKS_PAGE.is_enabled(course_key) + + +# .. toggle_name: contentstore.new_studio_mfe.use_new_group_configurations_page +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the use of the new studio course group configurations page mfe +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2024-1-18 +# .. toggle_target_removal_date: 2023-4-31 +# .. toggle_tickets: ... +# .. toggle_warning: +ENABLE_NEW_STUDIO_GROUP_CONFIGURATIONS_PAGE = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_group_configurations_page', __name__) + + +def use_new_group_configurations_page(course_key): + """ + Returns a boolean if new studio group configurations mfe is enabled + """ + return ENABLE_NEW_STUDIO_GROUP_CONFIGURATIONS_PAGE.is_enabled(course_key) + + # .. toggle_name: contentstore.mock_video_uploads # .. toggle_implementation: WaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 3b0be88d4d6b..3d30b8328139 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -69,14 +69,17 @@ split_library_view_on_dashboard, use_new_advanced_settings_page, use_new_course_outline_page, + use_new_certificates_page, use_new_export_page, use_new_files_uploads_page, use_new_grading_page, + use_new_group_configurations_page, use_new_course_team_page, use_new_home_page, use_new_import_page, use_new_schedule_details_page, use_new_text_editor, + use_new_textbooks_page, use_new_unit_page, use_new_updates_page, use_new_video_editor, @@ -427,6 +430,45 @@ def get_unit_url(course_locator, unit_locator) -> str: return unit_url +def get_certificates_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for certificates page view. + """ + unit_url = None + if use_new_certificates_page(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/certificates' + if mfe_base_url: + unit_url = course_mfe_url + return unit_url + + +def get_textbooks_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for textbooks page view. + """ + unit_url = None + if use_new_textbooks_page(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/pages-and-resources/textbooks' + if mfe_base_url: + unit_url = course_mfe_url + return unit_url + + +def get_group_configurations_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for group configurations page view. + """ + unit_url = None + if use_new_group_configurations_page(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/group_configurations' + if mfe_base_url: + unit_url = course_mfe_url + return unit_url + + def get_custom_pages_url(course_locator) -> str: """ Gets course authoring microfrontend URL for custom pages view. @@ -590,6 +632,15 @@ def ancestor_has_staff_lock(xblock, parent_xblock=None): return parent_xblock.visible_to_staff_only +def get_sequence_usage_keys(course): + """ + Extracts a list of 'subsections' usage_keys + """ + return [str(subsection.location) + for section in course.get_children() + for subsection in section.get_children()] + + def reverse_url(handler_name, key_name=None, key_value=None, kwargs=None): """ Creates the URL for the given handler. @@ -1806,6 +1857,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint ) from openedx.core.djangoapps.content_staging import api as content_staging_api + course_sequence_ids = get_sequence_usage_keys(course) component_templates = get_component_templates(course) ancestor_xblocks = [] parent = get_parent_xblock(xblock) @@ -1906,10 +1958,150 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint # Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API. 'user_clipboard': user_clipboard, 'is_fullwidth_content': is_library_xblock, + 'course_sequence_ids': course_sequence_ids, } return context +def get_certificates_context(course, user): + """ + Utils is used to get context for container xblock requests. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.views.certificates import CertificateManager + + course_key = course.id + certificate_url = reverse_course_url('certificates_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + upload_asset_url = reverse_course_url('assets_handler', course_key) + activation_handler_url = reverse_course_url( + handler_name='certificate_activation_handler', + course_key=course_key + ) + course_modes = [ + mode.slug for mode in CourseMode.modes_for_course( + course_id=course_key, include_expired=True + ) if mode.slug != 'audit' + ] + + has_certificate_modes = len(course_modes) > 0 + + if has_certificate_modes: + certificate_web_view_url = get_lms_link_for_certificate_web_view( + course_key=course_key, + mode=course_modes[0] # CourseMode.modes_for_course returns default mode if doesn't find anyone. + ) + else: + certificate_web_view_url = None + + is_active, certificates = CertificateManager.is_activated(course) + context = { + 'context_course': course, + 'certificate_url': certificate_url, + 'course_outline_url': course_outline_url, + 'upload_asset_url': upload_asset_url, + 'certificates': certificates, + 'has_certificate_modes': has_certificate_modes, + 'course_modes': course_modes, + 'certificate_web_view_url': certificate_web_view_url, + 'is_active': is_active, + 'is_global_staff': GlobalStaff().has_user(user), + 'certificate_activation_handler_url': activation_handler_url, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key), + } + return context + + +def get_textbooks_context(course): + """ + Utils is used to get context for textbooks for course. + It is used for both DRF and django views. + """ + + upload_asset_url = reverse_course_url('assets_handler', course.id) + textbook_url = reverse_course_url('textbooks_list_handler', course.id) + return { + 'context_course': course, + 'textbooks': course.pdf_textbooks, + 'upload_asset_url': upload_asset_url, + 'textbook_url': textbook_url, + } + + +def get_group_configurations_context(course, store): + """ + Utils is used to get context for course's group configurations. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.course_group_config import ( + COHORT_SCHEME, ENROLLMENT_SCHEME, GroupConfiguration, RANDOM_SCHEME + ) + from cms.djangoapps.contentstore.views.course import ( + are_content_experiments_enabled + ) + from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order + + course_key = course.id + group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + should_show_experiment_groups = are_content_experiments_enabled(course) + if should_show_experiment_groups: + experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) + else: + experiment_group_configurations = None + + all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) + should_show_enrollment_track = False + has_content_groups = False + displayable_partitions = [] + for partition in all_partitions: + partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) + + if partition['scheme'] == COHORT_SCHEME: + has_content_groups = True + displayable_partitions.append(partition) + elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: + # Add it to the front of the list if it should be shown. + if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: + displayable_partitions.append(partition) + elif partition['scheme'] == ENROLLMENT_SCHEME: + should_show_enrollment_track = len(partition['groups']) > 1 + + # Add it to the front of the list if it should be shown. + if should_show_enrollment_track: + displayable_partitions.insert(0, partition) + elif partition['scheme'] != RANDOM_SCHEME: + # Experiment group configurations are handled explicitly above. We don't + # want to display their groups twice. + displayable_partitions.append(partition) + + # Set the sort-order. Higher numbers sort earlier + scheme_priority = defaultdict(lambda: -1, { + ENROLLMENT_SCHEME: 1, + CONTENT_TYPE_GATING_SCHEME: 0 + }) + displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) + # Add empty content group if there is no COHORT User Partition in the list. + # This will add ability to add new groups in the view. + if not has_content_groups: + displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) + + context = { + 'context_course': course, + 'group_configuration_url': group_configuration_url, + 'course_outline_url': course_outline_url, + 'experiment_group_configurations': experiment_group_configurations, + 'should_show_experiment_groups': should_show_experiment_groups, + 'all_group_configurations': displayable_partitions, + 'should_show_enrollment_track': should_show_enrollment_track, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), + } + + return context + + class StudioPermissionsService: """ Service that can provide information about a user's permissions. @@ -1947,3 +2139,60 @@ def track_course_update_event(course_key, user, event_data=None): context = contexts.course_context_from_course_id(course_key) with tracker.get_tracker().context(event_name, context): tracker.emit(event_name, event_data) + + +def get_xblock_validation_messages(xblock): + """ + Retrieves validation messages for a given xblock. + + Args: + xblock: The xblock object to validate. + + Returns: + list: A list of validation error messages. + """ + validation_json = xblock.validate().to_json() + return validation_json['messages'] + + +def get_xblock_render_error(request, xblock): + """ + Checks if there are any rendering errors for a given block and return these. + + Args: + request: WSGI request object + xblock: The xblock object to rendering. + + Returns: + str: Error message which happened while rendering of xblock. + """ + from cms.djangoapps.contentstore.views.preview import _load_preview_block + from xmodule.studio_editable import has_author_view + from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW + + def get_xblock_render_context(request, block): + """ + Return a dict of the data needs for render of each block. + """ + can_edit = has_studio_write_access(request.user, block.usage_key.course_key) + + return { + "is_unit_page": False, + "can_edit": can_edit, + "root_xblock": xblock, + "reorderable_items": set(), + "paging": None, + "force_render": None, + "item_url": "/container/{block.location}", + "tags_count_map": {}, + } + + try: + block = _load_preview_block(request, xblock) + preview_view = AUTHOR_VIEW if has_author_view(block) else STUDENT_VIEW + render_context = get_xblock_render_context(request, block) + block.render(preview_view, render_context) + except Exception as exc: # pylint: disable=broad-except + return str(exc) + + return "" diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index 3c43ab1065ad..bfc1bf7e9b39 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -30,6 +30,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import HttpResponse +from django.shortcuts import redirect from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods @@ -37,7 +38,6 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import AssetKey, CourseKey -from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_studio_write_access from common.djangoapps.student.roles import GlobalStaff @@ -47,10 +47,11 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from ..exceptions import AssetNotFoundException +from ..toggles import use_new_certificates_page from ..utils import ( - get_lms_link_for_certificate_web_view, - get_proctored_exam_settings_url, - reverse_course_url + get_certificates_context, + get_certificates_url, + reverse_course_url, ) from .assets import delete_asset @@ -393,43 +394,10 @@ def certificates_list_handler(request, course_key_string): return JsonResponse({"error": msg}, status=403) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - certificate_url = reverse_course_url('certificates_list_handler', course_key) - course_outline_url = reverse_course_url('course_handler', course_key) - upload_asset_url = reverse_course_url('assets_handler', course_key) - activation_handler_url = reverse_course_url( - handler_name='certificate_activation_handler', - course_key=course_key - ) - course_modes = [ - mode.slug for mode in CourseMode.modes_for_course( - course_id=course.id, include_expired=True - ) if mode.slug != 'audit' - ] - - has_certificate_modes = len(course_modes) > 0 - - if has_certificate_modes: - certificate_web_view_url = get_lms_link_for_certificate_web_view( - course_key=course_key, - mode=course_modes[0] # CourseMode.modes_for_course returns default mode if doesn't find anyone. - ) - else: - certificate_web_view_url = None - is_active, certificates = CertificateManager.is_activated(course) - return render_to_response('certificates.html', { - 'context_course': course, - 'certificate_url': certificate_url, - 'course_outline_url': course_outline_url, - 'upload_asset_url': upload_asset_url, - 'certificates': certificates, - 'has_certificate_modes': has_certificate_modes, - 'course_modes': course_modes, - 'certificate_web_view_url': certificate_web_view_url, - 'is_active': is_active, - 'is_global_staff': GlobalStaff().has_user(request.user), - 'certificate_activation_handler_url': activation_handler_url, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), - }) + if use_new_certificates_page(course_key): + return redirect(get_certificates_url(course_key)) + certificates_context = get_certificates_context(course, request.user) + return render_to_response('certificates.html', certificates_context) elif "application/json" in request.META.get('HTTP_ACCEPT'): # Retrieve the list of certificates for the specified course if request.method == 'GET': diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index eac3c1048d7a..be6e1aaebd64 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -7,7 +7,6 @@ import random import re import string -from collections import defaultdict from typing import Dict import django.utils @@ -62,8 +61,6 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.lib.course_tabs import CourseTabPluginManager -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from organizations.models import Organization from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.course_block import CourseBlock, CourseFields # lint-amnesty, pylint: disable=wrong-import-order @@ -71,12 +68,10 @@ from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import DuplicateCourseError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import UserPartition # lint-amnesty, pylint: disable=wrong-import-order from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException # lint-amnesty, pylint: disable=wrong-import-order from ..course_group_config import ( COHORT_SCHEME, - ENROLLMENT_SCHEME, RANDOM_SCHEME, GroupConfiguration, GroupConfigurationsValidationError @@ -91,24 +86,30 @@ use_new_updates_page, use_new_advanced_settings_page, use_new_grading_page, - use_new_schedule_details_page + use_new_group_configurations_page, + use_new_schedule_details_page, + use_new_textbooks_page, ) from ..utils import ( add_instructor, - get_course_settings, + get_advanced_settings_url, get_course_grading, + get_course_index_context, + get_course_outline_url, + get_course_rerun_context, + get_course_settings, + get_grading_url, + get_group_configurations_context, + get_group_configurations_url, get_home_context, get_library_context, - get_course_index_context, get_lms_link_for_item, get_proctored_exam_settings_url, - get_course_outline_url, + get_schedule_details_url, get_studio_home_url, + get_textbooks_context, + get_textbooks_url, get_updates_url, - get_advanced_settings_url, - get_grading_url, - get_schedule_details_url, - get_course_rerun_context, initialize_permissions, remove_all_instructors, reverse_course_url, @@ -1347,14 +1348,10 @@ def textbooks_list_handler(request, course_key_string): if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'): # return HTML page - upload_asset_url = reverse_course_url('assets_handler', course_key) - textbook_url = reverse_course_url('textbooks_list_handler', course_key) - return render_to_response('textbooks.html', { - 'context_course': course, - 'textbooks': course.pdf_textbooks, - 'upload_asset_url': upload_asset_url, - 'textbook_url': textbook_url, - }) + if use_new_textbooks_page(course_key): + return redirect(get_textbooks_url(course_key)) + textbooks_context = get_textbooks_context(course) + return render_to_response('textbooks.html', textbooks_context) # from here on down, we know the client has requested JSON if request.method == 'GET': @@ -1518,59 +1515,10 @@ def group_configurations_list_handler(request, course_key_string): course = get_course_and_check_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): - group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) - course_outline_url = reverse_course_url('course_handler', course_key) - should_show_experiment_groups = are_content_experiments_enabled(course) - if should_show_experiment_groups: - experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) - else: - experiment_group_configurations = None - - all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) - should_show_enrollment_track = False - has_content_groups = False - displayable_partitions = [] - for partition in all_partitions: - partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) - - if partition['scheme'] == COHORT_SCHEME: - has_content_groups = True - displayable_partitions.append(partition) - elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: - # Add it to the front of the list if it should be shown. - if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: - displayable_partitions.append(partition) - elif partition['scheme'] == ENROLLMENT_SCHEME: - should_show_enrollment_track = len(partition['groups']) > 1 - - # Add it to the front of the list if it should be shown. - if should_show_enrollment_track: - displayable_partitions.insert(0, partition) - elif partition['scheme'] != RANDOM_SCHEME: - # Experiment group configurations are handled explicitly above. We don't - # want to display their groups twice. - displayable_partitions.append(partition) - - # Set the sort-order. Higher numbers sort earlier - scheme_priority = defaultdict(lambda: -1, { - ENROLLMENT_SCHEME: 1, - CONTENT_TYPE_GATING_SCHEME: 0 - }) - displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) - # Add empty content group if there is no COHORT User Partition in the list. - # This will add ability to add new groups in the view. - if not has_content_groups: - displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) - return render_to_response('group_configurations.html', { - 'context_course': course, - 'group_configuration_url': group_configuration_url, - 'course_outline_url': course_outline_url, - 'experiment_group_configurations': experiment_group_configurations, - 'should_show_experiment_groups': should_show_experiment_groups, - 'all_group_configurations': displayable_partitions, - 'should_show_enrollment_track': should_show_enrollment_track, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course.id), - }) + if use_new_group_configurations_page(course_key): + return redirect(get_group_configurations_url(course_key)) + group_configurations_context = get_group_configurations_context(course, store) + return render_to_response('group_configurations.html', group_configurations_context) elif "application/json" in request.META.get('HTTP_ACCEPT'): if request.method == 'POST': # create a new group configuration for the course diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index 1d5b52905357..21e9a1b2763a 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -34,7 +34,11 @@ def setUp(self): super().setUp() self.vertical = self._create_block(self.sequential, 'vertical', 'Unit') self.html = self._create_block(self.vertical, "html", "HTML") - self.child_container = self._create_block(self.vertical, 'split_test', 'Split Test') + self.child_container = self._create_block( + self.vertical, + 'split_test', + 'Split Test', + ) self.child_vertical = self._create_block(self.child_container, 'vertical', 'Child Vertical') self.video = self._create_block(self.child_vertical, "video", "My Video") self.store = modulestore() diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 8b122d8c8da0..9788c34cc2d4 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -48,6 +48,7 @@ from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE from openedx.core.lib.gating import api as gating_api from openedx.core.lib.cache_utils import request_cached +from openedx.core.lib.xblock_utils import get_icon from openedx.core.toggles import ENTRANCE_EXAMS from xmodule.course_block import DEFAULT_START_DATE from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum @@ -87,6 +88,11 @@ CREATE_IF_NOT_FOUND = ["course_info"] +# List of categories to check for presence in the children of the XBlock. +# This list is used to determine if all of the specified categories are absent +# in the categories of the children XBlock instances otherwise icon class variable will be set to `None`. +CATEGORIES_WITH_ABSENT_ICON = ["split_test"] + # Useful constants for defining predicates NEVER = lambda x: False ALWAYS = lambda x: True @@ -1063,6 +1069,10 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements ) else: user_partitions = get_user_partition_info(xblock, course=course) + all_excluded_categories_absent = all( + category not in [child.category for child in xblock.get_children()] + for category in CATEGORIES_WITH_ABSENT_ICON + ) xblock_info.update( { "edited_on": get_default_time_display(xblock.subtree_edited_on) @@ -1090,6 +1100,7 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "group_access": xblock.group_access, "user_partitions": user_partitions, "show_correctness": xblock.show_correctness, + "xblock_type": get_icon(xblock) if is_xblock_unit and all_excluded_categories_absent else None, } ) diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py index 5afcaac044af..abeecec7ccae 100644 --- a/lms/djangoapps/courseware/block_render.py +++ b/lms/djangoapps/courseware/block_render.py @@ -96,6 +96,8 @@ from common.djangoapps.edxmako.services import MakoService from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core.lib.cache_utils import CacheService +from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order + if TYPE_CHECKING: from rest_framework.request import Request @@ -828,7 +830,23 @@ def _get_block_by_usage_key(usage_key): usage_key.course_key, usage_key ) - raise Http404 from exc + + # Try getting the draft version if the published version for usage_key is not found + is_draft_attempt_successful = False + try: + block = modulestore().get_item(usage_key, revision=ModuleStoreEnum.RevisionOption.draft_only) + block_orig_usage_key, block_orig_version = modulestore().get_block_original_usage(usage_key) + is_draft_attempt_successful = True + except ItemNotFoundError: + log.warning( + "Invalid draft location for course id %s: %s", + usage_key.course_key, + usage_key + ) + raise Http404 from exc + + if not is_draft_attempt_successful: + raise Http404 from exc tracking_context = { 'module': { diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index d0e657775bce..d023cc8e5fab 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -1536,6 +1536,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta set_custom_attribute('block_type', usage_key.block_type) requested_view = request.GET.get('view', 'student_view') + is_authoring_mfe = request.GET.get('is_authoring_mfe') if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in return HttpResponseBadRequest( f"Rendering of the xblock view '{bleach.clean(requested_view, strip=True)}' is not supported." @@ -1651,7 +1652,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), 'on_courseware_page': True, 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), - 'is_learning_mfe': is_learning_mfe, + 'is_learning_mfe': is_authoring_mfe or is_learning_mfe, 'is_mobile_app': is_mobile_app, 'render_course_wide_assets': True, diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index 1d0d4017267c..afacab58a5dc 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -59,6 +59,12 @@ var $$course_id = "${course.id | n, js_escaped_string}"; +% if block: + +% endif + <%block name="js_extra"> @@ -183,6 +189,7 @@ window.parent.postMessage({ type: 'plugin.resize', + location: $$xBlockLocation || '', payload: { width: newWidth, height: newHeight,