diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index beb80bd640ab..ed907c803152 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,22 +1,3 @@ - - ## Description Describe what this pull request changes, and why. Include implications for people using this change. diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 18505b8f1b8b..732ca90c2c2f 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -18,9 +18,9 @@ jobs: # 'pinned' is used to install the latest patch version of Django # within the global constraint i.e. Django==3.2.21 in current case # because we have global constraint of Django<4.2 - django-version: ["pinned", "4.2"] + django-version: ["pinned"] mongo-version: ["4"] - mysql-version: ["5.7", "8"] + mysql-version: ["8"] # excluding mysql5.7 with Django 4.2 since Django 4.2 has # dropped support for MySQL<8 exclude: diff --git a/.tx/config b/.tx/config index 5ab82585d37b..14d0b6e2cf57 100644 --- a/.tx/config +++ b/.tx/config @@ -67,3 +67,14 @@ source_file = conf/locale/en/LC_MESSAGES/wiki.po source_lang = en type = PO +[o:open-edx:p:open-edx-releases:r:release-quince] +file_filter = conf/locale//LC_MESSAGES/django.po +source_file = conf/locale/en/LC_MESSAGES/django.po +source_lang = en +type = PO + +[o:open-edx:p:open-edx-releases:r:release-quince-js] +file_filter = conf/locale//LC_MESSAGES/djangojs.po +source_file = conf/locale/en/LC_MESSAGES/djangojs.po +source_lang = en +type = PO diff --git a/Makefile b/Makefile index bc5a79712e29..089d472bea50 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,8 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile * @# time someone tries to use the outputs. sed '/^django-simple-history==/d' requirements/common_constraints.txt > requirements/common_constraints.tmp mv requirements/common_constraints.tmp requirements/common_constraints.txt + sed 's/Django<4.0//g' requirements/common_constraints.txt > requirements/common_constraints.tmp + mv requirements/common_constraints.tmp requirements/common_constraints.txt pip-compile -v --allow-unsafe ${COMPILE_OPTS} -o requirements/pip.txt requirements/pip.in pip install -r requirements/pip.txt diff --git a/cms/djangoapps/api/v1/serializers/course_runs.py b/cms/djangoapps/api/v1/serializers/course_runs.py index cbd4d09e2181..e1ee6b743034 100644 --- a/cms/djangoapps/api/v1/serializers/course_runs.py +++ b/cms/djangoapps/api/v1/serializers/course_runs.py @@ -5,6 +5,7 @@ from django.db import transaction from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from rest_framework import serializers from rest_framework.fields import empty @@ -198,8 +199,50 @@ def update(self, instance, validated_data): 'display_name': instance.display_name } fields.update(validated_data) - new_course_run_key = rerun_course(user, course_run_key, course_run_key.org, number, run, fields, False) + new_course_run_key = rerun_course( + user, course_run_key, course_run_key.org, number, run, fields, background=False, + ) course_run = get_course_and_check_access(new_course_run_key, user) self.update_team(course_run, team) return course_run + + +class CourseCloneSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring + source_course_id = serializers.CharField() + destination_course_id = serializers.CharField() + + def validate(self, attrs): + source_course_id = attrs.get('source_course_id') + destination_course_id = attrs.get('destination_course_id') + store = modulestore() + source_key = CourseKey.from_string(source_course_id) + dest_key = CourseKey.from_string(destination_course_id) + + # Check if the source course exists + if not store.has_course(source_key): + raise serializers.ValidationError('Source course does not exist.') + + # Check if the destination course already exists + if store.has_course(dest_key): + raise serializers.ValidationError('Destination course already exists.') + return attrs + + def create(self, validated_data): + source_course_id = validated_data.get('source_course_id') + destination_course_id = validated_data.get('destination_course_id') + user = self.context['request'].user + source_course_key = CourseKey.from_string(source_course_id) + destination_course_key = CourseKey.from_string(destination_course_id) + source_course_run = get_course_and_check_access(source_course_key, user) + fields = { + 'display_name': source_course_run.display_name, + } + + destination_course_run_key = rerun_course( + user, source_course_key, destination_course_key.org, destination_course_key.course, + destination_course_key.run, fields, background=False, + ) + + destination_course_run = get_course_and_check_access(destination_course_run_key, user) + return destination_course_run diff --git a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py index 49589a473878..8366ef72941e 100644 --- a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py @@ -402,3 +402,54 @@ def test_rerun_invalid_number(self): assert response.data == {'non_field_errors': [ 'Invalid key supplied. Ensure there are no special characters in the Course Number.' ]} + + def test_clone_course(self): + course = CourseFactory() + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': str(course.id), + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 201 + self.assertEqual(response.data, {"message": "Course cloned successfully."}) + + def test_clone_course_with_missing_source_id(self): + url = reverse('api:v1:course_run-clone') + data = { + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + self.assertEqual(response.data, {'source_course_id': ['This field is required.']}) + + def test_clone_course_with_missing_dest_id(self): + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': 'course-v1:source+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + self.assertEqual(response.data, {'destination_course_id': ['This field is required.']}) + + def test_clone_course_with_nonexistent_source_course(self): + url = reverse('api:v1:course_run-clone') + data = { + 'source_course_id': 'course-v1:nonexistent+source+course_id', + 'destination_course_id': 'course-v1:destination+course+id', + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + assert str(response.data.get('non_field_errors')[0]) == 'Source course does not exist.' + + def test_clone_course_with_existing_dest_course(self): + url = reverse('api:v1:course_run-clone') + course = CourseFactory() + existing_dest_course = CourseFactory() + data = { + 'source_course_id': str(course.id), + 'destination_course_id': str(existing_dest_course.id), + } + response = self.client.post(url, data, format='json') + assert response.status_code == 400 + assert str(response.data.get('non_field_errors')[0]) == 'Destination course already exists.' diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index a0415d4e06dc..45ab02351698 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -13,6 +13,7 @@ from cms.djangoapps.contentstore.views.course import _accessible_courses_iter, get_course_and_check_access from ..serializers.course_runs import ( + CourseCloneSerializer, CourseRunCreateSerializer, CourseRunImageSerializer, CourseRunRerunSerializer, @@ -93,3 +94,11 @@ def rerun(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=miss new_course_run = serializer.save() serializer = self.get_serializer(new_course_run) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['post']) + def clone(self, request, *args, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument + serializer = CourseCloneSerializer(data=request.data, context=self.get_serializer_context()) + serializer.is_valid(raise_exception=True) + new_course_run = serializer.save() + serializer = self.get_serializer(new_course_run) + return Response({"message": "Course cloned successfully."}, status=status.HTTP_201_CREATED) diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index c2cc876f19da..2bfdd6574aad 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -17,6 +17,7 @@ from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError +from xmodule.library_content_block import LibraryContentBlock from xmodule.modulestore.django import modulestore from xmodule.xml_block import XmlMixin @@ -302,6 +303,16 @@ def _import_xml_node_to_parent( # and VAL will thus make the transcript available. child_nodes = [] + + if issubclass(xblock_class, XmlMixin): + # Hack: XBlocks that use "XmlMixin" have their own XML parsing behavior, and in particular if they encounter + # an XML node that has no children and has only a "url_name" attribute, they'll try to load the XML data + # from an XML file in runtime.resources_fs. But that file doesn't exist here. So we set at least one + # additional attribute here to make sure that url_name is not the only attribute; otherwise in some cases, + # XmlMixin.parse_xml will try to load an XML file that doesn't exist, giving an error. The name and value + # of this attribute don't matter and should be ignored. + node.attrib["x-is-pointer-node"] = "no" + if not xblock_class.has_children: # No children to worry about. The XML may contain child nodes, but they're not XBlocks. temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator) @@ -314,14 +325,6 @@ def _import_xml_node_to_parent( # serialization of a child block, in order. For blocks that don't support children, their XML content/nodes # could be anything (e.g. HTML, capa) node_without_children = etree.Element(node.tag, **node.attrib) - if issubclass(xblock_class, XmlMixin): - # Hack: XBlocks that use "XmlMixin" have their own XML parsing behavior, and in particular if they encounter - # an XML node that has no children and has only a "url_name" attribute, they'll try to load the XML data - # from an XML file in runtime.resources_fs. But that file doesn't exist here. So we set at least one - # additional attribute here to make sure that url_name is not the only attribute; otherwise in some cases, - # XmlMixin.parse_xml will try to load an XML file that doesn't exist, giving an error. The name and value - # of this attribute don't matter and should be ignored. - node_without_children.attrib["x-is-pointer-node"] = "no" temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys, id_generator) child_nodes = list(node) if xblock_class.has_children and temp_xblock.children: @@ -334,8 +337,14 @@ def _import_xml_node_to_parent( new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True) parent_xblock.children.append(new_xblock.location) store.update_item(parent_xblock, user_id) - for child_node in child_nodes: - _import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id) + if isinstance(new_xblock, LibraryContentBlock): + # Special case handling for library content. If we need this for other blocks in the future, it can be made into + # an API, and we'd call new_block.studio_post_paste() instead of this code. + # In this case, we want to pull the children from the library and let library_tools assign their IDs. + new_xblock.tools.update_children(new_xblock, version=new_xblock.source_library_version) + else: + for child_node in child_nodes: + _import_xml_node_to_parent(child_node, new_xblock, store, user_id=user_id) return new_xblock diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 1eb70347399c..42fde9a3f992 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1368,6 +1368,16 @@ def test_create_course_with_unicode_in_id_disabled(self): self.course_data['run'] = '����������' self.assert_create_course_failed(error_message) + @override_settings(DEFAULT_COURSE_INVITATION_ONLY=True) + def test_create_course_invitation_only(self): + """ + Test new course creation with setting: DEFAULT_COURSE_INVITATION_ONLY=True. + """ + test_course_data = self.assert_created_course() + course_id = _get_course_id(self.store, test_course_data) + course = self.store.get_course(course_id) + self.assertEqual(course.invitation_only, True) + def assert_course_permission_denied(self): """ Checks that the course did not get created due to a PermissionError. diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 1a4b709622e6..e392a32f3ad6 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1372,7 +1372,8 @@ def get_course_grading(course_key): 'grading_url': reverse_course_url('grading_handler', course_key), 'is_credit_course': is_credit_course(course_key), 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key), - 'course_assignment_lists': dict(course_assignment_lists) + 'course_assignment_lists': dict(course_assignment_lists), + 'default_grade_designations': settings.DEFAULT_GRADE_DESIGNATIONS } return grading_context diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index a55bb3db9a53..3ae5b0aec519 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -932,8 +932,9 @@ def create_new_course_in_store(store, user, org, number, run, fields): # Set default language from settings and enable web certs fields.update({ - 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), 'cert_html_view_enabled': True, + 'invitation_only': getattr(settings, 'DEFAULT_COURSE_INVITATION_ONLY', False), + 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), }) with modulestore().default_store(store): @@ -971,6 +972,12 @@ def rerun_course(user, source_course_key, org, number, run, fields, background=T if store.has_course(destination_course_key, ignore_case=True): raise DuplicateCourseError(source_course_key, destination_course_key) + # if org or name of source course don't match the destination course, + # verify user has access to the destination course + if source_course_key.org != destination_course_key.org or source_course_key.course != destination_course_key.course: + if not has_studio_write_access(user, destination_course_key): + raise PermissionDenied() + # Make sure user has instructor and staff access to the destination course # so the user can see the updated status for that course add_instructor(destination_course_key, user, user) diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 429630ac8d1b..bdf22532fd5f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -3,16 +3,22 @@ allow users to paste XBlocks that were copied using the staged_content/clipboard APIs. """ +import ddt +from django.test import LiveServerTestCase from opaque_keys.edx.keys import UsageKey from rest_framework.test import APIClient -from xmodule.modulestore.django import contentstore +from xmodule.modulestore.django import contentstore, modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course -from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, LibraryFactory, ToyCourseFactory + +from cms.djangoapps.contentstore.utils import reverse_usage_url +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/" XBLOCK_ENDPOINT = "/xblock/" +@ddt.ddt class ClipboardPasteTestCase(ModuleStoreTestCase): """ Test Clipboard Paste functionality @@ -99,6 +105,41 @@ def test_copy_and_paste_unit(self): # The new block should store a reference to where it was copied from assert dest_unit.copied_from_block == str(unit_key) + @ddt.data( + # A problem with absolutely no fields set. A previous version of copy-paste had an error when pasting this. + {"category": "problem", "display_name": None, "data": ""}, + ) + def test_copy_and_paste_component(self, block_args): + """ + Test copying a component (XBlock) from one course into another + """ + source_course = CourseFactory.create(display_name='Source Course') + source_block = BlockFactory.create(parent_location=source_course.location, **block_args) + + dest_course = CourseFactory.create(display_name='Destination Course') + with self.store.bulk_operations(dest_course.id): + dest_chapter = BlockFactory.create(parent=dest_course, category='chapter', display_name='Section') + dest_sequential = BlockFactory.create(parent=dest_chapter, category='sequential', display_name='Subsection') + + # Copy the block + client = APIClient() + client.login(username=self.user.username, password=self.user_password) + copy_response = client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(source_block.location)}, format="json") + assert copy_response.status_code == 200 + + # Paste the unit + paste_response = client.post(XBLOCK_ENDPOINT, { + "parent_locator": str(dest_sequential.location), + "staged_content": "clipboard", + }, format="json") + assert paste_response.status_code == 200 + dest_block_key = UsageKey.from_string(paste_response.json()["locator"]) + + dest_block = self.store.get_item(dest_block_key) + assert dest_block.display_name == source_block.display_name + # The new block should store a reference to where it was copied from + assert dest_block.copied_from_block == str(source_block.location) + def test_paste_with_assets(self): """ When pasting into a different course, any required static assets should @@ -167,3 +208,79 @@ def test_paste_with_assets(self): source_pic2_hash = contentstore().find(source_course.id.make_asset_key("asset", "picture2.jpg")).content_digest dest_pic2_hash = contentstore().find(dest_course_key.make_asset_key("asset", "picture2.jpg")).content_digest assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. + + +class ClipboardLibraryContentPasteTestCase(LiveServerTestCase, ModuleStoreTestCase): + """ + Test Clipboard Paste functionality with library content + """ + + def setUp(self): + """ + Set up a v2 Content Library and a library content block + """ + super().setUp() + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=self.user_password) + self.store = modulestore() + + def test_paste_library_content_block_v1(self): + """ + Same as the above test, but uses modulestore (v1) content library + """ + library = LibraryFactory.create() + data = { + 'parent_locator': str(library.location), + 'category': 'html', + 'display_name': 'HTML Content', + } + response = self.client.ajax_post(XBLOCK_ENDPOINT, data) + self.assertEqual(response.status_code, 200) + course = CourseFactory.create(display_name='Course') + orig_lc_block = BlockFactory.create( + parent=course, + category="library_content", + source_library_id=str(library.location.library_key), + display_name="LC Block", + publish_item=False, + ) + orig_lc_block.refresh_children() + orig_child = self.store.get_item(orig_lc_block.children[0]) + assert orig_child.display_name == "HTML Content" + # Copy a library content block that has children: + copy_response = self.client.post(CLIPBOARD_ENDPOINT, { + "usage_key": str(orig_lc_block.location) + }, format="json") + assert copy_response.status_code == 200 + + # Paste the Library content block: + paste_response = self.client.ajax_post(XBLOCK_ENDPOINT, { + "parent_locator": str(course.location), + "staged_content": "clipboard", + }) + assert paste_response.status_code == 200 + dest_lc_block_key = UsageKey.from_string(paste_response.json()["locator"]) + + # Get the ID of the new child: + dest_lc_block = self.store.get_item(dest_lc_block_key) + dest_child = self.store.get_item(dest_lc_block.children[0]) + assert dest_child.display_name == "HTML Content" + + # Importantly, the ID of the child must not changed when the library content is synced. + # Otherwise, user state saved against this child will be lost when it syncs. + dest_lc_block.refresh_children() + updated_dest_child = self.store.get_item(dest_lc_block.children[0]) + assert dest_child.location == updated_dest_child.location + + def _sync_lc_block_from_library(self, attr_name): + """ + Helper method to "sync" a Library Content Block by [re-]fetching its + children from the library. + """ + usage_key = getattr(self, attr_name).location + # It's easiest to do this via the REST API: + handler_url = reverse_usage_url('preview_handler', usage_key, kwargs={'handler': 'upgrade_and_sync'}) + response = self.client.post(handler_url) + assert response.status_code == 200 + # Now reload the block and make sure the child is in place + setattr(self, attr_name, self.store.get_item(usage_key)) # we must reload after upgrade_and_sync diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 425f97e3751f..4f02098b68e2 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -1394,9 +1394,6 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements else: xblock_info["staff_only_message"] = False - # If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, we show the newer menu that allows copying/pasting - xblock_info["enable_copy_paste_units"] = ENABLE_COPY_PASTE_UNITS.is_enabled() - xblock_info[ "has_partition_group_components" ] = has_children_visible_to_specific_partition_groups(xblock) @@ -1404,6 +1401,10 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements xblock, course=course ) + if course_outline or is_xblock_unit: + # If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, we show the newer menu that allows copying/pasting + xblock_info["enable_copy_paste_units"] = ENABLE_COPY_PASTE_UNITS.is_enabled() + if is_xblock_unit and summary_configuration.is_enabled(): xblock_info["summary_configuration_enabled"] = summary_configuration.is_summary_enabled(xblock_info['id']) diff --git a/cms/envs/common.py b/cms/envs/common.py index 10c70bc98215..b85fa90c0229 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -531,6 +531,17 @@ # .. toggle_creation_date: 2023-03-31 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/32015 'DISABLE_ADVANCED_SETTINGS': False, + + # .. toggle_name: FEATURES['ENABLE_LEGACY_MD5_HASH_FOR_ANONYMOUS_USER_ID'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Whether to enable the legacy MD5 hashing algorithm to generate anonymous user id + # instead of the newer SHAKE128 hashing algorithm + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2022-08-08 + # .. toggle_target_removal_date: None + # .. toggle_tickets: 'https://github.com/openedx/edx-platform/pull/30832' + 'ENABLE_LEGACY_MD5_HASH_FOR_ANONYMOUS_USER_ID': False, } # .. toggle_name: ENABLE_COPPA_COMPLIANCE @@ -831,7 +842,6 @@ CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = '' CROSS_DOMAIN_CSRF_COOKIE_NAME = '' CSRF_TRUSTED_ORIGINS = [] -CSRF_TRUSTED_ORIGINS_WITH_SCHEME = [] #################### CAPA External Code Evaluation ############################# XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds @@ -1086,7 +1096,8 @@ } DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -DEFAULT_HASHING_ALGORITHM = 'sha1' +# This will be overridden through CMS config +DEFAULT_HASHING_ALGORITHM = 'sha256' #################### Python sandbox ############################################ @@ -2273,7 +2284,6 @@ ############################ OAUTH2 Provider ################################### - # 5 minute expiration time for JWT id tokens issued for external API requests. OAUTH_ID_TOKEN_EXPIRATION = 5 * 60 @@ -2289,6 +2299,12 @@ API_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/latest/' AUTH_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html' +EDX_DRF_EXTENSIONS = { + # Set this value to an empty dict in order to prevent automatically updating + # user data from values in (possibly stale) JWTs. + 'JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING': {}, +} + ############## Settings for Studio Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini" @@ -2366,6 +2382,14 @@ # Rate limit for regrading tasks that a grading policy change can kick off POLICY_CHANGE_TASK_RATE_LIMIT = '900/h' +# .. setting_name: DEFAULT_GRADE_DESIGNATIONS +# .. setting_default: ['A', 'B', 'C', 'D'] +# .. setting_description: The default 'pass' grade cutoff designations to be used. The failure grade +# is always 'F' and should not be included in this list. +# .. setting_warning: The DEFAULT_GRADE_DESIGNATIONS list must have more than one designation, +# or else ['A', 'B', 'C', 'D'] will be used as the default grade designations. +DEFAULT_GRADE_DESIGNATIONS = ['A', 'B', 'C', 'D'] + ############## Settings for CourseGraph ############################ # .. setting_name: COURSEGRAPH_JOB_QUEUE @@ -2792,3 +2816,7 @@ #### Event bus publishing #### ## Will be more filled out as part of https://github.com/edx/edx-arch-experiments/issues/381 EVENT_BUS_PRODUCER_CONFIG = {} + +############## Default value for invitation_only when creating courses ############## + +DEFAULT_COURSE_INVITATION_ONLY = False diff --git a/cms/envs/production.py b/cms/envs/production.py index 213243fa8237..d04dfcd8acc0 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -14,7 +14,6 @@ import yaml from corsheaders.defaults import default_headers as corsheaders_default_headers -import django from django.core.exceptions import ImproperlyConfigured from django.urls import reverse_lazy from edx_django_utils.plugins import add_plugins @@ -237,11 +236,6 @@ def get_env_setting(setting): # by end users. CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) -# values are already updated above with default CSRF_TRUSTED_ORIGINS values but in -# case of new django version these values will override. -if django.VERSION[0] >= 4: # for greater than django 3.2 use schemes. - CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) - #Email overrides MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) MKTG_URL_OVERRIDES.update(ENV_TOKENS.get('MKTG_URL_OVERRIDES', MKTG_URL_OVERRIDES)) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index c8ab1a469145..5f970a89d592 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -75,6 +75,7 @@ function( $body.click(function() { $('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown'); $('.nav-dd .nav-item .title').removeClass('is-selected'); + $('.custom-dropdown .dropdown-options').hide(); }); $('.nav-dd .nav-item, .filterable-column .nav-item').click(function(e) { diff --git a/cms/static/js/factories/settings_graders.js b/cms/static/js/factories/settings_graders.js index dc75029e0f26..57e811b7ed3f 100644 --- a/cms/static/js/factories/settings_graders.js +++ b/cms/static/js/factories/settings_graders.js @@ -3,7 +3,7 @@ define([ ], function($, GradingView, CourseGradingPolicyModel) { 'use strict'; - return function(courseDetails, gradingUrl, courseAssignmentLists) { + return function(courseDetails, gradingUrl, gradeDesignations, courseAssignmentLists) { var model, editor; $('form :input') @@ -19,7 +19,8 @@ define([ editor = new GradingView({ el: $('.settings-grading'), model: model, - courseAssignmentLists: courseAssignmentLists + courseAssignmentLists: courseAssignmentLists, + gradeDesignations: gradeDesignations }); editor.render(); }; diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index cde0b42d8109..28ea2a4b9196 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -581,33 +581,6 @@ describe('Container Subviews', function() { }); }); - describe('PublishHistory', function() { - var lastPublishCss = '.wrapper-last-publish'; - - it('renders never published when the block is unpublished', function() { - renderContainerPage(this, mockContainerXBlockHtml, { - published: false, published_on: null, published_by: null - }); - expect(containerPage.$(lastPublishCss).text()).toContain('Never published'); - }); - - it('renders the last published date and user when the block is published', function() { - renderContainerPage(this, mockContainerXBlockHtml); - fetch({ - published: true, published_on: 'Jul 01, 2014 at 12:45 UTC', published_by: 'amako' - }); - expect(containerPage.$(lastPublishCss).text()) - .toContain('Last published Jul 01, 2014 at 12:45 UTC by amako'); - }); - - it('renders correctly when the block is published without publish info', function() { - renderContainerPage(this, mockContainerXBlockHtml); - fetch({ - published: true, published_on: null, published_by: null - }); - expect(containerPage.$(lastPublishCss).text()).toContain('Previously published'); - }); - }); describe('Message Area', function() { var messageSelector = '.container-message .warning', diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 8f296ebb6b58..10f9636b08b8 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -69,6 +69,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView model: this.model }); this.messageView.render(); + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); // Display access message on units and split test components if (!this.isLibraryPage) { this.containerAccessView = new ContainerSubviews.ContainerAccess({ @@ -81,7 +82,8 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView el: this.$('#publish-unit'), model: this.model, // When "Discard Changes" is clicked, the whole page must be re-rendered. - renderPage: this.render + renderPage: this.render, + clipboardBroadcastChannel: this.clipboardBroadcastChannel, }); this.xblockPublisher.render(); @@ -105,7 +107,6 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); - this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); }, getViewParameters: function() { @@ -158,6 +159,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView if (!self.isLibraryPage && !self.isLibraryContentPage) { self.initializePasteButton(); } + }, block_added: options && options.block_added }); diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index fc7f807257ca..a31fdfc23b95 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -106,7 +106,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H events: { 'click .action-publish': 'publish', 'click .action-discard': 'discardChanges', - 'click .action-staff-lock': 'toggleStaffLock' + 'click .action-staff-lock': 'toggleStaffLock', + 'click .action-copy': 'copyToClipboard' }, // takes XBlockInfo as a model @@ -116,6 +117,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H this.template = this.loadTemplate('publish-xblock'); this.model.on('sync', this.onSync, this); this.renderPage = this.options.renderPage; + this.clipboardBroadcastChannel = this.options.clipboardBroadcastChannel; }, onSync: function(model) { @@ -147,6 +149,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H releaseDateFrom: this.model.get('release_date_from'), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), staffLockFrom: this.model.get('staff_lock_from'), + enableCopyUnit: this.model.get('enable_copy_paste_units'), course: window.course, HtmlUtils: HtmlUtils }) @@ -173,6 +176,50 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H }); }, + copyToClipboard: function(e) { + e.preventDefault(); + e.stopPropagation(); + const clipboardEndpoint = "/api/content-staging/v1/clipboard/"; + const usageKeyToCopy = this.model.get('id'); + // Start showing a "Copying" notification: + ViewUtils.runOperationShowingMessage(gettext('Copying'), () => { + return $.postJSON( + clipboardEndpoint, + { usage_key: usageKeyToCopy }, + ).then((data) => { + const status = data.content?.status; + if (status === "ready") { + // something that enables the paste button in the actions dropdown + this.clipboardBroadcastChannel.postMessage(data); + return data; + } else if (status === "loading") { + // The clipboard is being loaded asynchonously. + // Poll the endpoint until the copying process is complete: + const deferred = $.Deferred(); + const checkStatus = () => { + $.getJSON(clipboardEndpoint, (pollData) => { + const newStatus = pollData.content?.status; + if (newStatus === "ready") { + // something that enables the paste button in actions dropdown + this.clipboardBroadcastChannel.postMessage(pollData); + deferred.resolve(pollData); + } else if (newStatus === "loading") { + setTimeout(checkStatus, 1_000); + } else { + deferred.reject(); + throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`); + } + }) + } + setTimeout(checkStatus, 1_000); + return deferred; + } else { + throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); + } + }); + }); + }, + discardChanges: function(e) { var xblockInfo = this.model, renderPage = this.renderPage; diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index ac4d170352a2..3d383ea457af 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -34,6 +34,7 @@ function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { $('#course_grade_cutoff-tpl').text() ); this.setupCutoffs(); + this.setupGradeDesignations(options.gradeDesignations); this.listenTo(this.model, 'invalid', this.handleValidationError); this.listenTo(this.model, 'change', this.showNotificationBar); @@ -318,7 +319,7 @@ function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { addNewGrade: function(e) { e.preventDefault(); var gradeLength = this.descendingCutoffs.length; // cutoffs doesn't include fail/f so this is only the passing grades - if (gradeLength > 3) { + if (gradeLength > this.GRADES.length - 1) { // TODO shouldn't we disable the button return; } @@ -399,7 +400,9 @@ function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { this.descendingCutoffs = _.sortBy(this.descendingCutoffs, function(gradeEle) { return -gradeEle.cutoff; }); }, - revertView: function() { + setupGradeDesignations: function(gradeDesignations) { + if (Array.isArray(gradeDesignations) && gradeDesignations.length > 1) { this.GRADES = gradeDesignations; } + },revertView: function() { var self = this; this.model.fetch({ success: function() { diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index d3c1fce9e00e..9abe0866ed48 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -8,7 +8,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState, getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo, - moveXBlock; + moveXBlock, pasteXBlock; /** * Represents the possible visibility states for an xblock: @@ -69,6 +69,85 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { }); }; + pasteXBlock = function(target) { + var parentLocator = target.data('parent'), + displayName = target.data('default-name'); + + return ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { + return $.postJSON(ModuleUtils.getUpdateUrl(), { + parent_locator: parentLocator, + staged_content: "clipboard", + }).then((data) => { + return data; + }); + }).done((data) => { + const { + conflicting_files: conflictingFiles, + error_files: errorFiles, + new_files: newFiles, + } = data.static_file_notices; + + const notices = []; + if (errorFiles.length) { + notices.push((next) => new PromptView.Error({ + title: gettext("Some errors occurred"), + message: ( + gettext("The following required files could not be added to the course:") + + " " + errorFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (conflictingFiles.length) { + notices.push((next) => new PromptView.Warning({ + title: gettext("You may need to update a file(s) manually"), + message: ( + gettext( + "The following files already exist in this course but don't match the " + + "version used by the component you pasted:" + ) + " " + conflictingFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (newFiles.length) { + notices.push(() => new NotificationView.Info({ + title: gettext("New file(s) added to Files & Uploads."), + message: ( + gettext("The following required files were imported to this course:") + + " " + newFiles.join(", ") + ), + actions: { + primary: { + text: gettext('View files'), + click: function(notification) { + const article = document.querySelector('[data-course-assets]'); + const assetsUrl = $(article).attr('data-course-assets'); + window.location.href = assetsUrl; + return; + } + }, + secondary: { + text: gettext('Dismiss'), + click: function(notification) { + return notification.hide(); + } + } + } + })); + } + if (notices.length) { + // Show the notices, one at a time: + const showNext = () => { + const view = notices.shift()(showNext); + view.show(); + } + // Delay to avoid conflict with the "Pasting..." notification. + setTimeout(showNext, 1250); + } + }); + }; + /** * Duplicates the specified xblock element in its parent xblock. * @param {jquery Element} xblockElement The xblock element to be duplicated. @@ -308,6 +387,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { getXBlockListTypeClass: getXBlockListTypeClass, updateXBlockFields: updateXBlockFields, getXBlockType: getXBlockType, - findXBlockInfo: findXBlockInfo + findXBlockInfo: findXBlockInfo, + pasteXBlock: pasteXBlock }; }); diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js index 6b913d5239da..adedd2e2c093 100644 --- a/cms/static/js/views/xblock.js +++ b/cms/static/js/views/xblock.js @@ -14,6 +14,10 @@ function($, _, ViewUtils, BaseView, XBlock, HtmlUtils) { 'click .notification-action-button': 'fireNotificationActionEvent' }, + options: { + clipboardData: { content: null }, + }, + initialize: function() { BaseView.prototype.initialize.call(this); this.view = this.options.view; diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index 97ff8b8aacfb..453108c0e548 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -288,6 +288,11 @@ $seq-nav-height: 40px; ol { display: flex; + .custom-dropdown { + position: relative; + display: inline-flex; + } + li { box-sizing: border-box; min-width: 40px; @@ -300,6 +305,47 @@ $seq-nav-height: 40px; @include border-right-style(solid); } + .dropdown-main-button { + border-right: 1px solid #e7e7e7 !important; + } + + .dropdown-toggle-button { + width: 15% !important; + + &:hover { + border-bottom: 1px solid #e7e7e7 !important; + } + } + + .dropdown-options { + position: absolute; + top: 100%; + z-index: 1000; + background-color: #ffffff; + min-width: 265px; + right: 0; + + li { + padding: 0.5em 1em; + cursor: pointer; + + a { + display: block; + width: 100%; + color: black; + } + + .checkmark { + float: right; + margin-left: 10px; + } + } + } + + .dropdown-options li:hover { + background-color: #f1f1f1; + } + button { @extend %ui-fake-link; @extend %ui-clear-button; diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index f3cfed83ef94..9f74e625fb17 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -325,6 +325,12 @@ .action-secondary { @extend %t-action4; + cursor: pointer; + color: $white; + + &:hover { + color: $gray-l3; + } } } } diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index cf8824ca5110..4f5fafcc60f6 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -239,6 +239,19 @@ color: $gray-l1; } } + + .action-copy { + width: 100%; + border-color: #0075b4; + padding-top: 10px; + padding-bottom: 10px; + line-height: 24px; + border-radius: 4px; + + &:hover { + @extend %btn-primary-blue; + } + } } } diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index cc62fd436f0d..c571af4b8cb8 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -777,23 +777,23 @@ height: 17px; } - &:nth-child(1) { + &:nth-child(5n+1) { background: #4fe696; } - &:nth-child(2) { + &:nth-child(5n+2) { background: #ffdf7e; } - &:nth-child(3) { + &:nth-child(5n+3) { background: #ffb657; } - &:nth-child(4) { + &:nth-child(5n+4) { background: #ef54a1; } - &:nth-child(5), + &:nth-child(5n+5), &.bar-fail { background: #fb336c; } diff --git a/cms/templates/container.html b/cms/templates/container.html index 2f83c0c6d5e1..f72ab7ea4874 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -53,26 +53,57 @@ clipboardData: ${user_clipboard | n, dump_js_escaped_json}, } ); - require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils) { + + require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils", "gettext"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils, gettext) { var model = new XBlockInfo({ id: '${subsection.location|n, decode.utf8}' }); var xblockView = new XBlockView({ model: model, el: $('#sequence-nav'), - view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}' + view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}', + clipboardData: ${user_clipboard | n, dump_js_escaped_json}, }); + xblockView.xblockReady = function() { - $('.seq_new_button').click(function(evt) { - evt.preventDefault(); - XBlockUtils.addXBlock($(evt.target)).done(function(locator) { + + var toggleCaretButton = function(clipboardData) { + if (clipboardData && clipboardData.content && clipboardData.source_usage_key.includes("vertical")) { + $('.dropdown-toggle-button').show(); + } else { + $('.dropdown-toggle-button').hide(); + $('.dropdown-options').hide(); + } + }; + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); + this.clipboardBroadcastChannel.onmessage = (event) => { + toggleCaretButton(event.data); + }; + toggleCaretButton(this.options.clipboardData); + + $('#new-unit-button').on('click', function(event) { + event.preventDefault(); + XBlockUtils.addXBlock($(this)).done(function(locator) { ViewUtils.redirect('/container/' + locator + '?action=new'); - return false; }); - return false; }); + $('.custom-dropdown .dropdown-toggle-button').on('click', function(event) { + event.stopPropagation(); // Prevent the event from closing immediately when we open it + $(this).next('.dropdown-options').slideToggle('fast'); // This toggles the dropdown visibility + var isExpanded = $(this).attr('aria-expanded') === 'true'; + $(this).attr('aria-expanded', !isExpanded); + }); + + $('.seq_paste_unit').on('click', function(event) { + event.preventDefault(); + $('.dropdown-options').hide(); + XBlockUtils.pasteXBlock($(this)).done(function(data) { + ViewUtils.redirect('/container/' + data.locator + '?action=new'); + }); + }); }; + xblockView.render(); }); diff --git a/cms/templates/js/publish-history.underscore b/cms/templates/js/publish-history.underscore index ca8648503385..354faa55ee7e 100644 --- a/cms/templates/js/publish-history.underscore +++ b/cms/templates/js/publish-history.underscore @@ -10,8 +10,3 @@ if (published_on && published_by) { copy = gettext("Previously published"); } %> - -
- <% // xss-lint: disable=underscore-not-escaped %> -

<%= copy %>

-
diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore index cc78ba256456..869db999f481 100644 --- a/cms/templates/js/publish-xblock.underscore +++ b/cms/templates/js/publish-xblock.underscore @@ -128,4 +128,16 @@ var visibleToStaffOnly = visibilityState === 'staff_only'; + <% if (enableCopyUnit) { %> +
+
    +
  • + +
  • +
+
+ <% } %> diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index c3b6f8f73a2a..1c8f0019bf3e 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -36,6 +36,7 @@ { is_credit_course: ${is_credit_course | n, dump_js_escaped_json} } ), "${grading_url | n, js_escaped_string}", + ${default_grade_designations | n, dump_js_escaped_json}, ${course_assignment_lists | n, dump_js_escaped_json}, ); }); diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6dd94ec78c55..34ea5584c428 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -324,7 +324,7 @@

${_("Tools")} % else: - + % endif