diff --git a/cms/djangoapps/contentstore/api/__init__.py b/cms/djangoapps/contentstore/api/__init__.py index e69de29bb2d1..6662d35370c3 100644 --- a/cms/djangoapps/contentstore/api/__init__.py +++ b/cms/djangoapps/contentstore/api/__init__.py @@ -0,0 +1,2 @@ +"""Contentstore API""" +from .views.utils import course_author_access_required diff --git a/cms/djangoapps/contentstore/api/urls.py b/cms/djangoapps/contentstore/api/urls.py index 6d8158c4c88f..2e5fb009ed05 100644 --- a/cms/djangoapps/contentstore/api/urls.py +++ b/cms/djangoapps/contentstore/api/urls.py @@ -6,7 +6,9 @@ from cms.djangoapps.contentstore.api.views import course_import, course_quality, course_validation + app_name = 'contentstore' +helper = "{0,1}" urlpatterns = [ re_path(fr'^v0/import/{settings.COURSE_ID_PATTERN}/$', @@ -15,4 +17,5 @@ course_validation.CourseValidationView.as_view(), name='course_validation'), re_path(fr'^v1/quality/{settings.COURSE_ID_PATTERN}/$', course_quality.CourseQualityView.as_view(), name='course_quality'), + ] diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py index 1e9f1b75d31d..2dfa72e2d067 100644 --- a/cms/djangoapps/contentstore/exams.py +++ b/cms/djangoapps/contentstore/exams.py @@ -14,7 +14,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from .views.helpers import is_item_in_course_tree +from .helpers import is_item_in_course_tree log = logging.getLogger(__name__) User = get_user_model() diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py new file mode 100644 index 000000000000..cbaf4b8f80cf --- /dev/null +++ b/cms/djangoapps/contentstore/helpers.py @@ -0,0 +1,286 @@ +""" +Helper methods for Studio views. +""" + +import urllib +from lxml import etree + +from django.utils.translation import gettext as _ +from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import DefinitionLocator, LocalId +from xblock.core import XBlock +from xblock.fields import ScopeIds +from xblock.runtime import IdGenerator +from xmodule.modulestore.django import modulestore + +# from cms.djangoapps.contentstore.views.preview import _load_preview_block +from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from common.djangoapps.student import auth +from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole + +try: + # Technically this is a django app plugin, so we should not error if it's not installed: + import openedx.core.djangoapps.content_staging.api as content_staging_api +except ImportError: + content_staging_api = None + +from .utils import reverse_course_url, reverse_library_url, reverse_usage_url + +# Note: Grader types are used throughout the platform but most usages are simply in-line +# strings. In addition, new grader types can be defined on the fly anytime one is needed +# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio. +GRADER_TYPES = { + "HOMEWORK": "Homework", + "LAB": "Lab", + "ENTRANCE_EXAM": "Entrance Exam", + "MIDTERM_EXAM": "Midterm Exam", + "FINAL_EXAM": "Final Exam" +} + + +def get_parent_xblock(xblock): + """ + Returns the xblock that is the parent of the specified xblock, or None if it has no parent. + """ + locator = xblock.location + parent_location = modulestore().get_parent_location(locator) + + if parent_location is None: + return None + return modulestore().get_item(parent_location) + + +def is_unit(xblock, parent_xblock=None): + """ + Returns true if the specified xblock is a vertical that is treated as a unit. + A unit is a vertical that is a direct child of a sequential (aka a subsection). + """ + if xblock.category == 'vertical': + if parent_xblock is None: + parent_xblock = get_parent_xblock(xblock) + parent_category = parent_xblock.category if parent_xblock else None + return parent_category == 'sequential' + return False + + +def xblock_has_own_studio_page(xblock, parent_xblock=None): + """ + Returns true if the specified xblock has an associated Studio page. Most xblocks do + not have their own page but are instead shown on the page of their parent. There + are a few exceptions: + 1. Courses + 2. Verticals that are either: + - themselves treated as units + - a direct child of a unit + 3. XBlocks that support children + """ + category = xblock.category + + if is_unit(xblock, parent_xblock): + return True + elif category == 'vertical': + if parent_xblock is None: + parent_xblock = get_parent_xblock(xblock) + return is_unit(parent_xblock) if parent_xblock else False + + # All other xblocks with children have their own page + return xblock.has_children + + +def xblock_studio_url(xblock, parent_xblock=None, find_parent=False): + """ + Returns the Studio editing URL for the specified xblock. + + You can pass the parent xblock as an optimization, to avoid needing to load + it twice, as sometimes the parent has to be checked. + + If you pass in a leaf block that doesn't have its own Studio page, this will + normally return None, but if you use find_parent=True, this will find the + nearest ancestor (usually the parent unit) that does have a Studio page and + return that URL. + """ + if not xblock_has_own_studio_page(xblock, parent_xblock): + if find_parent: + while xblock and not xblock_has_own_studio_page(xblock, parent_xblock): + xblock = parent_xblock or get_parent_xblock(xblock) + parent_xblock = None + if not xblock: + return None + else: + return None + category = xblock.category + if category == 'course': + return reverse_course_url('course_handler', xblock.location.course_key) + elif category in ('chapter', 'sequential'): + return '{url}?show={usage_key}'.format( + url=reverse_course_url('course_handler', xblock.location.course_key), + usage_key=urllib.parse.quote(str(xblock.location)) + ) + elif category == 'library': + library_key = xblock.location.course_key + return reverse_library_url('library_handler', library_key) + else: + return reverse_usage_url('container_handler', xblock.location) + + +def xblock_type_display_name(xblock, default_display_name=None): + """ + Returns the display name for the specified type of xblock. Note that an instance can be passed in + for context dependent names, e.g. a vertical beneath a sequential is a Unit. + + :param xblock: An xblock instance or the type of xblock (as a string). + :param default_display_name: The default value to return if no display name can be found. + :return: + """ + + if hasattr(xblock, 'category'): + category = xblock.category + if category == 'vertical' and not is_unit(xblock): + return _('Vertical') + else: + category = xblock + if category == 'chapter': + return _('Section') + elif category == 'sequential': + return _('Subsection') + elif category == 'vertical': + return _('Unit') + elif category == 'problem': + # The problem XBlock's display_name.default is not helpful ("Blank Advanced Problem") but changing it could have + # too many ripple effects in other places, so we have a special case for capa problems here. + # Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific + # description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type + # string ("problem"). + return _('Problem') + component_class = XBlock.load_class(category) + if hasattr(component_class, 'display_name') and component_class.display_name.default: + return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string + else: + return default_display_name + + +def xblock_primary_child_category(xblock): + """ + Returns the primary child category for the specified xblock, or None if there is not a primary category. + """ + category = xblock.category + if category == 'course': + return 'chapter' + elif category == 'chapter': + return 'sequential' + elif category == 'sequential': + return 'vertical' + return None + + +def remove_entrance_exam_graders(course_key, user): + """ + Removes existing entrance exam graders attached to the specified course + Typically used when adding/removing an entrance exam. + """ + grading_model = CourseGradingModel.fetch(course_key) + graders = grading_model.graders + for i, grader in enumerate(graders): + if grader['type'] == GRADER_TYPES['ENTRANCE_EXAM']: + CourseGradingModel.delete_grader(course_key, i, user) + + +class ImportIdGenerator(IdGenerator): + """ + Modulestore's IdGenerator doesn't work for importing single blocks as OLX, + so we implement our own + """ + + def __init__(self, context_key): + super().__init__() + self.context_key = context_key + + def create_aside(self, definition_id, usage_id, aside_type): + """ Generate a new aside key """ + raise NotImplementedError() + + def create_usage(self, def_id) -> UsageKey: + """ Generate a new UsageKey for an XBlock """ + # Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved. + return self.context_key.make_usage_key(def_id.block_type, LocalId()) + + def create_definition(self, block_type, slug=None) -> DefinitionLocator: + """ Generate a new definition_id for an XBlock """ + # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. + return DefinitionLocator(block_type, LocalId(block_type)) + + +def import_staged_content_from_user_clipboard(parent_key: UsageKey, request): + """ + Import a block (and any children it has) from "staged" OLX. + Does not deal with permissions or REST stuff - do that before calling this. + + Returns the newly created block on success or None if the clipboard is + empty. + """ + + from cms.djangoapps.contentstore.views.preview import _load_preview_block + + if not content_staging_api: + raise RuntimeError("The required content_staging app is not installed") + user_clipboard = content_staging_api.get_user_clipboard(request.user.id) + if not user_clipboard: + # Clipboard is empty or expired/error/loading + return None + block_type = user_clipboard.content.block_type + olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id) + node = etree.fromstring(olx_str) + store = modulestore() + with store.bulk_operations(parent_key.course_key): + parent_descriptor = store.get_item(parent_key) + # Some blocks like drag-and-drop only work here with the full XBlock runtime loaded: + + parent_xblock = _load_preview_block(request, parent_descriptor) + runtime = parent_xblock.runtime + # Generate the new ID: + id_generator = ImportIdGenerator(parent_key.context_key) + def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id) + usage_id = id_generator.create_usage(def_id) + keys = ScopeIds(None, block_type, def_id, usage_id) + # parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either + # one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores + # 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'. + # For children of this block, obviously only id_generator is used. + xblock_class = runtime.load_block_type(block_type) + temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator) + if xblock_class.has_children and temp_xblock.children: + raise NotImplementedError("We don't yet support pasting XBlocks with children") + temp_xblock.parent = parent_key + # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin) + temp_xblock.copied_from_block = str(user_clipboard.source_usage_key) + # Save the XBlock into modulestore. We need to save the block and its parent for this to work: + new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True) + parent_xblock.children.append(new_xblock.location) + store.update_item(parent_xblock, request.user.id) + return new_xblock + + +def is_item_in_course_tree(item): + """ + Check that the item is in the course tree. + + It's possible that the item is not in the course tree + if its parent has been deleted and is now an orphan. + """ + ancestor = item.get_parent() + while ancestor is not None and ancestor.location.block_type != "course": + ancestor = ancestor.get_parent() + + return ancestor is not None + + +def is_content_creator(user, org): + """ + Check if the user has the role to create content. + + This function checks if the User has role to create content + or if the org is supplied, it checks for Org level course content + creator. + """ + return (auth.user_has_role(user, CourseCreatorRole()) or + auth.user_has_role(user, OrgContentCreatorRole(org=org))) diff --git a/cms/djangoapps/contentstore/management/commands/delete_orphans.py b/cms/djangoapps/contentstore/management/commands/delete_orphans.py index 267af09a5443..8ee199cd0f00 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_orphans.py +++ b/cms/djangoapps/contentstore/management/commands/delete_orphans.py @@ -5,7 +5,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from cms.djangoapps.contentstore.views.block import _delete_orphans +from cms.djangoapps.contentstore.xblock_services.xblock_service import delete_orphans from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -29,14 +29,14 @@ def handle(self, *args, **options): if options['commit']: print('Deleting orphans from the course:') - deleted_items = _delete_orphans( + deleted_items = delete_orphans( course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit'] ) print("Success! Deleted the following orphans from the course:") print("\n".join(deleted_items)) else: print('Dry run. The following orphans would have been deleted from the course:') - deleted_items = _delete_orphans( + deleted_items = delete_orphans( course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit'] ) print("\n".join(deleted_items)) diff --git a/cms/djangoapps/contentstore/proctoring.py b/cms/djangoapps/contentstore/proctoring.py index d265232f83e3..9b289bb9fee7 100644 --- a/cms/djangoapps/contentstore/proctoring.py +++ b/cms/djangoapps/contentstore/proctoring.py @@ -23,7 +23,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from .views.helpers import is_item_in_course_tree +from .helpers import is_item_in_course_tree log = logging.getLogger(__name__) diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_proctoring.py deleted file mode 100644 index 1caeeacf9981..000000000000 --- a/cms/djangoapps/contentstore/rest_api/v1/tests/test_proctoring.py +++ /dev/null @@ -1,466 +0,0 @@ -""" -Unit tests for Contentstore views. -""" -import ddt -from mock import patch -from django.conf import settings -from django.test.utils import override_settings -from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag -from opaque_keys.edx.keys import CourseKey -from rest_framework import status -from rest_framework.test import APITestCase - -from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from common.djangoapps.student.tests.factories import GlobalStaffFactory -from common.djangoapps.student.tests.factories import InstructorFactory -from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order - -from ..mixins import PermissionAccessMixin - - -class ProctoringExamSettingsTestMixin(): - """ setup for proctored exam settings tests """ - def setUp(self): - super().setUp() - self.course_key = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course') - self.other_course_key = CourseKey.from_string('course-v1:edX+ToyX_Other_Course+Toy_Course') - self.course = self.create_course_from_course_key(self.course_key) - self.other_course = self.create_course_from_course_key(self.other_course_key) - self.password = 'password' - self.student = UserFactory.create(username='student', password=self.password) - self.global_staff = GlobalStaffFactory(username='global-staff', password=self.password) - self.course_instructor = InstructorFactory( - username='instructor', - password=self.password, - course_key=self.course.id, - ) - self.other_course_instructor = InstructorFactory( - username='other-course-instructor', - password=self.password, - course_key=self.other_course.id, - ) - - def tearDown(self): - super().tearDown() - self.client.logout() - - @classmethod - def create_course_from_course_key(cls, course_key): - return CourseFactory.create( - org=course_key.org, - course=course_key.course, - run=course_key.run - ) - - def make_request(self, course_id=None, data=None): - raise NotImplementedError - - def get_url(self, course_key): - return reverse( - 'cms.djangoapps.contentstore:v1:proctored_exam_settings', - kwargs={'course_id': course_key} - ) - - def test_403_if_student(self): - self.client.login(username=self.student.username, password=self.password) - response = self.make_request() - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_403_if_instructor_in_another_course(self): - self.client.login( - username=self.other_course_instructor.username, - password=self.password - ) - response = self.make_request() - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_404_no_course_block(self): - course_id = 'course-v1:edX+ToyX_Nonexistent_Course+Toy_Course' - self.client.login(username=self.global_staff, password=self.password) - response = self.make_request(course_id=course_id) - assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.data == { - 'detail': f'Course with course_id {course_id} does not exist.' - } - - -class ProctoringExamSettingsGetTests(ProctoringExamSettingsTestMixin, ModuleStoreTestCase, APITestCase): - """ Tests for proctored exam settings GETs """ - @classmethod - def get_expected_response_data(cls, course, user): # pylint: disable=unused-argument - return { - 'proctored_exam_settings': { - 'enable_proctored_exams': course.enable_proctored_exams, - 'allow_proctoring_opt_out': course.allow_proctoring_opt_out, - 'proctoring_provider': course.proctoring_provider, - 'proctoring_escalation_email': course.proctoring_escalation_email, - 'create_zendesk_tickets': course.create_zendesk_tickets, - }, - 'course_start_date': '2030-01-01T00:00:00Z', - 'available_proctoring_providers': ['null'], - } - - def make_request(self, course_id=None, data=None): - course_id = course_id if course_id else self.course.id - url = self.get_url(course_id) - return self.client.get(url) - - def test_200_global_staff(self): - self.client.login(username=self.global_staff.username, password=self.password) - response = self.make_request() - assert response.status_code == status.HTTP_200_OK - assert response.data == self.get_expected_response_data(self.course, self.global_staff) - - def test_200_course_instructor(self): - self.client.login(username=self.course_instructor.username, password=self.password) - response = self.make_request() - assert response.status_code == status.HTTP_200_OK - assert response.data == self.get_expected_response_data(self.course, self.course_instructor) - - @override_waffle_flag(EXAMS_IDA, active=False) - def test_providers_with_disabled_lti(self): - self.client.login(username=self.course_instructor.username, password=self.password) - response = self.make_request() - assert response.status_code == status.HTTP_200_OK - - # expected data should not include lti_external value - expected_data = { - 'proctored_exam_settings': { - 'enable_proctored_exams': self.course.enable_proctored_exams, - 'allow_proctoring_opt_out': self.course.allow_proctoring_opt_out, - 'proctoring_provider': self.course.proctoring_provider, - 'proctoring_escalation_email': self.course.proctoring_escalation_email, - 'create_zendesk_tickets': self.course.create_zendesk_tickets, - }, - 'course_start_date': '2030-01-01T00:00:00Z', - 'available_proctoring_providers': ['null'], - } - assert response.data == expected_data - - @override_waffle_flag(EXAMS_IDA, active=True) - def test_providers_with_enabled_lti(self): - self.client.login(username=self.course_instructor.username, password=self.password) - response = self.make_request() - assert response.status_code == status.HTTP_200_OK - - # expected data should include lti_external value - expected_data = { - 'proctored_exam_settings': { - 'enable_proctored_exams': self.course.enable_proctored_exams, - 'allow_proctoring_opt_out': self.course.allow_proctoring_opt_out, - 'proctoring_provider': self.course.proctoring_provider, - 'proctoring_escalation_email': self.course.proctoring_escalation_email, - 'create_zendesk_tickets': self.course.create_zendesk_tickets, - }, - 'course_start_date': '2030-01-01T00:00:00Z', - 'available_proctoring_providers': ['lti_external', 'null'], - } - assert response.data == expected_data - - -@ddt.ddt -class ProctoringExamSettingsPostTests(ProctoringExamSettingsTestMixin, ModuleStoreTestCase, APITestCase): - """ Tests for proctored exam settings POST """ - - @classmethod - def get_request_data( # pylint: disable=missing-function-docstring - cls, - enable_proctored_exams=False, - allow_proctoring_opt_out=True, - proctoring_provider='null', - proctoring_escalation_email='example@edx.org', - create_zendesk_tickets=True, - ): - return { - 'proctored_exam_settings': { - 'enable_proctored_exams': enable_proctored_exams, - 'allow_proctoring_opt_out': allow_proctoring_opt_out, - 'proctoring_provider': proctoring_provider, - 'proctoring_escalation_email': proctoring_escalation_email, - 'create_zendesk_tickets': create_zendesk_tickets, - } - } - - def make_request(self, course_id=None, data=None): - course_id = course_id if course_id else self.course.id - url = self.get_url(course_id) - if data is None: - data = self.get_request_data() - return self.client.post(url, data, format='json') - - @override_settings( - PROCTORING_BACKENDS={ - 'DEFAULT': 'proctortrack', - 'proctortrack': {} - }, - ) - def test_update_exam_settings_200_escalation_email(self): - """ update exam settings for provider that requires an escalation email (proctortrack) """ - self.client.login(username=self.global_staff.username, password=self.password) - data = self.get_request_data( - enable_proctored_exams=True, - proctoring_provider='proctortrack', - proctoring_escalation_email='foo@bar.com', - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_200_OK - self.assertDictEqual(response.data, { - 'proctored_exam_settings': { - 'enable_proctored_exams': True, - 'allow_proctoring_opt_out': True, - 'proctoring_provider': 'proctortrack', - 'proctoring_escalation_email': 'foo@bar.com', - 'create_zendesk_tickets': True, - } - }) - - # course settings have been updated - updated = modulestore().get_item(self.course.location) - assert updated.enable_proctored_exams is True - assert updated.proctoring_provider == 'proctortrack' - assert updated.proctoring_escalation_email == 'foo@bar.com' - - @override_settings( - PROCTORING_BACKENDS={ - 'DEFAULT': 'test_proctoring_provider', - 'test_proctoring_provider': {} - }, - ) - def test_update_exam_settings_200_no_escalation_email(self): - """ escalation email may be blank if not required by the provider """ - self.client.login(username=self.global_staff.username, password=self.password) - data = self.get_request_data( - enable_proctored_exams=True, - proctoring_provider='test_proctoring_provider', - proctoring_escalation_email=None - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_200_OK - self.assertDictEqual(response.data, { - 'proctored_exam_settings': { - 'enable_proctored_exams': True, - 'allow_proctoring_opt_out': True, - 'proctoring_provider': 'test_proctoring_provider', - 'proctoring_escalation_email': None, - 'create_zendesk_tickets': True, - } - }) - - # course settings have been updated - updated = modulestore().get_item(self.course.location) - assert updated.enable_proctored_exams is True - assert updated.proctoring_provider == 'test_proctoring_provider' - assert updated.proctoring_escalation_email is None - - def test_update_exam_settings_excluded_field(self): - """ - Excluded settings in POST data should not be updated - """ - self.client.login(username=self.global_staff.username, password=self.password) - data = self.get_request_data( - proctoring_escalation_email='foo@bar.com', - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_200_OK - self.assertDictEqual(response.data, { - 'proctored_exam_settings': { - 'enable_proctored_exams': False, - 'allow_proctoring_opt_out': True, - 'proctoring_provider': 'null', - 'proctoring_escalation_email': None, - 'create_zendesk_tickets': True, - } - }) - - # excluded course settings are not updated - updated = modulestore().get_item(self.course.location) - assert updated.proctoring_escalation_email is None - - @override_settings( - PROCTORING_BACKENDS={ - 'DEFAULT': 'null', - 'test_proctoring_provider': {} - }, - ) - def test_update_exam_settings_invalid_value(self): - self.client.login(username=self.global_staff.username, password=self.password) - PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES - PROCTORED_EXAMS_ENABLED_FEATURES['ENABLE_PROCTORED_EXAMS'] = True - with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): - data = self.get_request_data( - enable_proctored_exams=True, - proctoring_provider='notvalidprovider', - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual(response.data, { - 'detail': [{ - 'proctoring_provider': "The selected proctoring provider, notvalidprovider, is not a valid provider. " - "Please select from one of ['test_proctoring_provider']." - }] - }) - - # course settings have been updated - updated = modulestore().get_item(self.course.location) - assert updated.enable_proctored_exams is False - assert updated.proctoring_provider == 'null' - - def test_403_if_instructor_request_includes_opting_out(self): - self.client.login(username=self.course_instructor, password=self.password) - data = self.get_request_data() - response = self.make_request(data=data) - assert response.status_code == status.HTTP_403_FORBIDDEN - - @override_settings( - PROCTORING_BACKENDS={ - 'DEFAULT': 'proctortrack', - 'proctortrack': {} - }, - ) - def test_200_for_instructor_request_compatibility(self): - self.client.login(username=self.course_instructor, password=self.password) - data = { - 'proctored_exam_settings': { - 'enable_proctored_exams': True, - 'proctoring_provider': 'proctortrack', - 'proctoring_escalation_email': 'foo@bar.com', - } - } - response = self.make_request(data=data) - assert response.status_code == status.HTTP_200_OK - - @override_settings( - PROCTORING_BACKENDS={ - 'DEFAULT': 'proctortrack', - 'proctortrack': {}, - 'software_secure': {}, - }, - ) - @patch('logging.Logger.info') - @ddt.data( - ('proctortrack', False, False), - ('software_secure', True, False), - ('proctortrack', True, True), - ('software_secure', False, True), - ) - @ddt.unpack - def test_nonadmin_with_zendesk_ticket(self, proctoring_provider, create_zendesk_tickets, expect_log, logger_mock): - self.client.login(username=self.course_instructor, password=self.password) - data = { - 'proctored_exam_settings': { - 'enable_proctored_exams': True, - 'proctoring_provider': proctoring_provider, - 'proctoring_escalation_email': 'foo@bar.com', - 'create_zendesk_tickets': create_zendesk_tickets, - } - } - response = self.make_request(data=data) - assert response.status_code == status.HTTP_200_OK - if expect_log: - logger_string = ( - 'create_zendesk_tickets set to {ticket_value} but proctoring ' - 'provider is {provider} for course {course_id}. create_zendesk_tickets ' - 'should be updated for this course.'.format( - ticket_value=create_zendesk_tickets, - provider=proctoring_provider, - course_id=self.course.id - ) - ) - logger_mock.assert_any_call(logger_string) - - updated = modulestore().get_item(self.course.location) - assert updated.create_zendesk_tickets is create_zendesk_tickets - - @override_waffle_flag(EXAMS_IDA, active=True) - def test_200_for_lti_provider(self): - self.client.login(username=self.global_staff.username, password=self.password) - PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES - PROCTORED_EXAMS_ENABLED_FEATURES['ENABLE_PROCTORED_EXAMS'] = True - with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): - data = self.get_request_data( - enable_proctored_exams=True, - proctoring_provider='lti_external', - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_200_OK - - self.assertDictEqual(response.data, { - 'proctored_exam_settings': { - 'enable_proctored_exams': True, - 'allow_proctoring_opt_out': True, - 'proctoring_provider': 'lti_external', - 'proctoring_escalation_email': None, - 'create_zendesk_tickets': True, - } - }) - - # course settings have been updated - updated = modulestore().get_item(self.course.location) - assert updated.enable_proctored_exams is True - assert updated.proctoring_provider == 'lti_external' - - @override_waffle_flag(EXAMS_IDA, active=False) - def test_400_for_disabled_lti(self): - self.client.login(username=self.global_staff.username, password=self.password) - PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES - PROCTORED_EXAMS_ENABLED_FEATURES['ENABLE_PROCTORED_EXAMS'] = True - with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): - data = self.get_request_data( - enable_proctored_exams=True, - proctoring_provider='lti_external', - ) - response = self.make_request(data=data) - - # response is correct - assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual(response.data, { - 'detail': [{ - 'proctoring_provider': "The selected proctoring provider, lti_external, is not a valid provider. " - "Please select from one of ['null']." - }] - }) - - # course settings have been updated - updated = modulestore().get_item(self.course.location) - assert updated.enable_proctored_exams is False - assert updated.proctoring_provider == 'null' - - -@ddt.ddt -class CourseProctoringErrorsViewTest(CourseTestCase, PermissionAccessMixin): - """ - Tests for ProctoringErrorsView. - """ - - def setUp(self): - super().setUp() - self.url = reverse( - 'cms.djangoapps.contentstore:v1:proctoring_errors', - kwargs={"course_id": self.course.id}, - ) - self.non_staff_client, _ = self.create_non_staff_authed_user_client() - - @ddt.data(False, True) - def test_disable_advanced_settings_feature(self, disable_advanced_settings): - """ - If this feature is enabled, only Django Staff/Superuser should be able to see the proctoring errors. - For non-staff users the proctoring errors should be unavailable. - """ - with override_settings(FEATURES={'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings}): - response = self.non_staff_client.get(self.url) - self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index caaffc4ed6a3..4fc1d0af4240 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -1,6 +1,7 @@ """ Contenstore API v1 URLs. """ from django.urls import re_path +from django.conf import settings from openedx.core.constants import COURSE_ID_PATTERN @@ -9,7 +10,8 @@ CourseGradingView, CourseSettingsView, ProctoredExamSettingsView, - ProctoringErrorsView + ProctoringErrorsView, + xblock ) app_name = 'v1' @@ -40,4 +42,8 @@ CourseGradingView.as_view(), name="course_grading" ), + re_path( + fr'^xblock/{settings.COURSE_ID_PATTERN}/{settings.USAGE_KEY_PATTERN}?$', + xblock.XblockView.as_view(), name='studio_content' + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index d2b14ab7d65b..1de2139d6f9a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -5,3 +5,4 @@ from .grading import CourseGradingView from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView from .settings import CourseSettingsView +from .xblock import XblockView diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/__init__.py similarity index 100% rename from cms/djangoapps/contentstore/rest_api/v1/tests/__init__.py rename to cms/djangoapps/contentstore/rest_api/v1/views/tests/__init__.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py similarity index 81% rename from cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py rename to cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py index 8cc62ce28c13..cbc2fdc98c3f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_details.py @@ -10,7 +10,7 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from ..mixins import PermissionAccessMixin +from ...mixins import PermissionAccessMixin @ddt.ddt @@ -22,7 +22,7 @@ class CourseDetailsViewTest(CourseTestCase, PermissionAccessMixin): def setUp(self): super().setUp() self.url = reverse( - 'cms.djangoapps.contentstore:v1:course_details', + "cms.djangoapps.contentstore:v1:course_details", kwargs={"course_id": self.course.id}, ) @@ -46,13 +46,17 @@ def test_put_permissions_unauthorized(self): self.assertEqual(error, "You do not have permission to perform this action.") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_PREREQUISITE_COURSES": True}) def test_put_invalid_pre_requisite_course(self): - pre_requisite_course_keys = [str(self.course.id), 'invalid_key'] + pre_requisite_course_keys = [str(self.course.id), "invalid_key"] request_data = {"pre_requisite_courses": pre_requisite_course_keys} - response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json") + response = self.client.put( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()['error'], 'Invalid prerequisite course key') + self.assertEqual(response.json()["error"], "Invalid prerequisite course key") def test_put_course_details(self): request_data = { @@ -75,13 +79,10 @@ def test_put_course_details(self): "entrance_exam_minimum_score_pct": "50", "intro_video": None, "language": "creative-commons: ver=4.0 BY NC ND", - "learning_info": [ - "foo", - "bar" - ], + "learning_info": ["foo", "bar"], "license": "creative-commons: ver=4.0 BY NC ND", "org": "edX", - "overview": "
", + "overview": '
', "pre_requisite_courses": [], "run": "course", "self_paced": None, @@ -99,10 +100,14 @@ def test_put_course_details(self): "title": "title", "organization": "org", "image": "image", - "bio": "" + "bio": "", } ] }, } - response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json") + response = self.client.put( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py similarity index 70% rename from cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py rename to cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py index 00060075bd5e..f12bf13afe54 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_grading.py @@ -13,7 +13,7 @@ from cms.djangoapps.models.settings.course_grading import CourseGradingModel from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory -from ..mixins import PermissionAccessMixin +from ...mixins import PermissionAccessMixin @ddt.ddt @@ -25,27 +25,29 @@ class CourseGradingViewTest(CourseTestCase, PermissionAccessMixin): def setUp(self): super().setUp() self.url = reverse( - 'cms.djangoapps.contentstore:v1:course_grading', + "cms.djangoapps.contentstore:v1:course_grading", kwargs={"course_id": self.course.id}, ) def test_course_grading_response(self): - """ Check successful response content """ + """Check successful response content""" response = self.client.get(self.url) grading_data = CourseGradingModel.fetch(self.course.id) expected_response = { - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(self.course.id), - 'course_assignment_lists': {}, - 'course_details': grading_data.__dict__, - 'show_credit_eligibility': False, - 'is_credit_course': False, + "mfe_proctored_exam_settings_url": get_proctored_exam_settings_url( + self.course.id + ), + "course_assignment_lists": {}, + "course_details": grading_data.__dict__, + "show_credit_eligibility": False, + "is_credit_course": False, } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREDIT_ELIGIBILITY': True}) + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_CREDIT_ELIGIBILITY": True}) def test_credit_eligibility_setting(self): """ Make sure if the feature flag is enabled we have enabled values in response. @@ -53,8 +55,8 @@ def test_credit_eligibility_setting(self): _ = CreditCourseFactory(course_key=self.course.id, enabled=True) response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(response.data['show_credit_eligibility']) - self.assertTrue(response.data['is_credit_course']) + self.assertTrue(response.data["show_credit_eligibility"]) + self.assertTrue(response.data["is_credit_course"]) def test_post_permissions_unauthenticated(self): """ @@ -76,9 +78,11 @@ def test_post_permissions_unauthorized(self): self.assertEqual(error, "You do not have permission to perform this action.") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - @patch('openedx.core.djangoapps.credit.tasks.update_credit_course_requirements.delay') + @patch( + "openedx.core.djangoapps.credit.tasks.update_credit_course_requirements.delay" + ) def test_post_course_grading(self, mock_update_credit_course_requirements): - """ Check successful request with called task """ + """Check successful request with called task""" request_data = { "graders": [ { @@ -87,22 +91,18 @@ def test_post_course_grading(self, mock_update_credit_course_requirements): "drop_count": 0, "short_label": "", "weight": 100, - "id": 0 + "id": 0, } ], - "grade_cutoffs": { - "A": 0.75, - "B": 0.63, - "C": 0.57, - "D": 0.5 - }, - "grace_period": { - "hours": 12, - "minutes": 0 - }, + "grade_cutoffs": {"A": 0.75, "B": 0.63, "C": 0.57, "D": 0.5}, + "grace_period": {"hours": 12, "minutes": 0}, "minimum_grade_credit": 0.7, - "is_credit_course": True + "is_credit_course": True, } - response = self.client.post(path=self.url, data=json.dumps(request_data), content_type="application/json") + response = self.client.post( + path=self.url, + data=json.dumps(request_data), + content_type="application/json", + ) self.assertEqual(response.status_code, status.HTTP_200_OK) mock_update_credit_course_requirements.assert_called_once() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py new file mode 100644 index 000000000000..971b2b996ab9 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py @@ -0,0 +1,456 @@ +""" +Unit tests for Contentstore Proctored Exam Settings. +""" +import ddt +from mock import patch +from django.conf import settings +from django.test.utils import override_settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from rest_framework import status +from rest_framework.test import APITestCase + +from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA +from xmodule.modulestore.django import ( + modulestore, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, +) # lint-amnesty, pylint: disable=wrong-import-order + +from ...mixins import PermissionAccessMixin + + +class ProctoringExamSettingsTestcase(AuthorizeStaffTestCase): + """setup for proctored exam settings tests""" + + def get_url(self, course_key): + return reverse( + "cms.djangoapps.contentstore:v1:proctored_exam_settings", + kwargs={"course_id": course_key}, + ) + + def test_404_no_course_block(self): + course_id = "course-v1:edX+ToyX_Nonexistent_Course+Toy_Course" + self.client.login(username=self.global_staff, password=self.password) + response = self.make_request(course_id=course_id) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data == { + "detail": f"Course with course_id {course_id} does not exist." + } + + +class ProctoringExamSettingsGetTests( + ProctoringExamSettingsTestcase, ModuleStoreTestCase, APITestCase +): + """Tests for proctored exam settings GETs""" + + @classmethod + def get_expected_response_data( + cls, course, user + ): # pylint: disable=unused-argument + return { + "proctored_exam_settings": { + "enable_proctored_exams": course.enable_proctored_exams, + "allow_proctoring_opt_out": course.allow_proctoring_opt_out, + "proctoring_provider": course.proctoring_provider, + "proctoring_escalation_email": course.proctoring_escalation_email, + "create_zendesk_tickets": course.create_zendesk_tickets, + }, + "course_start_date": "2030-01-01T00:00:00Z", + "available_proctoring_providers": ["null"], + } + + def make_request(self, course_id=None, data=None): + course_id = course_id if course_id else self.course.id + url = self.get_url(course_id) + return self.client.get(url) + + def test_global_staff(self, expect_status=status.HTTP_200_OK): + response = super().test_global_staff(expect_status=expect_status) + assert response.data == self.get_expected_response_data( + self.course, self.global_staff + ) + + def test_course_instructor(self, expect_status=status.HTTP_200_OK): + response = super().test_course_instructor(expect_status=expect_status) + assert response.data == self.get_expected_response_data( + self.course, self.course_instructor + ) + + @override_waffle_flag(EXAMS_IDA, active=False) + def test_providers_with_disabled_lti(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request() + assert response.status_code == status.HTTP_200_OK + + # expected data should not include lti_external value + expected_data = { + "proctored_exam_settings": { + "enable_proctored_exams": self.course.enable_proctored_exams, + "allow_proctoring_opt_out": self.course.allow_proctoring_opt_out, + "proctoring_provider": self.course.proctoring_provider, + "proctoring_escalation_email": self.course.proctoring_escalation_email, + "create_zendesk_tickets": self.course.create_zendesk_tickets, + }, + "course_start_date": "2030-01-01T00:00:00Z", + "available_proctoring_providers": ["null"], + } + assert response.data == expected_data + + @override_waffle_flag(EXAMS_IDA, active=True) + def test_providers_with_enabled_lti(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request() + assert response.status_code == status.HTTP_200_OK + + # expected data should include lti_external value + expected_data = { + "proctored_exam_settings": { + "enable_proctored_exams": self.course.enable_proctored_exams, + "allow_proctoring_opt_out": self.course.allow_proctoring_opt_out, + "proctoring_provider": self.course.proctoring_provider, + "proctoring_escalation_email": self.course.proctoring_escalation_email, + "create_zendesk_tickets": self.course.create_zendesk_tickets, + }, + "course_start_date": "2030-01-01T00:00:00Z", + "available_proctoring_providers": ["lti_external", "null"], + } + assert response.data == expected_data + + +@ddt.ddt +class ProctoringExamSettingsPostTests( + ProctoringExamSettingsTestcase, ModuleStoreTestCase, APITestCase +): + """Tests for proctored exam settings POST""" + + @classmethod + def get_request_data( # pylint: disable=missing-function-docstring + cls, + enable_proctored_exams=False, + allow_proctoring_opt_out=True, + proctoring_provider="null", + proctoring_escalation_email="example@edx.org", + create_zendesk_tickets=True, + ): + return { + "proctored_exam_settings": { + "enable_proctored_exams": enable_proctored_exams, + "allow_proctoring_opt_out": allow_proctoring_opt_out, + "proctoring_provider": proctoring_provider, + "proctoring_escalation_email": proctoring_escalation_email, + "create_zendesk_tickets": create_zendesk_tickets, + } + } + + def make_request(self, course_id=None, data=None): + course_id = course_id if course_id else self.course.id + url = self.get_url(course_id) + if data is None: + data = self.get_request_data() + return self.client.post(url, data, format="json") + + def test_course_instructor(self, expect_status=status.HTTP_403_FORBIDDEN): + return super().test_course_instructor(expect_status=expect_status) + + @override_settings( + PROCTORING_BACKENDS={"DEFAULT": "proctortrack", "proctortrack": {}}, + ) + def test_update_exam_settings_200_escalation_email(self): + """update exam settings for provider that requires an escalation email (proctortrack)""" + self.client.login(username=self.global_staff.username, password=self.password) + data = self.get_request_data( + enable_proctored_exams=True, + proctoring_provider="proctortrack", + proctoring_escalation_email="foo@bar.com", + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_200_OK + self.assertDictEqual( + response.data, + { + "proctored_exam_settings": { + "enable_proctored_exams": True, + "allow_proctoring_opt_out": True, + "proctoring_provider": "proctortrack", + "proctoring_escalation_email": "foo@bar.com", + "create_zendesk_tickets": True, + } + }, + ) + + # course settings have been updated + updated = modulestore().get_item(self.course.location) + assert updated.enable_proctored_exams is True + assert updated.proctoring_provider == "proctortrack" + assert updated.proctoring_escalation_email == "foo@bar.com" + + @override_settings( + PROCTORING_BACKENDS={ + "DEFAULT": "test_proctoring_provider", + "test_proctoring_provider": {}, + }, + ) + def test_update_exam_settings_200_no_escalation_email(self): + """escalation email may be blank if not required by the provider""" + self.client.login(username=self.global_staff.username, password=self.password) + data = self.get_request_data( + enable_proctored_exams=True, + proctoring_provider="test_proctoring_provider", + proctoring_escalation_email=None, + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_200_OK + self.assertDictEqual( + response.data, + { + "proctored_exam_settings": { + "enable_proctored_exams": True, + "allow_proctoring_opt_out": True, + "proctoring_provider": "test_proctoring_provider", + "proctoring_escalation_email": None, + "create_zendesk_tickets": True, + } + }, + ) + + # course settings have been updated + updated = modulestore().get_item(self.course.location) + assert updated.enable_proctored_exams is True + assert updated.proctoring_provider == "test_proctoring_provider" + assert updated.proctoring_escalation_email is None + + def test_update_exam_settings_excluded_field(self): + """ + Excluded settings in POST data should not be updated + """ + self.client.login(username=self.global_staff.username, password=self.password) + data = self.get_request_data( + proctoring_escalation_email="foo@bar.com", + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_200_OK + self.assertDictEqual( + response.data, + { + "proctored_exam_settings": { + "enable_proctored_exams": False, + "allow_proctoring_opt_out": True, + "proctoring_provider": "null", + "proctoring_escalation_email": None, + "create_zendesk_tickets": True, + } + }, + ) + + # excluded course settings are not updated + updated = modulestore().get_item(self.course.location) + assert updated.proctoring_escalation_email is None + + @override_settings( + PROCTORING_BACKENDS={"DEFAULT": "null", "test_proctoring_provider": {}}, + ) + def test_update_exam_settings_invalid_value(self): + self.client.login(username=self.global_staff.username, password=self.password) + PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES + PROCTORED_EXAMS_ENABLED_FEATURES["ENABLE_PROCTORED_EXAMS"] = True + with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): + data = self.get_request_data( + enable_proctored_exams=True, + proctoring_provider="notvalidprovider", + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_400_BAD_REQUEST + self.assertDictEqual( + response.data, + { + "detail": [ + { + "proctoring_provider": ( + "The selected proctoring provider, notvalidprovider, is not a valid provider. " + "Please select from one of ['test_proctoring_provider']." + ) + } + ] + }, + ) + + # course settings have been updated + updated = modulestore().get_item(self.course.location) + assert updated.enable_proctored_exams is False + assert updated.proctoring_provider == "null" + + def test_403_if_instructor_request_includes_opting_out(self): + self.client.login(username=self.course_instructor, password=self.password) + data = self.get_request_data() + response = self.make_request(data=data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @override_settings( + PROCTORING_BACKENDS={"DEFAULT": "proctortrack", "proctortrack": {}}, + ) + def test_200_for_instructor_request_compatibility(self): + self.client.login(username=self.course_instructor, password=self.password) + data = { + "proctored_exam_settings": { + "enable_proctored_exams": True, + "proctoring_provider": "proctortrack", + "proctoring_escalation_email": "foo@bar.com", + } + } + response = self.make_request(data=data) + assert response.status_code == status.HTTP_200_OK + + @override_settings( + PROCTORING_BACKENDS={ + "DEFAULT": "proctortrack", + "proctortrack": {}, + "software_secure": {}, + }, + ) + @patch("logging.Logger.info") + @ddt.data( + ("proctortrack", False, False), + ("software_secure", True, False), + ("proctortrack", True, True), + ("software_secure", False, True), + ) + @ddt.unpack + def test_nonadmin_with_zendesk_ticket( + self, proctoring_provider, create_zendesk_tickets, expect_log, logger_mock + ): + self.client.login(username=self.course_instructor, password=self.password) + data = { + "proctored_exam_settings": { + "enable_proctored_exams": True, + "proctoring_provider": proctoring_provider, + "proctoring_escalation_email": "foo@bar.com", + "create_zendesk_tickets": create_zendesk_tickets, + } + } + response = self.make_request(data=data) + assert response.status_code == status.HTTP_200_OK + if expect_log: + logger_string = ( + "create_zendesk_tickets set to {ticket_value} but proctoring " + "provider is {provider} for course {course_id}. create_zendesk_tickets " + "should be updated for this course.".format( + ticket_value=create_zendesk_tickets, + provider=proctoring_provider, + course_id=self.course.id, + ) + ) + logger_mock.assert_any_call(logger_string) + + updated = modulestore().get_item(self.course.location) + assert updated.create_zendesk_tickets is create_zendesk_tickets + + @override_waffle_flag(EXAMS_IDA, active=True) + def test_200_for_lti_provider(self): + self.client.login(username=self.global_staff.username, password=self.password) + PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES + PROCTORED_EXAMS_ENABLED_FEATURES["ENABLE_PROCTORED_EXAMS"] = True + with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): + data = self.get_request_data( + enable_proctored_exams=True, + proctoring_provider="lti_external", + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_200_OK + + self.assertDictEqual( + response.data, + { + "proctored_exam_settings": { + "enable_proctored_exams": True, + "allow_proctoring_opt_out": True, + "proctoring_provider": "lti_external", + "proctoring_escalation_email": None, + "create_zendesk_tickets": True, + } + }, + ) + + # course settings have been updated + updated = modulestore().get_item(self.course.location) + assert updated.enable_proctored_exams is True + assert updated.proctoring_provider == "lti_external" + + @override_waffle_flag(EXAMS_IDA, active=False) + def test_400_for_disabled_lti(self): + self.client.login(username=self.global_staff.username, password=self.password) + PROCTORED_EXAMS_ENABLED_FEATURES = settings.FEATURES + PROCTORED_EXAMS_ENABLED_FEATURES["ENABLE_PROCTORED_EXAMS"] = True + with override_settings(FEATURES=PROCTORED_EXAMS_ENABLED_FEATURES): + data = self.get_request_data( + enable_proctored_exams=True, + proctoring_provider="lti_external", + ) + response = self.make_request(data=data) + + # response is correct + assert response.status_code == status.HTTP_400_BAD_REQUEST + self.assertDictEqual( + response.data, + { + "detail": [ + { + "proctoring_provider": ( + "The selected proctoring provider, lti_external, is not a valid provider. " + "Please select from one of ['null']." + ) + } + ] + }, + ) + + # course settings have been updated + updated = modulestore().get_item(self.course.location) + assert updated.enable_proctored_exams is False + assert updated.proctoring_provider == "null" + + +@ddt.ddt +class CourseProctoringErrorsViewTest(CourseTestCase, PermissionAccessMixin): + """ + Tests for ProctoringErrorsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:proctoring_errors", + kwargs={"course_id": self.course.id}, + ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() + + @ddt.data(False, True) + def test_disable_advanced_settings_feature(self, disable_advanced_settings): + """ + If this feature is enabled, only Django Staff/Superuser should be able to see the proctoring errors. + For non-staff users the proctoring errors should be unavailable. + """ + with override_settings( + FEATURES={"DISABLE_ADVANCED_SETTINGS": disable_advanced_settings} + ): + response = self.non_staff_client.get(self.url) + self.assertEqual( + response.status_code, 403 if disable_advanced_settings else 200 + ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py similarity index 50% rename from cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py rename to cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py index 8a4267de5721..4831ad4899a8 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py @@ -12,7 +12,7 @@ from common.djangoapps.util.course import get_link_for_about_page from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory -from ..mixins import PermissionAccessMixin +from ...mixins import PermissionAccessMixin @ddt.ddt @@ -24,39 +24,41 @@ class CourseSettingsViewTest(CourseTestCase, PermissionAccessMixin): def setUp(self): super().setUp() self.url = reverse( - 'cms.djangoapps.contentstore:v1:course_settings', + "cms.djangoapps.contentstore:v1:course_settings", kwargs={"course_id": self.course.id}, ) def test_course_settings_response(self): - """ Check successful response content """ + """Check successful response content""" response = self.client.get(self.url) expected_response = { - 'about_page_editable': True, - 'can_show_certificate_available_date_field': False, - 'course_display_name': self.course.display_name, - 'course_display_name_with_default': self.course.display_name_with_default, - 'credit_eligibility_enabled': True, - 'enrollment_end_editable': True, - 'enable_extended_course_details': False, - 'is_credit_course': False, - 'is_entrance_exams_enabled': True, - 'is_prerequisite_courses_enabled': False, - 'language_options': settings.ALL_LANGUAGES, - 'lms_link_for_about_page': get_link_for_about_page(self.course), - 'marketing_enabled': False, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(self.course.id), - 'short_description_editable': True, - 'sidebar_html_enabled': False, - 'show_min_grade_warning': False, - 'upgrade_deadline': None, - 'use_v2_cert_display_settings': False, + "about_page_editable": True, + "can_show_certificate_available_date_field": False, + "course_display_name": self.course.display_name, + "course_display_name_with_default": self.course.display_name_with_default, + "credit_eligibility_enabled": True, + "enrollment_end_editable": True, + "enable_extended_course_details": False, + "is_credit_course": False, + "is_entrance_exams_enabled": True, + "is_prerequisite_courses_enabled": False, + "language_options": settings.ALL_LANGUAGES, + "lms_link_for_about_page": get_link_for_about_page(self.course), + "marketing_enabled": False, + "mfe_proctored_exam_settings_url": get_proctored_exam_settings_url( + self.course.id + ), + "short_description_editable": True, + "sidebar_html_enabled": False, + "show_min_grade_warning": False, + "upgrade_deadline": None, + "use_v2_cert_display_settings": False, } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREDIT_ELIGIBILITY': True}) + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_CREDIT_ELIGIBILITY": True}) def test_credit_eligibility_setting(self): """ Make sure if the feature flag is enabled we have updated the dict keys in response. @@ -64,17 +66,20 @@ def test_credit_eligibility_setting(self): _ = CreditCourseFactory(course_key=self.course.id, enabled=True) response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('credit_requirements', response.data) - self.assertTrue(response.data['is_credit_course']) + self.assertIn("credit_requirements", response.data) + self.assertTrue(response.data["is_credit_course"]) - @patch.dict('django.conf.settings.FEATURES', { - 'ENABLE_PREREQUISITE_COURSES': True, - 'MILESTONES_APP': True, - }) + @patch.dict( + "django.conf.settings.FEATURES", + { + "ENABLE_PREREQUISITE_COURSES": True, + "MILESTONES_APP": True, + }, + ) def test_prerequisite_courses_enabled_setting(self): """ Make sure if the feature flags are enabled we have updated the dict keys in response. """ response = self.client.get(self.url) - self.assertIn('possible_pre_requisite_courses', response.data) + self.assertIn("possible_pre_requisite_courses", response.data) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock.py new file mode 100644 index 000000000000..e3bccca51f11 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_xblock.py @@ -0,0 +1,346 @@ +""" +Tests for the xblock view of the Studio Content API. This tests only the view itself, +not the underlying Xblock service. +It checks that the xblock_handler method of the Xblock service is called with the expected parameters. +""" +from unittest.mock import patch +from django.http import JsonResponse + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase + + +TEST_LOCATOR = "block-v1:dede+aba+weagi+type@problem+block@ba6327f840da49289fb27a9243913478" + + +class XblockViewTestCase(AuthorizeStaffTestCase): + """ + This base class supports tests with the various HTTP methods (GET, POST, PUT, PATCH, and DELETE). + Tests for each such message are organized by classes that derive from this one (e.g., XblockViewGetTest). + Each derived class supplies get_test_data() to govern what goes into the body of the HTTP request. + Each derived class optionally overrides get_url_params() to govern request parameter values. + Additionally, each derived class supplies send_request() to bring it all together when making a request. + """ + + def get_test_data(self): + raise NotImplementedError("get_test_data must be implemented by subclasses") + + def get_url_params(self): + """ + Returns a dictionary of parameters to be used in the url that includes course_id and usage_key_string. + Override this method if you don't want to use the default values. + """ + return {"course_id": self.get_course_key_string(), "usage_key_string": TEST_LOCATOR} + + def get_url(self, _course_id=None): + return reverse( + "cms.djangoapps.contentstore:v1:studio_content", + kwargs=self.get_url_params(), + ) + + def send_request(self, _url, _data): + raise NotImplementedError("send_request must be implemented by subclasses") + + @patch( + "cms.djangoapps.contentstore.rest_api.v1.views.xblock.handle_xblock", + return_value=JsonResponse( + { + "locator": TEST_LOCATOR, + "courseKey": AuthorizeStaffTestCase.get_course_key_string(), + } + ), + ) + @patch( + "cms.djangoapps.contentstore.rest_api.v1.views.xblock.toggles.use_studio_content_api", + return_value=True, + ) + def make_request( + self, + mock_use_studio_content_api, + mock_handle_xblock, + run_assertions=None, + course_id=None, + data=None, + ): + """ + Note that the actual xblock handler is mocked out and not used here. Patches used with this method serve to + test that routing of HTTP requests to the xblock handler is correct, that the intended HTTP method has been + used, that data fed into the handler is as expected, and that data returned by the handler is as expected. + Inputs and outputs are handled through send_request() polymorphism, to cover all the HTTP methods in a + common fashion here. + Validations are through injection of run_assersions(). + """ + url = self.get_url() + data = self.get_test_data() + + response = self.send_request(url, data) + + # run optional callback method with additional assertions + if run_assertions: + run_assertions( + response=response, mock_handle_xblock=mock_handle_xblock + ) + + return response + + +class XblockViewGetTest(XblockViewTestCase, ModuleStoreTestCase, APITestCase): + """ + Test GET operation on xblocks + """ + + def get_test_data(self): + return None + + def assert_xblock_handler_called(self, *, mock_handle_xblock, response): + """ + This defines a callback method that is called after the request is made + and runs additional assertions on the response and mock_handle_xblock. + """ + mock_handle_xblock.assert_called_once() + passed_args = mock_handle_xblock.call_args[0][0] + + assert passed_args.method == "GET" + assert passed_args.path == self.get_url() + + def send_request(self, url, data): + return self.client.get(url) + + def test_api_behind_feature_flag(self): + # should return 404 if the feature flag is not enabled + url = self.get_url() + + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_xblock_handler_called_with_correct_arguments(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request( # pylint: disable=no-value-for-parameter + run_assertions=self.assert_xblock_handler_called, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["locator"] == TEST_LOCATOR + assert data["courseKey"] == self.get_course_key_string() + + +class XblockViewPostTest(XblockViewTestCase, ModuleStoreTestCase, APITestCase): + """ + Test POST operation on xblocks - Create a new xblock for a parent xblock + """ + + def get_url_params(self): + return {"course_id": self.get_course_key_string()} + + def get_url(self, _course_id=None): + return reverse( + "cms.djangoapps.contentstore:v1:studio_content", + kwargs=self.get_url_params(), + ) + + def get_test_data(self): + course_id = self.get_course_key_string() + return { + "parent_locator": course_id, + "category": "html", + "courseKey": course_id, + } + + def assert_xblock_handler_called(self, *, mock_handle_xblock, response): + """ + This defines a callback method that is called after the request is made + and runs additional assertions on the response and mock_handle_xblock. + """ + mock_handle_xblock.assert_called_once() + passed_args = mock_handle_xblock.call_args[0][0] + + course_id = self.get_course_key_string() + + assert passed_args.data.get("courseKey") == course_id + assert passed_args.method == "POST" + assert passed_args.path == self.get_url() + + def send_request(self, url, data): + return self.client.post(url, data=data, format="json") + + def test_api_behind_feature_flag(self): + # should return 404 if the feature flag is not enabled + url = self.get_url() + + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_xblock_handler_called_with_correct_arguments(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request( # pylint: disable=no-value-for-parameter + run_assertions=self.assert_xblock_handler_called, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["locator"] == TEST_LOCATOR + assert data["courseKey"] == self.get_course_key_string() + + +class XblockViewPutTest(XblockViewTestCase, ModuleStoreTestCase, APITestCase): + """ + Test PUT operation on xblocks - update an xblock + """ + + def get_test_data(self): + course_id = self.get_course_key_string() + return { + "category": "html", + "courseKey": course_id, + "data": "

Updated block!

", + "has_changes": True, + "id": TEST_LOCATOR, + "metadata": { + "display_name": "Text" + } + } + + def assert_xblock_handler_called(self, *, mock_handle_xblock, response): + """ + This defines a callback method that is called after the request is made + and runs additional assertions on the response and mock_handle_xblock. + """ + mock_handle_xblock.assert_called_once() + passed_args = mock_handle_xblock.call_args[0][0] + + course_id = self.get_course_key_string() + + assert passed_args.data.get("courseKey") == course_id + assert passed_args.data.get("data") == "

Updated block!

" + assert passed_args.data.get("id") == TEST_LOCATOR + assert passed_args.method == "PUT" + assert passed_args.path == self.get_url() + + def send_request(self, url, data): + return self.client.put(url, data=data, format="json") + + def test_api_behind_feature_flag(self): + # should return 404 if the feature flag is not enabled + url = self.get_url() + + response = self.client.put(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_xblock_handler_called_with_correct_arguments(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request( # pylint: disable=no-value-for-parameter + run_assertions=self.assert_xblock_handler_called, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["locator"] == TEST_LOCATOR + assert data["courseKey"] == self.get_course_key_string() + + +class XblockViewPatchTest(XblockViewTestCase, ModuleStoreTestCase, APITestCase): + """ + Test PATCH operation on xblocks - update an xblock + """ + + def get_test_data(self): + course_id = self.get_course_key_string() + return { + "category": "html", + "courseKey": course_id, + "data": "

Patched block!

", + "has_changes": True, + "id": TEST_LOCATOR, + "metadata": { + "display_name": "Text" + } + } + + def assert_xblock_handler_called(self, *, mock_handle_xblock, response): + """ + This defines a callback method that is called after the request is made + and runs additional assertions on the response and mock_handle_xblock. + """ + mock_handle_xblock.assert_called_once() + passed_args = mock_handle_xblock.call_args[0][0] + + course_id = self.get_course_key_string() + + assert passed_args.data.get("courseKey") == course_id + assert passed_args.data.get("data") == "

Patched block!

" + assert passed_args.data.get("id") == TEST_LOCATOR + assert passed_args.method == "PATCH" + assert passed_args.path == self.get_url() + + def send_request(self, url, data): + return self.client.patch(url, data=data, format="json") + + def test_api_behind_feature_flag(self): + # should return 404 if the feature flag is not enabled + url = self.get_url() + + response = self.client.patch(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_xblock_handler_called_with_correct_arguments(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request( # pylint: disable=no-value-for-parameter + run_assertions=self.assert_xblock_handler_called, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["locator"] == TEST_LOCATOR + assert data["courseKey"] == self.get_course_key_string() + + +class XblockViewDeleteTest(XblockViewTestCase, ModuleStoreTestCase, APITestCase): + """ + Test DELETE operation on xblocks - delete an xblock + """ + + def get_test_data(self): + return None + + def assert_xblock_handler_called(self, *, mock_handle_xblock, response): + """ + This defines a callback method that is called after the request is made + and runs additional assertions on the response and mock_handle_xblock. + """ + mock_handle_xblock.assert_called_once() + passed_args = mock_handle_xblock.call_args[0][0] + + assert passed_args.method == "DELETE" + assert passed_args.path == self.get_url() + + def send_request(self, url, data): + return self.client.delete(url) + + def test_api_behind_feature_flag(self): + # should return 404 if the feature flag is not enabled + url = self.get_url() + + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_xblock_handler_called_with_correct_arguments(self): + self.client.login( + username=self.course_instructor.username, password=self.password + ) + response = self.make_request( # pylint: disable=no-value-for-parameter + run_assertions=self.assert_xblock_handler_called, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["locator"] == TEST_LOCATOR + assert data["courseKey"] == self.get_course_key_string() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py new file mode 100644 index 000000000000..d360d6aa029a --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py @@ -0,0 +1,65 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +import logging +from rest_framework.generics import RetrieveUpdateDestroyAPIView, CreateAPIView +from django.views.decorators.csrf import csrf_exempt +from django.http import Http404 + +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes +from common.djangoapps.util.json_request import expect_json_in_class_view + +from ....api import course_author_access_required + +from cms.djangoapps.contentstore.xblock_services import xblock_service +import cms.djangoapps.contentstore.toggles as contentstore_toggles + +log = logging.getLogger(__name__) +toggles = contentstore_toggles +handle_xblock = xblock_service.handle_xblock + + +@view_auth_classes() +class XblockView(DeveloperErrorViewMixin, RetrieveUpdateDestroyAPIView, CreateAPIView): + """ + public rest API endpoint for the Studio Content API. + course_key: required argument, needed to authorize course authors. + usage_key_string (optional): + xblock identifier, for example in the form of "block-v1:+type@+block@" + """ + + def dispatch(self, request, *args, **kwargs): + # TODO: probably want to refactor this to a decorator. + """ + The dispatch method of a View class handles HTTP requests in general + and calls other methods to handle specific HTTP methods. + We use this to raise a 404 if the content api is disabled. + """ + if not toggles.use_studio_content_api(): + raise Http404 + return super().dispatch(request, *args, **kwargs) + + # pylint: disable=arguments-differ + @course_author_access_required + @expect_json_in_class_view + def retrieve(self, request, course_key, usage_key_string=None): + return handle_xblock(request, usage_key_string) + + @course_author_access_required + @expect_json_in_class_view + def update(self, request, course_key, usage_key_string=None): + return handle_xblock(request, usage_key_string) + + @course_author_access_required + @expect_json_in_class_view + def partial_update(self, request, course_key, usage_key_string=None): + return handle_xblock(request, usage_key_string) + + @course_author_access_required + @expect_json_in_class_view + def destroy(self, request, course_key, usage_key_string=None): + return handle_xblock(request, usage_key_string) + + @csrf_exempt + @course_author_access_required + @expect_json_in_class_view + def create(self, request, course_key, usage_key_string=None): + return handle_xblock(request, usage_key_string) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 4b6dc100db61..ccfa8bb8f819 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,14 +9,17 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator from path import Path as path from pytz import UTC +from rest_framework import status from user_tasks.models import UserTaskArtifact, UserTaskStatus from cms.djangoapps.contentstore import utils from cms.djangoapps.contentstore.tasks import ALL_ALLOWED_XBLOCKS, validate_course_olx from cms.djangoapps.contentstore.tests.utils import TEST_DATA_DIR, CourseTestCase +from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -785,3 +788,77 @@ def test_non_html_blocks_titles_not_replaced(self): block_type = "something else" result = utils.determine_label(display_name, block_type) self.assertEqual(result, "something else") + + +class AuthorizeStaffTestCase(): + """ + Test that only staff roles can access an API endpoint. + """ + @classmethod + def get_course_key_string(cls): + return 'course-v1:edX+ToyX+Toy_Course' + + @classmethod + def get_other_course_key_string(cls): + return 'course-v1:edX+ToyX_Other_Course+Toy_Course' + + def setUp(self): + super().setUp() + self.course_key = self.get_course_key_string() + self.other_course_key = self.get_other_course_key_string() + self.course = self.create_course_from_course_key(CourseKey.from_string(self.course_key)) + self.other_course = self.create_course_from_course_key(CourseKey.from_string(self.other_course_key)) + self.password = 'password' + self.student = UserFactory.create(username='student', password=self.password) + self.global_staff = GlobalStaffFactory( + username='global-staff', password=self.password + ) + self.course_instructor = InstructorFactory( + username='instructor', + password=self.password, + course_key=self.course.id, + ) + self.other_course_instructor = InstructorFactory( + username='other-course-instructor', + password=self.password, + course_key=self.other_course.id, + ) + + @classmethod + def create_course_from_course_key(cls, course_key): + return CourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run + ) + + def make_request(self, course_id=None, data=None): + raise NotImplementedError + + def get_url(self, course_key): + raise NotImplementedError + + def test_student(self, expect_status=status.HTTP_403_FORBIDDEN): + self.client.login(username=self.student.username, password=self.password) + response = self.make_request() + assert response.status_code == expect_status + + def test_instructor_in_another_course(self, expect_status=status.HTTP_403_FORBIDDEN): + self.client.login( + username=self.other_course_instructor.username, + password=self.password + ) + response = self.make_request() + assert response.status_code == expect_status + + def test_global_staff(self, expect_status=status.HTTP_200_OK): + self.client.login(username=self.global_staff.username, password=self.password) + response = self.make_request() + assert response.status_code == expect_status + return response + + def test_course_instructor(self, expect_status=status.HTTP_200_OK): + self.client.login(username=self.course_instructor.username, password=self.password) + response = self.make_request() + assert response.status_code == expect_status + return response diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 4c2b5b5adf14..0536db140587 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -20,6 +20,7 @@ from cms.djangoapps.contentstore.utils import reverse_url from common.djangoapps.student.models import Registration + TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT @@ -42,6 +43,7 @@ class AjaxEnabledTestClient(Client): """ Convenience class to make testing easier. """ + def ajax_post(self, path, data=None, content_type="application/json", **kwargs): """ Convenience method for client post which serializes the data into json and sets the accept type @@ -214,6 +216,7 @@ class HTTPGetResponse: """ Generic object used to return results from a mock patch to an HTTP GET request """ + def __init__(self, status_code, response_string): self.status_code = status_code self.text = response_string diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index ef4b9bac5ae9..87a551d0a8cd 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -195,6 +195,30 @@ def individualize_anonymous_user_id(course_id): ) +# .. toggle_name: contentstore.enable_studio_content_api +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables the new (experimental and unsafe!) Studio Content REST API for course authors, +# .. which provides CRUD capabilities for course content and xblock editing. +# .. Use at your own peril - you can easily delete learner data when editing running courses. +# .. This can be triggered by deleting blocks, editing subsections, problems, assignments, discussions, +# .. creating new problems or graded sections, and by other things you do. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-05-26 +# .. toggle_tickets: TNL-10208 +ENABLE_STUDIO_CONTENT_API = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_studio_content_api', + __name__, +) + + +def use_studio_content_api(): + """ + Returns a boolean if studio editing API is enabled + """ + return ENABLE_STUDIO_CONTENT_API.is_enabled() + + # .. toggle_name: new_studio_mfe.use_new_home_page # .. toggle_implementation: WaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index a35230f3b8a5..d522a8bc0499 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -2,114 +2,74 @@ import logging from collections import OrderedDict -from datetime import datetime from functools import partial -from django.conf import settings from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import PermissionDenied -from django.http import Http404, HttpResponse, HttpResponseBadRequest +from django.http import Http404, HttpResponse from django.utils.translation import gettext as _ from django.views.decorators.http import require_http_methods -from edx_django_utils.plugins import pluggable_override -from edx_proctoring.api import ( - does_backend_support_onboarding, - get_exam_by_content_id, - get_exam_configuration_dashboard_url -) -from edx_proctoring.exceptions import ProctoredExamNotFoundException -from help_tokens.core import HelpUrlExpert from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryUsageLocator -from pytz import UTC from web_fragments.fragment import Fragment -from xblock.core import XBlock -from xblock.fields import Scope -from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG -from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW from common.djangoapps.edxmako.shortcuts import render_to_string -from common.djangoapps.static_replace import replace_static_urls -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access -from common.djangoapps.util.date_utils import get_default_time_display +from common.djangoapps.student.auth import ( + has_studio_read_access, + has_studio_write_access, +) from common.djangoapps.util.json_request import JsonResponse, expect_json -from openedx.core.djangoapps.bookmarks import api as bookmarks_api -from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration -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.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside -from openedx.core.toggles import ENTRANCE_EXAMS -from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import EdxJSONEncoder, ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.inheritance import own_metadata # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.tabs import CourseTabList # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, STUDIO_VIEW # lint-amnesty, pylint: disable=wrong-import-order - -from ..utils import ( - ancestor_has_staff_lock, - find_release_date_source, - find_staff_lock_source, - get_split_group_display_name, - get_user_partition_info, - get_visibility_partition_info, - has_children_visible_to_specific_partition_groups, - is_currently_visible_to_students, - is_self_paced, duplicate_block, load_services_for_studio +from openedx.core.lib.xblock_utils import ( + hash_resource, + request_token, + wrap_xblock, + wrap_xblock_aside, ) -from .helpers import ( - create_xblock, - get_parent_xblock, - import_staged_content_from_user_clipboard, +from xmodule.modulestore.django import ( + modulestore, +) # lint-amnesty, pylint: disable=wrong-import-order + + +from xmodule.x_module import ( + AUTHOR_VIEW, + PREVIEW_VIEWS, + STUDENT_VIEW, + STUDIO_VIEW, +) # lint-amnesty, pylint: disable=wrong-import-order + + +from ..helpers import ( is_unit, - usage_key_with_run, - xblock_primary_child_category, - xblock_studio_url, - xblock_type_display_name ) from .preview import get_preview_fragment +from cms.djangoapps.contentstore.xblock_services import ( + handle_xblock, + create_xblock_info, + load_services_for_studio, + get_block_info, + get_xblock, + delete_orphans, + usage_key_with_run, +) + __all__ = [ - 'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler' + "orphan_handler", + "xblock_handler", + "xblock_view_handler", + "xblock_outline_handler", + "xblock_container_handler", ] log = logging.getLogger(__name__) -CREATE_IF_NOT_FOUND = ['course_info'] +CREATE_IF_NOT_FOUND = ["course_info"] # Useful constants for defining predicates NEVER = lambda x: False ALWAYS = lambda x: True -def _filter_entrance_exam_grader(graders): - """ - If the entrance exams feature is enabled we need to hide away the grader from - views/controls like the 'Grade as' dropdown that allows a course author to select - the grader type for a given section of a course - """ - if ENTRANCE_EXAMS.is_enabled(): - graders = [grader for grader in graders if grader.get('type') != 'Entrance Exam'] - return graders - - -def _is_library_component_limit_reached(usage_key): - """ - Verify if the library has reached the maximum number of components allowed in it - """ - store = modulestore() - parent = store.get_item(usage_key) - if not parent.has_children: - # Limit cannot be applied on such items - return False - total_children = len(parent.children) - return total_children + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY - - @require_http_methods(("DELETE", "GET", "PUT", "POST", "PATCH")) @login_required @expect_json @@ -166,106 +126,7 @@ def xblock_handler(request, usage_key_string=None): fields except parent_locator) The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned. """ - if usage_key_string: - usage_key = usage_key_with_run(usage_key_string) - - access_check = has_studio_read_access if request.method == 'GET' else has_studio_write_access - if not access_check(request.user, usage_key.course_key): - raise PermissionDenied() - - if request.method == 'GET': - accept_header = request.META.get('HTTP_ACCEPT', 'application/json') - - if 'application/json' in accept_header: - fields = request.GET.get('fields', '').split(',') - if 'graderType' in fields: - # right now can't combine output of this w/ output of _get_block_info, but worthy goal - return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key)) - elif 'ancestorInfo' in fields: - xblock = _get_xblock(usage_key, request.user) - ancestor_info = _create_xblock_ancestor_info(xblock, is_concise=True) - return JsonResponse(ancestor_info) - # TODO: pass fields to _get_block_info and only return those - with modulestore().bulk_operations(usage_key.course_key): - response = _get_block_info(_get_xblock(usage_key, request.user)) - return JsonResponse(response) - else: - return HttpResponse(status=406) - - elif request.method == 'DELETE': - _delete_item(usage_key, request.user) - return JsonResponse() - else: # Since we have a usage_key, we are updating an existing xblock. - return _save_xblock( - request.user, - _get_xblock(usage_key, request.user), - data=request.json.get('data'), - children_strings=request.json.get('children'), - metadata=request.json.get('metadata'), - nullout=request.json.get('nullout'), - grader_type=request.json.get('graderType'), - is_prereq=request.json.get('isPrereq'), - prereq_usage_key=request.json.get('prereqUsageKey'), - prereq_min_score=request.json.get('prereqMinScore'), - prereq_min_completion=request.json.get('prereqMinCompletion'), - publish=request.json.get('publish'), - fields=request.json.get('fields'), - ) - elif request.method in ('PUT', 'POST'): - if 'duplicate_source_locator' in request.json: - parent_usage_key = usage_key_with_run(request.json['parent_locator']) - duplicate_source_usage_key = usage_key_with_run(request.json['duplicate_source_locator']) - - source_course = duplicate_source_usage_key.course_key - dest_course = parent_usage_key.course_key - if ( - not has_studio_write_access(request.user, dest_course) or - not has_studio_read_access(request.user, source_course) - ): - raise PermissionDenied() - - # Libraries have a maximum component limit enforced on them - if (isinstance(parent_usage_key, LibraryUsageLocator) and - _is_library_component_limit_reached(parent_usage_key)): - return JsonResponse( - { - 'error': _('Libraries cannot have more than {limit} components').format( - limit=settings.MAX_BLOCKS_PER_CONTENT_LIBRARY - ) - }, - status=400 - ) - - dest_usage_key = duplicate_block( - parent_usage_key, - duplicate_source_usage_key, - request.user, - display_name=request.json.get('display_name'), - ) - return JsonResponse({ - 'locator': str(dest_usage_key), - 'courseKey': str(dest_usage_key.course_key) - }) - else: - return _create_block(request) - elif request.method == 'PATCH': - if 'move_source_locator' in request.json: - move_source_usage_key = usage_key_with_run(request.json.get('move_source_locator')) - target_parent_usage_key = usage_key_with_run(request.json.get('parent_locator')) - target_index = request.json.get('target_index') - if ( - not has_studio_write_access(request.user, target_parent_usage_key.course_key) or - not has_studio_read_access(request.user, target_parent_usage_key.course_key) - ): - raise PermissionDenied() - return _move_item(move_source_usage_key, target_parent_usage_key, request.user, target_index) - - return JsonResponse({'error': 'Patch request did not recognise any parameters to handle.'}, status=400) - else: - return HttpResponseBadRequest( - 'Only instance creation is supported without a usage key.', - content_type='text/plain' - ) + return handle_xblock(request, usage_key_string) @require_http_methods("GET") @@ -284,29 +145,37 @@ def xblock_view_handler(request, usage_key_string, view_name): if not has_studio_read_access(request.user, usage_key.course_key): raise PermissionDenied() - accept_header = request.META.get('HTTP_ACCEPT', 'application/json') + accept_header = request.META.get("HTTP_ACCEPT", "application/json") - if 'application/json' in accept_header: + if "application/json" in accept_header: store = modulestore() xblock = store.get_item(usage_key) - container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview'] + container_views = [ + "container_preview", + "reorderable_container_child_preview", + "container_child_preview", + ] # wrap the generated fragment in the xmodule_editor div so that the javascript # can bind to it correctly - xblock.runtime.wrappers.append(partial( - wrap_xblock, - 'StudioRuntime', - usage_id_serializer=str, - request_token=request_token(request), - )) + xblock.runtime.wrappers.append( + partial( + wrap_xblock, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + ) + ) - xblock.runtime.wrappers_asides.append(partial( - wrap_xblock_aside, - 'StudioRuntime', - usage_id_serializer=str, - request_token=request_token(request), - extra_classes=['wrapper-comp-plugins'] - )) + xblock.runtime.wrappers_asides.append( + partial( + wrap_xblock_aside, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + extra_classes=["wrapper-comp-plugins"], + ) + ) if view_name in (STUDIO_VIEW, VISIBILITY_VIEW): if view_name == STUDIO_VIEW: @@ -317,12 +186,18 @@ def xblock_view_handler(request, usage_key_string, view_name): # catch exceptions indiscriminately, since after this point they escape the # dungeon and surface as uneditable, unsaveable, and undeletable # component-goblins. - except Exception as exc: # pylint: disable=broad-except - log.debug("Unable to render %s for %r", view_name, xblock, exc_info=True) - fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) + except Exception as exc: # pylint: disable=broad-except + log.debug( + "Unable to render %s for %r", view_name, xblock, exc_info=True + ) + fragment = Fragment( + render_to_string("html_error.html", {"message": str(exc)}) + ) elif view_name in PREVIEW_VIEWS + container_views: - is_pages_view = view_name == STUDENT_VIEW # Only the "Pages" view uses student view in Studio + is_pages_view = ( + view_name == STUDENT_VIEW + ) # Only the "Pages" view uses student view in Studio can_edit = has_studio_write_access(request.user, usage_key.course_key) # Determine the items to be shown as reorderable. Note that the view @@ -330,58 +205,65 @@ def xblock_view_handler(request, usage_key_string, view_name): # are being shown in a reorderable container, so the xblock is automatically # added to the list. reorderable_items = set() - if view_name == 'reorderable_container_child_preview': + if view_name == "reorderable_container_child_preview": reorderable_items.add(xblock.location) paging = None try: - if request.GET.get('enable_paging', 'false') == 'true': + if request.GET.get("enable_paging", "false") == "true": paging = { - 'page_number': int(request.GET.get('page_number', 0)), - 'page_size': int(request.GET.get('page_size', 0)), + "page_number": int(request.GET.get("page_number", 0)), + "page_size": int(request.GET.get("page_size", 0)), } except ValueError: return HttpResponse( content="Couldn't parse paging parameters: enable_paging: " - "{}, page_number: {}, page_size: {}".format( - request.GET.get('enable_paging', 'false'), - request.GET.get('page_number', 0), - request.GET.get('page_size', 0) - ), + "{}, page_number: {}, page_size: {}".format( + request.GET.get("enable_paging", "false"), + request.GET.get("page_number", 0), + request.GET.get("page_size", 0), + ), status=400, content_type="text/plain", ) - force_render = request.GET.get('force_render', None) + force_render = request.GET.get("force_render", None) # Set up the context to be passed to each XBlock's render method. context = request.GET.dict() - context.update({ - # This setting disables the recursive wrapping of xblocks - 'is_pages_view': is_pages_view or view_name == AUTHOR_VIEW, - 'is_unit_page': is_unit(xblock), - 'can_edit': can_edit, - 'root_xblock': xblock if (view_name == 'container_preview') else None, - 'reorderable_items': reorderable_items, - 'paging': paging, - 'force_render': force_render, - 'item_url': '/container/{usage_key}', - }) + context.update( + { + # This setting disables the recursive wrapping of xblocks + "is_pages_view": is_pages_view or view_name == AUTHOR_VIEW, + "is_unit_page": is_unit(xblock), + "can_edit": can_edit, + "root_xblock": xblock + if (view_name == "container_preview") + else None, + "reorderable_items": reorderable_items, + "paging": paging, + "force_render": force_render, + "item_url": "/container/{usage_key}", + } + ) fragment = get_preview_fragment(request, xblock, context) # Note that the container view recursively adds headers into the preview fragment, # so only the "Pages" view requires that this extra wrapper be included. display_label = xblock.display_name or xblock.scope_ids.block_type - if not xblock.display_name and xblock.scope_ids.block_type == 'html': + if not xblock.display_name and xblock.scope_ids.block_type == "html": display_label = _("Text") if is_pages_view: - fragment.content = render_to_string('component.html', { - 'xblock_context': context, - 'xblock': xblock, - 'locator': usage_key, - 'preview': fragment.content, - 'label': display_label, - }) + fragment.content = render_to_string( + "component.html", + { + "xblock_context": context, + "xblock": xblock, + "locator": usage_key, + "preview": fragment.content, + "label": display_label, + }, + ) else: raise Http404 @@ -391,12 +273,11 @@ def xblock_view_handler(request, usage_key_string, view_name): fragment_content = fragment.content if isinstance(fragment_content, bytes): - fragment_content = fragment.content.decode('utf-8') + fragment_content = fragment.content.decode("utf-8") - return JsonResponse({ - 'html': fragment_content, - 'resources': list(hashed_resources.items()) - }) + return JsonResponse( + {"html": fragment_content, "resources": list(hashed_resources.items())} + ) else: return HttpResponse(status=406) @@ -415,17 +296,22 @@ def xblock_outline_handler(request, usage_key_string): if not has_studio_read_access(request.user, usage_key.course_key): raise PermissionDenied() - response_format = request.GET.get('format', 'html') - if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + response_format = request.GET.get("format", "html") + if response_format == "json" or "application/json" in request.META.get( + "HTTP_ACCEPT", "application/json" + ): store = modulestore() with store.bulk_operations(usage_key.course_key): root_xblock = store.get_item(usage_key, depth=None) - return JsonResponse(create_xblock_info( - root_xblock, - include_child_info=True, - course_outline=True, - include_children_predicate=lambda xblock: not xblock.category == 'vertical' - )) + return JsonResponse( + create_xblock_info( + root_xblock, + include_child_info=True, + course_outline=True, + include_children_predicate=lambda xblock: not xblock.category + == "vertical", + ) + ) else: raise Http404 @@ -444,425 +330,21 @@ def xblock_container_handler(request, usage_key_string): if not has_studio_read_access(request.user, usage_key.course_key): raise PermissionDenied() - response_format = request.GET.get('format', 'html') - if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + response_format = request.GET.get("format", "html") + if response_format == "json" or "application/json" in request.META.get( + "HTTP_ACCEPT", "application/json" + ): with modulestore().bulk_operations(usage_key.course_key): - response = _get_block_info( - _get_xblock(usage_key, request.user), include_ancestor_info=True, include_publishing_info=True + response = get_block_info( + get_xblock(usage_key, request.user), + include_ancestor_info=True, + include_publishing_info=True, ) return JsonResponse(response) else: raise Http404 -def _update_with_callback(xblock, user, old_metadata=None, old_content=None): - """ - Updates the xblock in the modulestore. - But before doing so, it calls the xblock's editor_saved callback function. - """ - if callable(getattr(xblock, "editor_saved", None)): - if old_metadata is None: - old_metadata = own_metadata(xblock) - if old_content is None: - old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) - load_services_for_studio(xblock.runtime, user) - xblock.editor_saved(user, old_metadata, old_content) - - # Update after the callback so any changes made in the callback will get persisted. - return modulestore().update_item(xblock, user.id) - - -def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None, nullout=None, # lint-amnesty, pylint: disable=too-many-statements - grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, - prereq_min_completion=None, publish=None, fields=None): - """ - Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. - nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert - to default). - - """ - store = modulestore() - # Perform all xblock changes within a (single-versioned) transaction - with store.bulk_operations(xblock.location.course_key): - - # Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI). - if publish == "discard_changes": - store.revert_to_published(xblock.location, user.id) - # Returning the same sort of result that we do for other save operations. In the future, - # we may want to return the full XBlockInfo. - return JsonResponse({'id': str(xblock.location)}) - - old_metadata = own_metadata(xblock) - old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) - - if data: - # TODO Allow any scope.content fields not just "data" (exactly like the get below this) - xblock.data = data - else: - data = old_content['data'] if 'data' in old_content else None - - if fields: - for field_name in fields: - setattr(xblock, field_name, fields[field_name]) - - if children_strings is not None: - children = [] - for child_string in children_strings: - children.append(usage_key_with_run(child_string)) - - # if new children have been added, remove them from their old parents - new_children = set(children) - set(xblock.children) - for new_child in new_children: - old_parent_location = store.get_parent_location(new_child) - if old_parent_location: - old_parent = store.get_item(old_parent_location) - old_parent.children.remove(new_child) - old_parent = _update_with_callback(old_parent, user) - else: - # the Studio UI currently doesn't present orphaned children, so assume this is an error - return JsonResponse({"error": "Invalid data, possibly caused by concurrent authors."}, 400) - - # make sure there are no old children that became orphans - # In a single-author (no-conflict) scenario, all children in the persisted list on the server should be - # present in the updated list. If there are any children that have been dropped as part of this update, - # then that would be an error. - # - # We can be even more restrictive in a multi-author (conflict), by returning an error whenever - # len(old_children) > 0. However, that conflict can still be "merged" if the dropped child had been - # re-parented. Hence, the check for the parent in the any statement below. - # - # Note that this multi-author conflict error should not occur in modulestores (such as Split) that support - # atomic write transactions. In Split, if there was another author who moved one of the "old_children" - # into another parent, then that child would have been deleted from this parent on the server. However, - # this is error could occur in modulestores (such as Draft) that do not support atomic write-transactions - old_children = set(xblock.children) - set(children) - if any( - store.get_parent_location(old_child) == xblock.location - for old_child in old_children - ): - # since children are moved as part of a single transaction, orphans should not be created - return JsonResponse({"error": "Invalid data, possibly caused by concurrent authors."}, 400) - - # set the children on the xblock - xblock.children = children - - # also commit any metadata which might have been passed along - if nullout is not None or metadata is not None: - # the postback is not the complete metadata, as there's system metadata which is - # not presented to the end-user for editing. So let's use the original (existing_item) and - # 'apply' the submitted metadata, so we don't end up deleting system metadata. - if nullout is not None: - for metadata_key in nullout: - setattr(xblock, metadata_key, None) - - # update existing metadata with submitted metadata (which can be partial) - # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If - # the intent is to make it None, use the nullout field - if metadata is not None: - for metadata_key, value in metadata.items(): - field = xblock.fields[metadata_key] - - if value is None: - field.delete_from(xblock) - else: - try: - value = field.from_json(value) - except ValueError as verr: - reason = _("Invalid data") - if str(verr): - reason = _("Invalid data ({details})").format(details=str(verr)) - return JsonResponse({"error": reason}, 400) - - field.write_to(xblock, value) - - validate_and_update_xblock_due_date(xblock) - # update the xblock and call any xblock callbacks - xblock = _update_with_callback(xblock, user, old_metadata, old_content) - - # for static tabs, their containing course also records their display name - course = store.get_course(xblock.location.course_key) - if xblock.location.block_type == 'static_tab': - # find the course's reference to this tab and update the name. - static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name) - # only update if changed - if static_tab: - update_tab = False - if static_tab['name'] != xblock.display_name: - static_tab['name'] = xblock.display_name - update_tab = True - if static_tab['course_staff_only'] != xblock.course_staff_only: - static_tab['course_staff_only'] = xblock.course_staff_only - update_tab = True - if update_tab: - store.update_item(course, user.id) - - result = { - 'id': str(xblock.location), - 'data': data, - 'metadata': own_metadata(xblock) - } - - if grader_type is not None: - result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user)) - - # Save gating info - if xblock.category == 'sequential' and course.enable_subsection_gating: - if is_prereq is not None: - if is_prereq: - gating_api.add_prerequisite(xblock.location.course_key, xblock.location) - else: - gating_api.remove_prerequisite(xblock.location) - result['is_prereq'] = is_prereq - - if prereq_usage_key is not None: - gating_api.set_required_content( - xblock.location.course_key, - xblock.location, - prereq_usage_key, - prereq_min_score, - prereq_min_completion - ) - - # If publish is set to 'republish' and this item is not in direct only categories and has previously been - # published, then this item should be republished. This is used by staff locking to ensure that changing the - # draft value of the staff lock will also update the published version, but only at the unit level. - if publish == 'republish' and xblock.category not in DIRECT_ONLY_CATEGORIES: - if modulestore().has_published_version(xblock): - publish = 'make_public' - - # Make public after updating the xblock, in case the caller asked for both an update and a publish. - # Used by Bok Choy tests and by republishing of staff locks. - if publish == 'make_public': - modulestore().publish(xblock.location, user.id) - - # Note that children aren't being returned until we have a use case. - return JsonResponse(result, encoder=EdxJSONEncoder) - - -@login_required -@expect_json -def create_item(request): - """ - Exposes internal helper method without breaking existing bindings/dependencies - """ - return _create_block(request) - - -@login_required -@expect_json -def _create_block(request): - """View for create blocks.""" - parent_locator = request.json['parent_locator'] - usage_key = usage_key_with_run(parent_locator) - if not has_studio_write_access(request.user, usage_key.course_key): - raise PermissionDenied() - - if request.json.get('staged_content') == "clipboard": - # Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key': - try: - created_xblock = import_staged_content_from_user_clipboard(parent_key=usage_key, request=request) - except Exception: # pylint: disable=broad-except - log.exception("Could not paste component into location {}".format(usage_key)) - return JsonResponse({"error": _('There was a problem pasting your component.')}, status=400) - if created_xblock is None: - return JsonResponse({"error": _('Your clipboard is empty or invalid.')}, status=400) - return JsonResponse( - {'locator': str(created_xblock.location), 'courseKey': str(created_xblock.location.course_key)} - ) - - category = request.json['category'] - if isinstance(usage_key, LibraryUsageLocator): - # Only these categories are supported at this time. - if category not in ['html', 'problem', 'video']: - return HttpResponseBadRequest( - "Category '%s' not supported for Libraries" % category, content_type='text/plain' - ) - - if _is_library_component_limit_reached(usage_key): - return JsonResponse( - { - 'error': _('Libraries cannot have more than {limit} components').format( - limit=settings.MAX_BLOCKS_PER_CONTENT_LIBRARY - ) - }, - status=400 - ) - - created_block = create_xblock( - parent_locator=parent_locator, - user=request.user, - category=category, - display_name=request.json.get('display_name'), - boilerplate=request.json.get('boilerplate'), - ) - - return JsonResponse( - {'locator': str(created_block.location), 'courseKey': str(created_block.location.course_key)} - ) - - -def _get_source_index(source_usage_key, source_parent): - """ - Get source index position of the XBlock. - - Arguments: - source_usage_key (BlockUsageLocator): Locator of source item. - source_parent (XBlock): A parent of the source XBlock. - - Returns: - source_index (int): Index position of the xblock in a parent. - """ - try: - source_index = source_parent.children.index(source_usage_key) - return source_index - except ValueError: - return None - - -def is_source_item_in_target_parents(source_item, target_parent): - """ - Returns True if source item is found in target parents otherwise False. - - Arguments: - source_item (XBlock): Source Xblock. - target_parent (XBlock): Target XBlock. - """ - target_ancestors = _create_xblock_ancestor_info(target_parent, is_concise=True)['ancestors'] - for target_ancestor in target_ancestors: - if str(source_item.location) == target_ancestor['id']: - return True - return False - - -def _move_item(source_usage_key, target_parent_usage_key, user, target_index=None): - """ - Move an existing xblock as a child of the supplied target_parent_usage_key. - - Arguments: - source_usage_key (BlockUsageLocator): Locator of source item. - target_parent_usage_key (BlockUsageLocator): Locator of target parent. - target_index (int): If provided, insert source item at provided index location in target_parent_usage_key item. - - Returns: - JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation - is performed. - """ - # Get the list of all parentable component type XBlocks. - parent_component_types = list( - {name for name, class_ in XBlock.load_classes() if getattr(class_, 'has_children', False)} - - set(DIRECT_ONLY_CATEGORIES) - ) - - store = modulestore() - with store.bulk_operations(source_usage_key.course_key): - source_item = store.get_item(source_usage_key) - source_parent = source_item.get_parent() - target_parent = store.get_item(target_parent_usage_key) - source_type = source_item.category - target_parent_type = target_parent.category - error = None - - # Store actual/initial index of the source item. This would be sent back with response, - # so that with Undo operation, it would easier to move back item to it's original/old index. - source_index = _get_source_index(source_usage_key, source_parent) - - valid_move_type = { - 'sequential': 'vertical', - 'chapter': 'sequential', - } - - if (valid_move_type.get(target_parent_type, '') != source_type and - target_parent_type not in parent_component_types): - error = _('You can not move {source_type} into {target_parent_type}.').format( - source_type=source_type, - target_parent_type=target_parent_type, - ) - elif source_parent.location == target_parent.location or source_item.location in target_parent.children: - error = _('Item is already present in target location.') - elif source_item.location == target_parent.location: - error = _('You can not move an item into itself.') - elif is_source_item_in_target_parents(source_item, target_parent): - error = _('You can not move an item into it\'s child.') - elif target_parent_type == 'split_test': - error = _('You can not move an item directly into content experiment.') - elif source_index is None: - error = _('{source_usage_key} not found in {parent_usage_key}.').format( - source_usage_key=str(source_usage_key), - parent_usage_key=str(source_parent.location) - ) - else: - try: - target_index = int(target_index) if target_index is not None else None - if target_index is not None and len(target_parent.children) < target_index: - error = _('You can not move {source_usage_key} at an invalid index ({target_index}).').format( - source_usage_key=str(source_usage_key), - target_index=target_index - ) - except ValueError: - error = _('You must provide target_index ({target_index}) as an integer.').format( - target_index=target_index - ) - if error: - return JsonResponse({'error': error}, status=400) - - # When target_index is provided, insert xblock at target_index position, otherwise insert at the end. - insert_at = target_index if target_index is not None else len(target_parent.children) - - store.update_item_parent( - item_location=source_item.location, - new_parent_location=target_parent.location, - old_parent_location=source_parent.location, - insert_at=insert_at, - user_id=user.id - ) - - log.info( - 'MOVE: %s moved from %s to %s at %d index', - str(source_usage_key), - str(source_parent.location), - str(target_parent_usage_key), - insert_at - ) - - context = { - 'move_source_locator': str(source_usage_key), - 'parent_locator': str(target_parent_usage_key), - 'source_index': target_index if target_index is not None else source_index - } - return JsonResponse(context) - - -@login_required -@expect_json -def delete_item(request, usage_key): - """ - Exposes internal helper method without breaking existing bindings/dependencies - """ - _delete_item(usage_key, request.user) - - -def _delete_item(usage_key, user): - """ - Deletes an existing xblock with the given usage_key. - If the xblock is a Static Tab, removes it from course.tabs as well. - """ - store = modulestore() - - with store.bulk_operations(usage_key.course_key): - # VS[compat] cdodge: This is a hack because static_tabs also have references from the course block, so - # if we add one then we need to also add it to the policy information (i.e. metadata) - # we should remove this once we can break this reference from the course to static tabs - if usage_key.block_type == 'static_tab': - course = store.get_course(usage_key.course_key) - existing_tabs = course.tabs or [] - course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != usage_key.block_id] - store.update_item(course, user.id) - - # Delete user bookmarks - bookmarks_api.delete_bookmarks(usage_key) - store.delete_item(usage_key, user.id) - - @login_required @require_http_methods(("GET", "DELETE")) def orphan_handler(request, course_key_string): @@ -874,611 +356,18 @@ def orphan_handler(request, course_key_string): from the root via children """ course_usage_key = CourseKey.from_string(course_key_string) - if request.method == 'GET': + if request.method == "GET": if has_studio_read_access(request.user, course_usage_key): - return JsonResponse([str(item) for item in modulestore().get_orphans(course_usage_key)]) + return JsonResponse( + [str(item) for item in modulestore().get_orphans(course_usage_key)] + ) else: raise PermissionDenied() - if request.method == 'DELETE': + if request.method == "DELETE": if request.user.is_staff: - deleted_items = _delete_orphans(course_usage_key, request.user.id, commit=True) - return JsonResponse({'deleted': deleted_items}) - else: - raise PermissionDenied() - - -def _delete_orphans(course_usage_key, user_id, commit=False): - """ - Helper function to delete orphans for a given course. - If `commit` is False, this function does not actually remove - the orphans. - """ - store = modulestore() - blocks = store.get_orphans(course_usage_key) - branch = course_usage_key.branch - if commit: - with store.bulk_operations(course_usage_key): - for blockloc in blocks: - revision = ModuleStoreEnum.RevisionOption.all - # specify branches when deleting orphans - if branch == ModuleStoreEnum.BranchName.published: - revision = ModuleStoreEnum.RevisionOption.published_only - store.delete_item(blockloc, user_id, revision=revision) - return [str(block) for block in blocks] - - -def _get_xblock(usage_key, user): - """ - Returns the xblock for the specified usage key. Note: if failing to find a key with a category - in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically. - """ - store = modulestore() - with store.bulk_operations(usage_key.course_key): - try: - return store.get_item(usage_key, depth=None) - except ItemNotFoundError: - if usage_key.block_type in CREATE_IF_NOT_FOUND: - # Create a new one for certain categories only. Used for course info handouts. - return store.create_item( - user.id, - usage_key.course_key, - usage_key.block_type, - block_id=usage_key.block_id) - else: - raise - except InvalidLocationError: - log.error("Can't find item by location.") - return JsonResponse({"error": "Can't find item by location: " + str(usage_key)}, 404) - - -def _get_block_info(xblock, rewrite_static_links=True, include_ancestor_info=False, include_publishing_info=False): - """ - metadata, data, id representation of a leaf block fetcher. - :param usage_key: A UsageKey - """ - with modulestore().bulk_operations(xblock.location.course_key): - data = getattr(xblock, 'data', '') - if rewrite_static_links: - data = replace_static_urls( - data, - None, - course_id=xblock.location.course_key - ) - - # Pre-cache has changes for the entire course because we'll need it for the ancestor info - # Except library blocks which don't [yet] use draft/publish - if not isinstance(xblock.location, LibraryUsageLocator): - modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None)) - - # Note that children aren't being returned until we have a use case. - xblock_info = create_xblock_info( - xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=include_ancestor_info - ) - if include_publishing_info: - add_container_page_publishing_info(xblock, xblock_info) - - return xblock_info - - -def _get_gating_info(course, xblock): - """ - Returns a dict containing gating information for the given xblock which - can be added to xblock info responses. - - Arguments: - course (CourseBlock): The course - xblock (XBlock): The xblock - - Returns: - dict: Gating information - """ - info = {} - if xblock.category == 'sequential' and course.enable_subsection_gating: - if not hasattr(course, 'gating_prerequisites'): - # Cache gating prerequisites on course block so that we are not - # hitting the database for every xblock in the course - course.gating_prerequisites = gating_api.get_prerequisites(course.id) - info["is_prereq"] = gating_api.is_prerequisite(course.id, xblock.location) - info["prereqs"] = [ - p for p in course.gating_prerequisites if str(xblock.location) not in p['namespace'] - ] - prereq, prereq_min_score, prereq_min_completion = gating_api.get_required_content( - course.id, - xblock.location - ) - info["prereq"] = prereq - info["prereq_min_score"] = prereq_min_score - info["prereq_min_completion"] = prereq_min_completion - if prereq: - info["visibility_state"] = VisibilityState.gated - return info - - -@pluggable_override('OVERRIDE_CREATE_XBLOCK_INFO') -def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, # lint-amnesty, pylint: disable=too-many-statements - course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None, - user=None, course=None, is_concise=False): - """ - Creates the information needed for client-side XBlockInfo. - - If data or metadata are not specified, their information will not be added - (regardless of whether or not the xblock actually has data or metadata). - - There are three optional boolean parameters: - include_ancestor_info - if true, ancestor info is added to the response - include_child_info - if true, direct child info is included in the response - is_concise - if true, returns the concise version of xblock info, default is false. - course_outline - if true, the xblock is being rendered on behalf of the course outline. - There are certain expensive computations that do not need to be included in this case. - - In addition, an optional include_children_predicate argument can be provided to define whether or - not a particular xblock should have its children included. - - You can customize the behavior of this function using the `OVERRIDE_CREATE_XBLOCK_INFO` pluggable override point. - For example: - >>> def create_xblock_info(default_fn, xblock, *args, **kwargs): - ... xblock_info = default_fn(xblock, *args, **kwargs) - ... xblock_info['icon'] = xblock.icon_override - ... return xblock_info - """ - is_library_block = isinstance(xblock.location, LibraryUsageLocator) - is_xblock_unit = is_unit(xblock, parent_xblock) - # this should not be calculated for Sections and Subsections on Unit page or for library blocks - has_changes = None - if (is_xblock_unit or course_outline) and not is_library_block: - has_changes = modulestore().has_changes(xblock) - - if graders is None: - if not is_library_block: - graders = CourseGradingModel.fetch(xblock.location.course_key).graders - else: - graders = [] - - # Filter the graders data as needed - graders = _filter_entrance_exam_grader(graders) - - # We need to load the course in order to retrieve user partition information. - # For this reason, we load the course once and re-use it when recursively loading children. - if course is None: - course = modulestore().get_course(xblock.location.course_key) - - # Compute the child info first so it can be included in aggregate information for the parent - should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline) - if should_visit_children and xblock.has_children: - child_info = _create_xblock_child_info( - xblock, - course_outline, - graders, - include_children_predicate=include_children_predicate, - user=user, - course=course, - is_concise=is_concise - ) - else: - child_info = None - - release_date = _get_release_date(xblock, user) - - if xblock.category != 'course' and not is_concise: - visibility_state = _compute_visibility_state( - xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course) - ) - else: - visibility_state = None - published = modulestore().has_published_version(xblock) if not is_library_block else None - published_on = get_default_time_display(xblock.published_on) if published and xblock.published_on else None - - # defining the default value 'True' for delete, duplicate, drag and add new child actions - # in xblock_actions for each xblock. - xblock_actions = {'deletable': True, 'draggable': True, 'childAddable': True, 'duplicable': True} - explanatory_message = None - - # is_entrance_exam is inherited metadata. - if xblock.category == 'chapter' and getattr(xblock, "is_entrance_exam", None): - # Entrance exam section should not be deletable, draggable and not have 'New Subsection' button. - xblock_actions['deletable'] = xblock_actions['childAddable'] = xblock_actions['draggable'] = False - if parent_xblock is None: - parent_xblock = get_parent_xblock(xblock) - - # Translators: The {pct_sign} here represents the percent sign, i.e., '%' - # in many languages. This is used to avoid Transifex's misinterpreting of - # '% o'. The percent sign is also translatable as a standalone string. - explanatory_message = _('Students must score {score}{pct_sign} or higher to access course materials.').format( - score=int(parent_xblock.entrance_exam_minimum_score_pct * 100), - # Translators: This is the percent sign. It will be used to represent - # a percent value out of 100, e.g. "58%" means "58/100". - pct_sign=_('%')) - - xblock_info = { - 'id': str(xblock.location), - 'display_name': xblock.display_name_with_default, - 'category': xblock.category, - 'has_children': xblock.has_children - } - - if course is not None and PUBLIC_VIDEO_SHARE.is_enabled(xblock.location.course_key): - xblock_info.update({ - 'video_sharing_enabled': True, - 'video_sharing_options': course.video_sharing_options, - 'video_sharing_doc_url': HelpUrlExpert.the_one().url_for_token('social_sharing') - }) - - if xblock.category == 'course': - discussions_config = DiscussionsConfiguration.get(course.id) - show_unit_level_discussions_toggle = ( - discussions_config.enabled and - discussions_config.supports_in_context_discussions() and - discussions_config.enable_in_context and - discussions_config.unit_level_visibility - ) - xblock_info["unit_level_discussions"] = show_unit_level_discussions_toggle - - if is_concise: - if child_info and child_info.get('children', []): - xblock_info['child_info'] = child_info - # Groups are labelled with their internal ids, rather than with the group name. Replace id with display name. - group_display_name = get_split_group_display_name(xblock, course) - xblock_info['display_name'] = group_display_name if group_display_name else xblock_info['display_name'] - else: - user_partitions = get_user_partition_info(xblock, course=course) - xblock_info.update({ - 'edited_on': get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, - 'published': published, - 'published_on': published_on, - 'studio_url': xblock_studio_url(xblock, parent_xblock), - 'released_to_students': datetime.now(UTC) > xblock.start, - 'release_date': release_date, - 'visibility_state': visibility_state, - 'has_explicit_staff_lock': xblock.fields['visible_to_staff_only'].is_set_on(xblock), - 'start': xblock.fields['start'].to_json(xblock.start), - 'graded': xblock.graded, - 'due_date': get_default_time_display(xblock.due), - 'due': xblock.fields['due'].to_json(xblock.due), - 'relative_weeks_due': xblock.relative_weeks_due, - 'format': xblock.format, - 'course_graders': [grader.get('type') for grader in graders], - 'has_changes': has_changes, - 'actions': xblock_actions, - 'explanatory_message': explanatory_message, - 'group_access': xblock.group_access, - 'user_partitions': user_partitions, - 'show_correctness': xblock.show_correctness, - }) - - if xblock.category == 'sequential': - xblock_info.update({ - 'hide_after_due': xblock.hide_after_due, - }) - elif xblock.category in ('chapter', 'course'): - if xblock.category == 'chapter': - xblock_info.update({ - 'highlights': xblock.highlights, - }) - elif xblock.category == 'course': - xblock_info.update({ - 'highlights_enabled_for_messaging': course.highlights_enabled_for_messaging, - }) - xblock_info.update({ - 'highlights_enabled': True, # used to be controlled by a waffle switch, now just always enabled - 'highlights_preview_only': False, # used to be controlled by a waffle flag, now just always disabled - 'highlights_doc_url': HelpUrlExpert.the_one().url_for_token('content_highlights'), - }) - - # update xblock_info with special exam information if the feature flag is enabled - if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): - if xblock.category == 'course': - xblock_info.update({ - 'enable_proctored_exams': xblock.enable_proctored_exams, - 'create_zendesk_tickets': xblock.create_zendesk_tickets, - 'enable_timed_exams': xblock.enable_timed_exams, - }) - elif xblock.category == 'sequential': - rules_url = settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('online_proctoring_rules', "") - supports_onboarding = does_backend_support_onboarding(course.proctoring_provider) - - proctoring_exam_configuration_link = None - if xblock.is_proctored_exam: - proctoring_exam_configuration_link = get_exam_configuration_dashboard_url( - course.id, xblock_info['id']) - - if course.proctoring_provider == 'proctortrack': - show_review_rules = SHOW_REVIEW_RULES_FLAG.is_enabled(xblock.location.course_key) - else: - show_review_rules = True - - xblock_info.update({ - 'is_proctored_exam': xblock.is_proctored_exam, - 'was_exam_ever_linked_with_external': _was_xblock_ever_exam_linked_with_external( - course, xblock - ), - 'online_proctoring_rules': rules_url, - 'is_practice_exam': xblock.is_practice_exam, - 'is_onboarding_exam': xblock.is_onboarding_exam, - 'is_time_limited': xblock.is_time_limited, - 'exam_review_rules': xblock.exam_review_rules, - 'default_time_limit_minutes': xblock.default_time_limit_minutes, - 'proctoring_exam_configuration_link': proctoring_exam_configuration_link, - 'supports_onboarding': supports_onboarding, - 'show_review_rules': show_review_rules - }) - - # Update with gating info - xblock_info.update(_get_gating_info(course, xblock)) - if is_xblock_unit: - # if xblock is a Unit we add the discussion_enabled option - xblock_info['discussion_enabled'] = xblock.discussion_enabled - if xblock.category == 'sequential': - # Entrance exam subsection should be hidden. in_entrance_exam is - # inherited metadata, all children will have it. - if getattr(xblock, 'in_entrance_exam', False): - xblock_info['is_header_visible'] = False - - if data is not None: - xblock_info['data'] = data - if metadata is not None: - xblock_info['metadata'] = metadata - if include_ancestor_info: - xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline, include_child_info=True) - if child_info: - xblock_info['child_info'] = child_info - if visibility_state == VisibilityState.staff_only: - xblock_info['ancestor_has_staff_lock'] = ancestor_has_staff_lock(xblock, parent_xblock) - else: - xblock_info['ancestor_has_staff_lock'] = False - - if course_outline: - if xblock_info['has_explicit_staff_lock']: - xblock_info['staff_only_message'] = True - elif child_info and child_info['children']: - xblock_info['staff_only_message'] = all( - child['staff_only_message'] for child in child_info['children'] - ) - else: - xblock_info['staff_only_message'] = False - - xblock_info['has_partition_group_components'] = has_children_visible_to_specific_partition_groups( - xblock + deleted_items = delete_orphans( + course_usage_key, request.user.id, commit=True ) - xblock_info['user_partition_info'] = get_visibility_partition_info(xblock, course=course) - - return xblock_info - - -def _was_xblock_ever_exam_linked_with_external(course, xblock): - """ - Determine whether this XBlock is or was ever configured as an external proctored exam. - - If this block is *not* currently an externally linked proctored exam, the best way for us to tell - whether it was was *ever* such is by checking whether - edx-proctoring has an exam record associated with the block's ID, - and the exam record has external_id. - If an exception is not raised, then we know that such a record exists, - indicating that this *was* once an externally linked proctored exam. - - Arguments: - course (CourseBlock) - xblock (XBlock) - - Returns: bool - """ - try: - exam = get_exam_by_content_id(course.id, xblock.location) - return bool('external_id' in exam and exam['external_id']) - except ProctoredExamNotFoundException: - pass - return False - - -def add_container_page_publishing_info(xblock, xblock_info): - """ - Adds information about the xblock's publish state to the supplied - xblock_info for the container page. - """ - def safe_get_username(user_id): - """ - Guard against bad user_ids, like the infamous "**replace_user**". - Note that this will ignore our special known IDs (ModuleStoreEnum.UserID). - We should consider adding special handling for those values. - - :param user_id: the user id to get the username of - :return: username, or None if the user does not exist or user_id is None - """ - if user_id: - try: - return User.objects.get(id=user_id).username - except: # pylint: disable=bare-except - pass - - return None - - xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by) - xblock_info["published_by"] = safe_get_username(xblock.published_by) - xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock) - xblock_info["has_partition_group_components"] = has_children_visible_to_specific_partition_groups(xblock) - if xblock_info["release_date"]: - xblock_info["release_date_from"] = _get_release_date_from(xblock) - if xblock_info["visibility_state"] == VisibilityState.staff_only: - xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock) - else: - xblock_info["staff_lock_from"] = None - - -class VisibilityState: - """ - Represents the possible visibility states for an xblock: - - live - the block and all of its descendants are live to students (excluding staff only items) - Note: Live means both published and released. - - ready - the block is ready to go live and all of its descendants are live or ready (excluding staff only items) - Note: content is ready when it is published and scheduled with a release date in the future. - - unscheduled - the block and all of its descendants have no release date (excluding staff only items) - Note: it is valid for items to be published with no release date in which case they are still unscheduled. - - needs_attention - the block or its descendants are not fully live, ready or unscheduled - (excluding staff only items) - For example: one subsection has draft content, or there's both unreleased and released content in one section. - - staff_only - all of the block's content is to be shown to staff only - Note: staff only items do not affect their parent's state. - - gated - all of the block's content is to be shown to students only after the configured prerequisite is met - """ - live = 'live' - ready = 'ready' - unscheduled = 'unscheduled' - needs_attention = 'needs_attention' - staff_only = 'staff_only' - gated = 'gated' - - -def _compute_visibility_state(xblock, child_info, is_unit_with_changes, is_course_self_paced=False): - """ - Returns the current publish state for the specified xblock and its children - """ - if xblock.visible_to_staff_only: - return VisibilityState.staff_only - elif is_unit_with_changes: - # Note that a unit that has never been published will fall into this category, - # as well as previously published units with draft content. - return VisibilityState.needs_attention - - is_unscheduled = xblock.start == DEFAULT_START_DATE - is_live = is_course_self_paced or datetime.now(UTC) > xblock.start - if child_info and child_info.get('children', []): - all_staff_only = True - all_unscheduled = True - all_live = True - for child in child_info['children']: - child_state = child['visibility_state'] - if child_state == VisibilityState.needs_attention: - return child_state - elif not child_state == VisibilityState.staff_only: - all_staff_only = False - if not child_state == VisibilityState.unscheduled: - all_unscheduled = False - if not child_state == VisibilityState.live: - all_live = False - if all_staff_only: - return VisibilityState.staff_only - elif all_unscheduled: - return VisibilityState.unscheduled if is_unscheduled else VisibilityState.needs_attention - elif all_live: - return VisibilityState.live if is_live else VisibilityState.needs_attention + return JsonResponse({"deleted": deleted_items}) else: - return VisibilityState.ready if not is_unscheduled else VisibilityState.needs_attention - if is_live: - return VisibilityState.live - elif is_unscheduled: - return VisibilityState.unscheduled - else: - return VisibilityState.ready - - -def _create_xblock_ancestor_info(xblock, course_outline=False, include_child_info=False, is_concise=False): - """ - Returns information about the ancestors of an xblock. Note that the direct parent will also return - information about all of its children. - """ - ancestors = [] - - def collect_ancestor_info(ancestor, include_child_info=False, is_concise=False): - """ - Collect xblock info regarding the specified xblock and its ancestors. - """ - if ancestor: - direct_children_only = lambda parent: parent == ancestor - ancestors.append(create_xblock_info( - ancestor, - include_child_info=include_child_info, - course_outline=course_outline, - include_children_predicate=direct_children_only, - is_concise=is_concise - )) - collect_ancestor_info(get_parent_xblock(ancestor), is_concise=is_concise) - collect_ancestor_info(get_parent_xblock(xblock), include_child_info=include_child_info, is_concise=is_concise) - return { - 'ancestors': ancestors - } - - -def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None, - course=None, is_concise=False): - """ - Returns information about the children of an xblock, as well as about the primary category - of xblock expected as children. - """ - child_info = {} - child_category = xblock_primary_child_category(xblock) - if child_category: - child_info = { - 'category': child_category, - 'display_name': xblock_type_display_name(child_category, default_display_name=child_category), - } - if xblock.has_children and include_children_predicate(xblock): - child_info['children'] = [ - create_xblock_info( - child, include_child_info=True, course_outline=course_outline, - include_children_predicate=include_children_predicate, - parent_xblock=xblock, - graders=graders, - user=user, - course=course, - is_concise=is_concise - ) for child in xblock.get_children() - ] - return child_info - - -def _get_release_date(xblock, user=None): - """ - Returns the release date for the xblock, or None if the release date has never been set. - """ - # If year of start date is less than 1900 then reset the start date to DEFAULT_START_DATE - reset_to_default = False - try: - reset_to_default = xblock.start.year < 1900 - except ValueError: - # For old mongo courses, accessing the start attribute calls `to_json()`, - # which raises a `ValueError` for years < 1900. - reset_to_default = True - - if reset_to_default and user: - xblock.start = DEFAULT_START_DATE - xblock = _update_with_callback(xblock, user) - - # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set - return get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None - - -def validate_and_update_xblock_due_date(xblock): - """ - Validates the due date for the xblock, and set to None if pre-1900 due date provided - """ - if xblock.due and xblock.due.year < 1900: - xblock.due = None - - -def _get_release_date_from(xblock): - """ - Returns a string representation of the section or subsection that sets the xblock's release date - """ - return _xblock_type_and_display_name(find_release_date_source(xblock)) - - -def _get_staff_lock_from(xblock): - """ - Returns a string representation of the section or subsection that sets the xblock's release date - """ - source = find_staff_lock_source(xblock) - return _xblock_type_and_display_name(source) if source else None - - -def _xblock_type_and_display_name(xblock): - """ - Returns a string representation of the xblock's type and display name - """ - return _('{section_or_subsection} "{display_name}"').format( - section_or_subsection=xblock_type_display_name(xblock), - display_name=xblock.display_name_with_default) + raise PermissionDenied() diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 69a5a5ffd9b0..2533141b2cdf 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -35,10 +35,13 @@ 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 ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, \ - load_services_for_studio -from .helpers import get_parent_xblock, is_unit, xblock_type_display_name -from .block import add_container_page_publishing_info, create_xblock_info +from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url +from ..helpers import get_parent_xblock, is_unit, xblock_type_display_name +from cms.djangoapps.contentstore.xblock_services.xblock_service import ( + add_container_page_publishing_info, + create_xblock_info, + load_services_for_studio, +) __all__ = [ 'container_handler', diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 4dd4bcfaa368..99669e5a7276 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -102,8 +102,10 @@ update_course_details, ) from .component import ADVANCED_COMPONENT_TYPES -from .helpers import is_content_creator -from .block import create_xblock_info +from ..helpers import is_content_creator +from cms.djangoapps.contentstore.xblock_services.xblock_service import ( + create_xblock_info, +) from .library import ( LIBRARIES_ENABLED, LIBRARY_AUTHORING_MICROFRONTEND_URL, diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index ced3ed531b37..b58f0d84bf27 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -23,8 +23,9 @@ 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 .helpers import create_xblock, remove_entrance_exam_graders -from .block import delete_item +from ..helpers import remove_entrance_exam_graders +from ..xblock_services.create_xblock import create_xblock +from cms.djangoapps.contentstore.xblock_services.xblock_service import delete_item __all__ = ['entrance_exam', ] diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 0c2ba76fac57..78c8d6a6e6ab 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -1,47 +1,9 @@ """ -Helper methods for Studio views. +Helpers specific to these specific views. +Since many helper methods are shared between these views as well as views in the /api +and /rest_api folders, they live one level up in contentstore/helpers.py """ - -import urllib -from lxml import etree -from uuid import uuid4 - from django.http import HttpResponse -from django.utils.translation import gettext as _ -from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import DefinitionLocator, LocalId -from xblock.core import XBlock -from xblock.fields import ScopeIds -from xblock.runtime import IdGenerator -from xmodule.modulestore.django import modulestore -from xmodule.tabs import StaticTab - -from cms.djangoapps.contentstore.views.preview import _load_preview_block -from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from common.djangoapps.student import auth -from common.djangoapps.student.roles import CourseCreatorRole, OrgContentCreatorRole -from openedx.core.toggles import ENTRANCE_EXAMS - -try: - # Technically this is a django app plugin, so we should not error if it's not installed: - import openedx.core.djangoapps.content_staging.api as content_staging_api -except ImportError: - content_staging_api = None - -from ..utils import reverse_course_url, reverse_library_url, reverse_usage_url - -__all__ = ['event'] - -# Note: Grader types are used throughout the platform but most usages are simply in-line -# strings. In addition, new grader types can be defined on the fly anytime one is needed -# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio. -GRADER_TYPES = { - "HOMEWORK": "Homework", - "LAB": "Lab", - "ENTRANCE_EXAM": "Entrance Exam", - "MIDTERM_EXAM": "Midterm Exam", - "FINAL_EXAM": "Final Exam" -} def event(request): @@ -50,351 +12,3 @@ def event(request): console logs don't get distracted :-) ''' return HttpResponse(status=204) - - -def get_parent_xblock(xblock): - """ - Returns the xblock that is the parent of the specified xblock, or None if it has no parent. - """ - locator = xblock.location - parent_location = modulestore().get_parent_location(locator) - - if parent_location is None: - return None - return modulestore().get_item(parent_location) - - -def is_unit(xblock, parent_xblock=None): - """ - Returns true if the specified xblock is a vertical that is treated as a unit. - A unit is a vertical that is a direct child of a sequential (aka a subsection). - """ - if xblock.category == 'vertical': - if parent_xblock is None: - parent_xblock = get_parent_xblock(xblock) - parent_category = parent_xblock.category if parent_xblock else None - return parent_category == 'sequential' - return False - - -def xblock_has_own_studio_page(xblock, parent_xblock=None): - """ - Returns true if the specified xblock has an associated Studio page. Most xblocks do - not have their own page but are instead shown on the page of their parent. There - are a few exceptions: - 1. Courses - 2. Verticals that are either: - - themselves treated as units - - a direct child of a unit - 3. XBlocks that support children - """ - category = xblock.category - - if is_unit(xblock, parent_xblock): - return True - elif category == 'vertical': - if parent_xblock is None: - parent_xblock = get_parent_xblock(xblock) - return is_unit(parent_xblock) if parent_xblock else False - - # All other xblocks with children have their own page - return xblock.has_children - - -def xblock_studio_url(xblock, parent_xblock=None, find_parent=False): - """ - Returns the Studio editing URL for the specified xblock. - - You can pass the parent xblock as an optimization, to avoid needing to load - it twice, as sometimes the parent has to be checked. - - If you pass in a leaf block that doesn't have its own Studio page, this will - normally return None, but if you use find_parent=True, this will find the - nearest ancestor (usually the parent unit) that does have a Studio page and - return that URL. - """ - if not xblock_has_own_studio_page(xblock, parent_xblock): - if find_parent: - while xblock and not xblock_has_own_studio_page(xblock, parent_xblock): - xblock = parent_xblock or get_parent_xblock(xblock) - parent_xblock = None - if not xblock: - return None - else: - return None - category = xblock.category - if category == 'course': - return reverse_course_url('course_handler', xblock.location.course_key) - elif category in ('chapter', 'sequential'): - return '{url}?show={usage_key}'.format( - url=reverse_course_url('course_handler', xblock.location.course_key), - usage_key=urllib.parse.quote(str(xblock.location)) - ) - elif category == 'library': - library_key = xblock.location.course_key - return reverse_library_url('library_handler', library_key) - else: - return reverse_usage_url('container_handler', xblock.location) - - -def xblock_type_display_name(xblock, default_display_name=None): - """ - Returns the display name for the specified type of xblock. Note that an instance can be passed in - for context dependent names, e.g. a vertical beneath a sequential is a Unit. - - :param xblock: An xblock instance or the type of xblock (as a string). - :param default_display_name: The default value to return if no display name can be found. - :return: - """ - - if hasattr(xblock, 'category'): - category = xblock.category - if category == 'vertical' and not is_unit(xblock): - return _('Vertical') - else: - category = xblock - if category == 'chapter': - return _('Section') - elif category == 'sequential': - return _('Subsection') - elif category == 'vertical': - return _('Unit') - elif category == 'problem': - # The problem XBlock's display_name.default is not helpful ("Blank Advanced Problem") but changing it could have - # too many ripple effects in other places, so we have a special case for capa problems here. - # Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific - # description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type - # string ("problem"). - return _('Problem') - component_class = XBlock.load_class(category) - if hasattr(component_class, 'display_name') and component_class.display_name.default: - return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string - else: - return default_display_name - - -def xblock_primary_child_category(xblock): - """ - Returns the primary child category for the specified xblock, or None if there is not a primary category. - """ - category = xblock.category - if category == 'course': - return 'chapter' - elif category == 'chapter': - return 'sequential' - elif category == 'sequential': - return 'vertical' - return None - - -def usage_key_with_run(usage_key_string): - """ - Converts usage_key_string to a UsageKey, adding a course run if necessary - """ - usage_key = UsageKey.from_string(usage_key_string) - usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) - return usage_key - - -def remove_entrance_exam_graders(course_key, user): - """ - Removes existing entrance exam graders attached to the specified course - Typically used when adding/removing an entrance exam. - """ - grading_model = CourseGradingModel.fetch(course_key) - graders = grading_model.graders - for i, grader in enumerate(graders): - if grader['type'] == GRADER_TYPES['ENTRANCE_EXAM']: - CourseGradingModel.delete_grader(course_key, i, user) - - -def create_xblock(parent_locator, user, category, display_name, boilerplate=None, is_entrance_exam=False): - """ - Performs the actual grunt work of creating items/xblocks -- knows nothing about requests, views, etc. - """ - store = modulestore() - usage_key = usage_key_with_run(parent_locator) - with store.bulk_operations(usage_key.course_key): - parent = store.get_item(usage_key) - dest_usage_key = usage_key.replace(category=category, name=uuid4().hex) - - # get the metadata, display_name, and definition from the caller - metadata = {} - data = None - template_id = boilerplate - if template_id: - clz = parent.runtime.load_block_type(category) - if clz is not None: - template = clz.get_template(template_id) - if template is not None: - metadata = template.get('metadata', {}) - data = template.get('data') - - if display_name is not None: - metadata['display_name'] = display_name - - # We should use the 'fields' kwarg for newer block settings/values (vs. metadata or data) - fields = {} - - # Entrance Exams: Chapter module positioning - child_position = None - if ENTRANCE_EXAMS.is_enabled(): - if category == 'chapter' and is_entrance_exam: - fields['is_entrance_exam'] = is_entrance_exam - fields['in_entrance_exam'] = True # Inherited metadata, all children will have it - child_position = 0 - - # TODO need to fix components that are sending definition_data as strings, instead of as dicts - # For now, migrate them into dicts here. - if isinstance(data, str): - data = {'data': data} - - created_block = store.create_child( - user.id, - usage_key, - dest_usage_key.block_type, - block_id=dest_usage_key.block_id, - fields=fields, - definition_data=data, - metadata=metadata, - runtime=parent.runtime, - position=child_position, - ) - - # Entrance Exams: Grader assignment - if ENTRANCE_EXAMS.is_enabled(): - course_key = usage_key.course_key - course = store.get_course(course_key) - if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled: - if category == 'sequential' and parent_locator == course.entrance_exam_id: - # Clean up any pre-existing entrance exam graders - remove_entrance_exam_graders(course_key, user) - grader = { - "type": GRADER_TYPES['ENTRANCE_EXAM'], - "min_count": 0, - "drop_count": 0, - "short_label": "Entrance", - "weight": 0 - } - grading_model = CourseGradingModel.update_grader_from_json( - course.id, - grader, - user - ) - CourseGradingModel.update_section_grader_type( - created_block, - grading_model['type'], - user - ) - - # VS[compat] cdodge: This is a hack because static_tabs also have references from the course block, so - # if we add one then we need to also add it to the policy information (i.e. metadata) - # we should remove this once we can break this reference from the course to static tabs - if category == 'static_tab': - display_name = display_name or _("Empty") # Prevent name being None - course = store.get_course(dest_usage_key.course_key) - course.tabs.append( - StaticTab( - name=display_name, - url_slug=dest_usage_key.block_id, - ) - ) - store.update_item(course, user.id) - - return created_block - - -class ImportIdGenerator(IdGenerator): - """ - Modulestore's IdGenerator doesn't work for importing single blocks as OLX, - so we implement our own - """ - def __init__(self, context_key): - super().__init__() - self.context_key = context_key - - def create_aside(self, definition_id, usage_id, aside_type): - """ Generate a new aside key """ - raise NotImplementedError() - - def create_usage(self, def_id) -> UsageKey: - """ Generate a new UsageKey for an XBlock """ - # Note: Split modulestore will detect this temporary ID and create a new block ID when the XBlock is saved. - return self.context_key.make_usage_key(def_id.block_type, LocalId()) - - def create_definition(self, block_type, slug=None) -> DefinitionLocator: - """ Generate a new definition_id for an XBlock """ - # Note: Split modulestore will detect this temporary ID and create a new definition ID when the XBlock is saved. - return DefinitionLocator(block_type, LocalId(block_type)) - - -def import_staged_content_from_user_clipboard(parent_key: UsageKey, request): - """ - Import a block (and any children it has) from "staged" OLX. - Does not deal with permissions or REST stuff - do that before calling this. - - Returns the newly created block on success or None if the clipboard is - empty. - """ - if not content_staging_api: - raise RuntimeError("The required content_staging app is not installed") - user_clipboard = content_staging_api.get_user_clipboard(request.user.id) - if not user_clipboard: - # Clipboard is empty or expired/error/loading - return None - block_type = user_clipboard.content.block_type - olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id) - node = etree.fromstring(olx_str) - store = modulestore() - with store.bulk_operations(parent_key.course_key): - parent_descriptor = store.get_item(parent_key) - # Some blocks like drag-and-drop only work here with the full XBlock runtime loaded: - parent_xblock = _load_preview_block(request, parent_descriptor) - runtime = parent_xblock.runtime - # Generate the new ID: - id_generator = ImportIdGenerator(parent_key.context_key) - def_id = id_generator.create_definition(block_type, user_clipboard.source_usage_key.block_id) - usage_id = id_generator.create_usage(def_id) - keys = ScopeIds(None, block_type, def_id, usage_id) - # parse_xml is a really messy API. We pass both 'keys' and 'id_generator' and, depending on the XBlock, either - # one may be used to determine the new XBlock's usage key, and the other will be ignored. e.g. video ignores - # 'keys' and uses 'id_generator', but the default XBlock parse_xml ignores 'id_generator' and uses 'keys'. - # For children of this block, obviously only id_generator is used. - xblock_class = runtime.load_block_type(block_type) - temp_xblock = xblock_class.parse_xml(node, runtime, keys, id_generator) - if xblock_class.has_children and temp_xblock.children: - raise NotImplementedError("We don't yet support pasting XBlocks with children") - temp_xblock.parent = parent_key - # Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin) - temp_xblock.copied_from_block = str(user_clipboard.source_usage_key) - # Save the XBlock into modulestore. We need to save the block and its parent for this to work: - new_xblock = store.update_item(temp_xblock, request.user.id, allow_not_found=True) - parent_xblock.children.append(new_xblock.location) - store.update_item(parent_xblock, request.user.id) - return new_xblock - - -def is_item_in_course_tree(item): - """ - Check that the item is in the course tree. - - It's possible that the item is not in the course tree - if its parent has been deleted and is now an orphan. - """ - ancestor = item.get_parent() - while ancestor is not None and ancestor.location.block_type != "course": - ancestor = ancestor.get_parent() - - return ancestor is not None - - -def is_content_creator(user, org): - """ - Check if the user has the role to create content. - - This function checks if the User has role to create content - or if the org is supplied, it checks for Org level course content - creator. - """ - return (auth.user_has_role(user, CourseCreatorRole()) or - auth.user_has_role(user, OrgContentCreatorRole(org=org))) diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 8ec1e79d1932..efcad6e6035d 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -42,8 +42,8 @@ from ..config.waffle import REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND from ..utils import add_instructor, reverse_library_url from .component import CONTAINER_TEMPLATES, get_component_templates -from .helpers import is_content_creator -from .block import create_xblock_info +from ..helpers import is_content_creator +from cms.djangoapps.contentstore.xblock_services.xblock_service import create_xblock_info from .user import user_with_role __all__ = ['library_handler', 'manage_library_users'] diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index bb827d361c63..5199553b2b48 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -36,35 +36,48 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory, check_mongo_calls +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + LibraryFactory, + check_mongo_calls, +) from xmodule.partitions.partitions import ( ENROLLMENT_TRACK_PARTITION_ID, MINIMUM_STATIC_PARTITION_ID, Group, - UserPartition + UserPartition, ) from xmodule.partitions.tests.test_partitions import MockPartitionService from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url, duplicate_block, update_from_source -from cms.djangoapps.contentstore.views import block as item_module +from cms.djangoapps.contentstore.utils import ( + reverse_course_url, + reverse_usage_url, + duplicate_block, + update_from_source, +) +from cms.djangoapps.contentstore.xblock_services import xblock_service as item_module from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from common.djangoapps.xblock_django.models import ( XBlockConfiguration, XBlockStudioConfiguration, - XBlockStudioConfigurationFlag + XBlockStudioConfigurationFlag, ) from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from ..component import component_handler, get_component_templates -from ..block import ( +from cms.djangoapps.contentstore.xblock_services.xblock_service import ( ALWAYS, VisibilityState, - _get_block_info, + get_block_info, _get_source_index, _xblock_type_and_display_name, add_container_page_publishing_info, @@ -76,20 +89,22 @@ class AsideTest(XBlockAside): """ Test xblock aside class """ + FRAG_CONTENT = "

Aside Foo rendered

" field11 = String(default="aside1_default_value1", scope=Scope.content) field12 = String(default="aside1_default_value2", scope=Scope.settings) field13 = String(default="aside1_default_value3", scope=Scope.parent) - @XBlockAside.aside_for('student_view') + @XBlockAside.aside_for("student_view") def student_view_aside(self, block, context): # pylint: disable=unused-argument """Add to the student view""" return Fragment(self.FRAG_CONTENT) class ItemTest(CourseTestCase): - """ Base test class for create, save, and delete """ + """Base test class for create, save, and delete""" + def setUp(self): super().setUp() @@ -102,7 +117,7 @@ def get_item_from_modulestore(self, usage_key, verify_is_draft=False): """ item = self.store.get_item(usage_key) if verify_is_draft: - self.assertTrue(getattr(item, 'is_draft', False)) + self.assertTrue(getattr(item, "is_draft", False)) return item def response_usage_key(self, response): @@ -110,31 +125,35 @@ def response_usage_key(self, response): Get the UsageKey from the response payload and verify that the status_code was 200. :param response: """ - parsed = json.loads(response.content.decode('utf-8')) + parsed = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) - key = UsageKey.from_string(parsed['locator']) + key = UsageKey.from_string(parsed["locator"]) if key.course_key.run is None: - key = key.map_into_course(CourseKey.from_string(parsed['courseKey'])) + key = key.map_into_course(CourseKey.from_string(parsed["courseKey"])) return key - def create_xblock(self, parent_usage_key=None, display_name=None, category=None, boilerplate=None): # lint-amnesty, pylint: disable=missing-function-docstring + def create_xblock( + self, parent_usage_key=None, display_name=None, category=None, boilerplate=None + ): # lint-amnesty, pylint: disable=missing-function-docstring data = { - 'parent_locator': str( - self.usage_key - )if parent_usage_key is None else str(parent_usage_key), - 'category': category + "parent_locator": str(self.usage_key) + if parent_usage_key is None + else str(parent_usage_key), + "category": category, } if display_name is not None: - data['display_name'] = display_name + data["display_name"] = display_name if boilerplate is not None: - data['boilerplate'] = boilerplate - return self.client.ajax_post(reverse('xblock_handler'), json.dumps(data)) + data["boilerplate"] = boilerplate + return self.client.ajax_post(reverse("xblock_handler"), json.dumps(data)) def _create_vertical(self, parent_usage_key=None): """ Creates a vertical, returning its UsageKey. """ - resp = self.create_xblock(category='vertical', parent_usage_key=parent_usage_key) + resp = self.create_xblock( + category="vertical", parent_usage_key=parent_usage_key + ) self.assertEqual(resp.status_code, 200) return self.response_usage_key(resp) @@ -144,10 +163,12 @@ class GetItemTest(ItemTest): """Tests for '/xblock' GET url.""" def _get_preview(self, usage_key, data=None): - """ Makes a request to xblock preview handler """ - preview_url = reverse_usage_url("xblock_view_handler", usage_key, {'view_name': 'container_preview'}) + """Makes a request to xblock preview handler""" + preview_url = reverse_usage_url( + "xblock_view_handler", usage_key, {"view_name": "container_preview"} + ) data = data if data else {} - resp = self.client.get(preview_url, data, HTTP_ACCEPT='application/json') + resp = self.client.get(preview_url, data, HTTP_ACCEPT="application/json") return resp def _get_container_preview(self, usage_key, data=None): @@ -156,15 +177,17 @@ def _get_container_preview(self, usage_key, data=None): """ resp = self._get_preview(usage_key, data) self.assertEqual(resp.status_code, 200) - resp_content = json.loads(resp.content.decode('utf-8')) - html = resp_content['html'] + resp_content = json.loads(resp.content.decode("utf-8")) + html = resp_content["html"] self.assertTrue(html) - resources = resp_content['resources'] + resources = resp_content["resources"] self.assertIsNotNone(resources) return html, resources - def _get_container_preview_with_error(self, usage_key, expected_code, data=None, content_contains=None): - """ Make request and asserts on response code and response contents """ + def _get_container_preview_with_error( + self, usage_key, expected_code, data=None, content_contains=None + ): + """Make request and asserts on response code and response contents""" resp = self._get_preview(usage_key, data) self.assertEqual(resp.status_code, expected_code) if content_contains: @@ -173,11 +196,11 @@ def _get_container_preview_with_error(self, usage_key, expected_code, data=None, def test_get_vertical(self): # Add a vertical - resp = self.create_xblock(category='vertical') + resp = self.create_xblock(category="vertical") usage_key = self.response_usage_key(resp) # Retrieve it - resp = self.client.get(reverse_usage_url('xblock_handler', usage_key)) + resp = self.client.get(reverse_usage_url("xblock_handler", usage_key)) self.assertEqual(resp.status_code, 200) def test_get_empty_container_fragment(self): @@ -185,9 +208,9 @@ def test_get_empty_container_fragment(self): html, __ = self._get_container_preview(root_usage_key) # XBlock messages are added by the Studio wrapper. - self.assertIn('wrapper-xblock-message', html) + self.assertIn("wrapper-xblock-message", html) # Make sure that "wrapper-xblock" does not appear by itself (without -message at end). - self.assertNotRegex(html, r'wrapper-xblock[^-]+') + self.assertNotRegex(html, r"wrapper-xblock[^-]+") # Verify that the header and article tags are still added self.assertIn('
', html) @@ -197,21 +220,26 @@ def test_get_container_fragment(self): root_usage_key = self._create_vertical() # Add a problem beneath a child vertical - child_vertical_usage_key = self._create_vertical(parent_usage_key=root_usage_key) - resp = self.create_xblock(parent_usage_key=child_vertical_usage_key, category='problem', - boilerplate='multiplechoice.yaml') + child_vertical_usage_key = self._create_vertical( + parent_usage_key=root_usage_key + ) + resp = self.create_xblock( + parent_usage_key=child_vertical_usage_key, + category="problem", + boilerplate="multiplechoice.yaml", + ) self.assertEqual(resp.status_code, 200) # Get the preview HTML html, __ = self._get_container_preview(root_usage_key) # Verify that the Studio nesting wrapper has been added - self.assertIn('level-nesting', html) + self.assertIn("level-nesting", html) self.assertIn('
', html) self.assertIn('
', html) # Verify that the Studio element wrapper has been added - self.assertIn('level-element', html) + self.assertIn("level-element", html) def test_get_container_nested_container_fragment(self): """ @@ -224,20 +252,23 @@ def test_get_container_nested_container_fragment(self): self.assertEqual(resp.status_code, 200) wrapper_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=wrapper_usage_key, category='problem', - boilerplate='multiplechoice.yaml') + resp = self.create_xblock( + parent_usage_key=wrapper_usage_key, + category="problem", + boilerplate="multiplechoice.yaml", + ) self.assertEqual(resp.status_code, 200) # Get the preview HTML and verify the View -> link is present. html, __ = self._get_container_preview(root_usage_key) - self.assertIn('wrapper-xblock', html) + self.assertIn("wrapper-xblock", html) self.assertRegex( html, # The instance of the wrapper class will have an auto-generated ID. Allow any # characters after wrapper. '"/container/{}" class="action-button">\\s*View'.format( re.escape(str(wrapper_usage_key)) - ) + ), ) def test_split_test(self): @@ -245,54 +276,71 @@ def test_split_test(self): Test that a split_test block renders all of its children in Studio. """ root_usage_key = self._create_vertical() - resp = self.create_xblock(category='split_test', parent_usage_key=root_usage_key) + resp = self.create_xblock( + category="split_test", parent_usage_key=root_usage_key + ) split_test_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=split_test_usage_key, category='html', - boilerplate='announcement.yaml') + resp = self.create_xblock( + parent_usage_key=split_test_usage_key, + category="html", + boilerplate="announcement.yaml", + ) self.assertEqual(resp.status_code, 200) - resp = self.create_xblock(parent_usage_key=split_test_usage_key, category='html', - boilerplate='zooming_image.yaml') + resp = self.create_xblock( + parent_usage_key=split_test_usage_key, + category="html", + boilerplate="zooming_image.yaml", + ) self.assertEqual(resp.status_code, 200) html, __ = self._get_container_preview(split_test_usage_key) - self.assertIn('Announcement', html) - self.assertIn('Zooming', html) + self.assertIn("Announcement", html) + self.assertIn("Zooming", html) def test_split_test_edited(self): """ Test that rename of a group changes display name of child vertical. """ - self.course.user_partitions = [UserPartition( - 0, 'first_partition', 'First Partition', - [Group("0", 'alpha'), Group("1", 'beta')] - )] + self.course.user_partitions = [ + UserPartition( + 0, + "first_partition", + "First Partition", + [Group("0", "alpha"), Group("1", "beta")], + ) + ] self.store.update_item(self.course, self.user.id) root_usage_key = self._create_vertical() - resp = self.create_xblock(category='split_test', parent_usage_key=root_usage_key) + resp = self.create_xblock( + category="split_test", parent_usage_key=root_usage_key + ) split_test_usage_key = self.response_usage_key(resp) self.client.ajax_post( reverse_usage_url("xblock_handler", split_test_usage_key), - data={'metadata': {'user_partition_id': str(0)}} + data={"metadata": {"user_partition_id": str(0)}}, ) html, __ = self._get_container_preview(split_test_usage_key) - self.assertIn('alpha', html) - self.assertIn('beta', html) + self.assertIn("alpha", html) + self.assertIn("beta", html) # Rename groups in group configuration GROUP_CONFIGURATION_JSON = { - 'id': 0, - 'name': 'first_partition', - 'scheme': 'random', - 'description': 'First Partition', - 'version': UserPartition.VERSION, - 'groups': [ - {'id': 0, 'name': 'New_NAME_A', 'version': 1}, - {'id': 1, 'name': 'New_NAME_B', 'version': 1}, + "id": 0, + "name": "first_partition", + "scheme": "random", + "description": "First Partition", + "version": UserPartition.VERSION, + "groups": [ + {"id": 0, "name": "New_NAME_A", "version": 1}, + {"id": 1, "name": "New_NAME_B", "version": 1}, ], } response = self.client.put( - reverse_course_url('group_configurations_detail_handler', self.course.id, - kwargs={'group_configuration_id': 0}), + reverse_course_url( + "group_configurations_detail_handler", + self.course.id, + kwargs={"group_configuration_id": 0}, + ), data=json.dumps(GROUP_CONFIGURATION_JSON), content_type="application/json", HTTP_ACCEPT="application/json", @@ -300,16 +348,18 @@ def test_split_test_edited(self): ) self.assertEqual(response.status_code, 201) html, __ = self._get_container_preview(split_test_usage_key) - self.assertNotIn('alpha', html) - self.assertNotIn('beta', html) - self.assertIn('New_NAME_A', html) - self.assertIn('New_NAME_B', html) + self.assertNotIn("alpha", html) + self.assertNotIn("beta", html) + self.assertIn("New_NAME_A", html) + self.assertIn("New_NAME_B", html) def test_valid_paging(self): """ Tests that valid paging is passed along to underlying block """ - with patch('cms.djangoapps.contentstore.views.block.get_preview_fragment') as patched_get_preview_fragment: + with patch( + "cms.djangoapps.contentstore.views.block.get_preview_fragment" + ) as patched_get_preview_fragment: retval = Mock() type(retval).content = PropertyMock(return_value="Some content") type(retval).resources = PropertyMock(return_value=[]) @@ -318,14 +368,14 @@ def test_valid_paging(self): root_usage_key = self._create_vertical() _, _ = self._get_container_preview( root_usage_key, - {'enable_paging': 'true', 'page_number': 0, 'page_size': 2} + {"enable_paging": "true", "page_number": 0, "page_size": 2}, ) call_args = patched_get_preview_fragment.call_args[0] _, _, context = call_args - self.assertIn('paging', context) - self.assertEqual({'page_number': 0, 'page_size': 2}, context['paging']) + self.assertIn("paging", context) + self.assertEqual({"page_number": 0, "page_size": 2}, context["paging"]) - @ddt.data([1, 'invalid'], ['invalid', 2]) + @ddt.data([1, "invalid"], ["invalid", 2]) @ddt.unpack def test_invalid_paging(self, page_number, page_size): """ @@ -335,8 +385,12 @@ def test_invalid_paging(self, page_number, page_size): self._get_container_preview_with_error( root_usage_key, 400, - data={'enable_paging': 'true', 'page_number': page_number, 'page_size': page_size}, - content_contains="Couldn't parse paging parameters" + data={ + "enable_paging": "true", + "page_number": page_number, + "page_size": page_size, + }, + content_contains="Couldn't parse paging parameters", ) def test_get_user_partitions_and_groups(self): @@ -349,58 +403,65 @@ def test_get_user_partitions_and_groups(self): scheme=UserPartition.get_scheme("random"), description="Random user partition", groups=[ - Group(id=MINIMUM_STATIC_PARTITION_ID + 1, name="Group A"), # See note above. - Group(id=MINIMUM_STATIC_PARTITION_ID + 2, name="Group B"), # See note above. + Group( + id=MINIMUM_STATIC_PARTITION_ID + 1, name="Group A" + ), # See note above. + Group( + id=MINIMUM_STATIC_PARTITION_ID + 2, name="Group B" + ), # See note above. ], ), ] self.store.update_item(self.course, self.user.id) # Create an item and retrieve it - resp = self.create_xblock(category='vertical') + resp = self.create_xblock(category="vertical") usage_key = self.response_usage_key(resp) - resp = self.client.get(reverse_usage_url('xblock_handler', usage_key)) + resp = self.client.get(reverse_usage_url("xblock_handler", usage_key)) self.assertEqual(resp.status_code, 200) # Check that the partition and group information was returned - result = json.loads(resp.content.decode('utf-8')) - self.assertEqual(result["user_partitions"], [ - { - "id": ENROLLMENT_TRACK_PARTITION_ID, - "name": "Enrollment Track Groups", - "scheme": "enrollment_track", - "groups": [ - { - "id": settings.COURSE_ENROLLMENT_MODES["audit"]["id"], - "name": "Audit", - "selected": False, - "deleted": False, - } - ] - }, - { - "id": MINIMUM_STATIC_PARTITION_ID, - "name": "Random user partition", - "scheme": "random", - "groups": [ - { - "id": MINIMUM_STATIC_PARTITION_ID + 1, - "name": "Group A", - "selected": False, - "deleted": False, - }, - { - "id": MINIMUM_STATIC_PARTITION_ID + 2, - "name": "Group B", - "selected": False, - "deleted": False, - }, - ] - } - ]) + result = json.loads(resp.content.decode("utf-8")) + self.assertEqual( + result["user_partitions"], + [ + { + "id": ENROLLMENT_TRACK_PARTITION_ID, + "name": "Enrollment Track Groups", + "scheme": "enrollment_track", + "groups": [ + { + "id": settings.COURSE_ENROLLMENT_MODES["audit"]["id"], + "name": "Audit", + "selected": False, + "deleted": False, + } + ], + }, + { + "id": MINIMUM_STATIC_PARTITION_ID, + "name": "Random user partition", + "scheme": "random", + "groups": [ + { + "id": MINIMUM_STATIC_PARTITION_ID + 1, + "name": "Group A", + "selected": False, + "deleted": False, + }, + { + "id": MINIMUM_STATIC_PARTITION_ID + 2, + "name": "Group B", + "selected": False, + "deleted": False, + }, + ], + }, + ], + ) self.assertEqual(result["group_access"], {}) - @ddt.data('ancestorInfo', '') + @ddt.data("ancestorInfo", "") def test_ancestor_info(self, field_type): """ Test that we get correct ancestor info. @@ -410,19 +471,33 @@ def test_ancestor_info(self, field_type): """ # Create a parent chapter - chap1 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter1', category='chapter') + chap1 = self.create_xblock( + parent_usage_key=self.course.location, + display_name="chapter1", + category="chapter", + ) chapter_usage_key = self.response_usage_key(chap1) # create a sequential - seq1 = self.create_xblock(parent_usage_key=chapter_usage_key, display_name='seq1', category='sequential') + seq1 = self.create_xblock( + parent_usage_key=chapter_usage_key, + display_name="seq1", + category="sequential", + ) seq_usage_key = self.response_usage_key(seq1) # create a vertical - vert1 = self.create_xblock(parent_usage_key=seq_usage_key, display_name='vertical1', category='vertical') + vert1 = self.create_xblock( + parent_usage_key=seq_usage_key, + display_name="vertical1", + category="vertical", + ) vert_usage_key = self.response_usage_key(vert1) # create problem and an html component - problem1 = self.create_xblock(parent_usage_key=vert_usage_key, display_name='problem1', category='problem') + problem1 = self.create_xblock( + parent_usage_key=vert_usage_key, display_name="problem1", category="problem" + ) problem_usage_key = self.response_usage_key(problem1) def assert_xblock_info(xblock, xblock_info): @@ -433,25 +508,32 @@ def assert_xblock_info(xblock, xblock_info): xblock (XBlock): An XBlock item. xblock_info (dict): A dict containing xblock information. """ - self.assertEqual(str(xblock.location), xblock_info['id']) - self.assertEqual(xblock.display_name, xblock_info['display_name']) - self.assertEqual(xblock.category, xblock_info['category']) - - for usage_key in (problem_usage_key, vert_usage_key, seq_usage_key, chapter_usage_key): + self.assertEqual(str(xblock.location), xblock_info["id"]) + self.assertEqual(xblock.display_name, xblock_info["display_name"]) + self.assertEqual(xblock.category, xblock_info["category"]) + + for usage_key in ( + problem_usage_key, + vert_usage_key, + seq_usage_key, + chapter_usage_key, + ): xblock = self.get_item_from_modulestore(usage_key) - url = reverse_usage_url('xblock_handler', usage_key) + f'?fields={field_type}' + url = ( + reverse_usage_url("xblock_handler", usage_key) + f"?fields={field_type}" + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) - response = json.loads(response.content.decode('utf-8')) - if field_type == 'ancestorInfo': - self.assertIn('ancestors', response) - for ancestor_info in response['ancestors']: + response = json.loads(response.content.decode("utf-8")) + if field_type == "ancestorInfo": + self.assertIn("ancestors", response) + for ancestor_info in response["ancestors"]: parent_xblock = xblock.get_parent() assert_xblock_info(parent_xblock, ancestor_info) xblock = parent_xblock else: - self.assertNotIn('ancestors', response) - self.assertEqual(_get_block_info(xblock), response) + self.assertNotIn("ancestors", response) + self.assertEqual(get_block_info(xblock), response) @ddt.ddt @@ -462,11 +544,13 @@ class DeleteItem(ItemTest): def test_delete_static_page(self, store): course = CourseFactory.create(default_store=store) # Add static tab - resp = self.create_xblock(category='static_tab', parent_usage_key=course.location) + resp = self.create_xblock( + category="static_tab", parent_usage_key=course.location + ) usage_key = self.response_usage_key(resp) # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). - resp = self.client.delete(reverse_usage_url('xblock_handler', usage_key)) + resp = self.client.delete(reverse_usage_url("xblock_handler", usage_key)) self.assertEqual(resp.status_code, 204) @@ -480,13 +564,13 @@ def test_create_nicely(self): Try the straightforward use cases """ # create a chapter - display_name = 'Nicely created' - resp = self.create_xblock(display_name=display_name, category='chapter') + display_name = "Nicely created" + resp = self.create_xblock(display_name=display_name, category="chapter") # get the new item and check its category and display_name chap_usage_key = self.response_usage_key(resp) new_obj = self.get_item_from_modulestore(chap_usage_key) - self.assertEqual(new_obj.scope_ids.block_type, 'chapter') + self.assertEqual(new_obj.scope_ids.block_type, "chapter") self.assertEqual(new_obj.display_name, display_name) self.assertEqual(new_obj.location.org, self.course.location.org) self.assertEqual(new_obj.location.course, self.course.location.course) @@ -496,35 +580,35 @@ def test_create_nicely(self): self.assertIn(chap_usage_key, course.children) # use default display name - resp = self.create_xblock(parent_usage_key=chap_usage_key, category='vertical') + resp = self.create_xblock(parent_usage_key=chap_usage_key, category="vertical") vert_usage_key = self.response_usage_key(resp) # create problem w/ boilerplate - template_id = 'multiplechoice.yaml' + template_id = "multiplechoice.yaml" resp = self.create_xblock( - parent_usage_key=vert_usage_key, - category='problem', - boilerplate=template_id + parent_usage_key=vert_usage_key, category="problem", boilerplate=template_id ) prob_usage_key = self.response_usage_key(resp) problem = self.get_item_from_modulestore(prob_usage_key, verify_is_draft=True) # check against the template template = ProblemBlock.get_template(template_id) - self.assertEqual(problem.data, template['data']) - self.assertEqual(problem.display_name, template['metadata']['display_name']) - self.assertEqual(problem.markdown, template['metadata']['markdown']) + self.assertEqual(problem.data, template["data"]) + self.assertEqual(problem.display_name, template["metadata"]["display_name"]) + self.assertEqual(problem.markdown, template["metadata"]["markdown"]) def test_create_block_negative(self): """ Negative tests for create_item """ # non-existent boilerplate: creates a default - resp = self.create_xblock(category='problem', boilerplate='nosuchboilerplate.yaml') + resp = self.create_xblock( + category="problem", boilerplate="nosuchboilerplate.yaml" + ) self.assertEqual(resp.status_code, 200) def test_create_with_future_date(self): self.assertEqual(self.course.start, datetime(2030, 1, 1, tzinfo=UTC)) - resp = self.create_xblock(category='chapter') + resp = self.create_xblock(category="chapter") usage_key = self.response_usage_key(resp) obj = self.get_item_from_modulestore(usage_key) self.assertEqual(obj.start, datetime(2030, 1, 1, tzinfo=UTC)) @@ -534,30 +618,41 @@ def test_static_tabs_initialization(self): Test that static tab display names are not being initialized as None. """ # Add a new static tab with no explicit name - resp = self.create_xblock(category='static_tab') + resp = self.create_xblock(category="static_tab") usage_key = self.response_usage_key(resp) # Check that its name is not None new_tab = self.get_item_from_modulestore(usage_key) - self.assertEqual(new_tab.display_name, 'Empty') + self.assertEqual(new_tab.display_name, "Empty") class DuplicateHelper: """ Helper mixin class for TestDuplicateItem and TestDuplicateItemWithAsides """ - def _duplicate_and_verify(self, source_usage_key, parent_usage_key, check_asides=False): - """ Duplicates the source, parenting to supplied parent. Then does equality check. """ + + def _duplicate_and_verify( + self, source_usage_key, parent_usage_key, check_asides=False + ): + """Duplicates the source, parenting to supplied parent. Then does equality check.""" usage_key = self._duplicate_item(parent_usage_key, source_usage_key) # pylint: disable=no-member self.assertTrue( - self._check_equality(source_usage_key, usage_key, parent_usage_key, check_asides=check_asides), - "Duplicated item differs from original" + self._check_equality( + source_usage_key, usage_key, parent_usage_key, check_asides=check_asides + ), + "Duplicated item differs from original", ) return usage_key - def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_key=None, check_asides=False, - is_child=False): + def _check_equality( + self, + source_usage_key, + duplicate_usage_key, + parent_usage_key=None, + check_asides=False, + is_child=False, + ): """ Gets source and duplicated items from the modulestore using supplied usage keys. Then verifies that they represent equivalent items (modulo parents and other @@ -574,26 +669,30 @@ def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_ke self.assertEqual(len(duplicated_asides), 1) self.assertEqual(original_asides[0].field11, duplicated_asides[0].field11) self.assertEqual(original_asides[0].field12, duplicated_asides[0].field12) - self.assertNotEqual(original_asides[0].field13, duplicated_asides[0].field13) - self.assertEqual(duplicated_asides[0].field13, 'aside1_default_value3') + self.assertNotEqual( + original_asides[0].field13, duplicated_asides[0].field13 + ) + self.assertEqual(duplicated_asides[0].field13, "aside1_default_value3") self.assertNotEqual( str(original_item.location), str(duplicated_item.location), - "Location of duplicate should be different from original" + "Location of duplicate should be different from original", ) # Parent will only be equal for root of duplicated structure, in the case # where an item is duplicated in-place. if parent_usage_key and str(original_item.parent) == str(parent_usage_key): self.assertEqual( - str(parent_usage_key), str(duplicated_item.parent), - "Parent of duplicate should equal parent of source for root xblock when duplicated in-place" + str(parent_usage_key), + str(duplicated_item.parent), + "Parent of duplicate should equal parent of source for root xblock when duplicated in-place", ) else: self.assertNotEqual( - str(original_item.parent), str(duplicated_item.parent), - "Parent duplicate should be different from source" + str(original_item.parent), + str(duplicated_item.parent), + "Parent duplicate should be different from source", ) # Set the location and parent to be the same so we can make sure the rest of the @@ -607,15 +706,23 @@ def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_ke self.assertEqual( len(original_item.children), len(duplicated_item.children), - "Duplicated item differs in number of children" + "Duplicated item differs in number of children", ) for i in range(len(original_item.children)): - if not self._check_equality(original_item.children[i], duplicated_item.children[i], is_child=True): + if not self._check_equality( + original_item.children[i], + duplicated_item.children[i], + is_child=True, + ): return False duplicated_item.children = original_item.children - return self._verify_duplicate_display_name(original_item, duplicated_item, is_child) + return self._verify_duplicate_display_name( + original_item, duplicated_item, is_child + ) - def _verify_duplicate_display_name(self, original_item, duplicated_item, is_child=False): + def _verify_duplicate_display_name( + self, original_item, duplicated_item, is_child=False + ): """ Verifies display name of duplicated item. """ @@ -624,8 +731,11 @@ def _verify_duplicate_display_name(self, original_item, duplicated_item, is_chil return duplicated_item.display_name == original_item.category return duplicated_item.display_name == original_item.display_name if original_item.display_name is not None: - return duplicated_item.display_name == "Duplicate of '{display_name}'".format( - display_name=original_item.display_name + return ( + duplicated_item.display_name + == "Duplicate of '{display_name}'".format( + display_name=original_item.display_name + ) ) return duplicated_item.display_name == "Duplicate of {display_name}".format( display_name=original_item.category @@ -637,13 +747,13 @@ def _duplicate_item(self, parent_usage_key, source_usage_key, display_name=None) """ # pylint: disable=no-member data = { - 'parent_locator': str(parent_usage_key), - 'duplicate_source_locator': str(source_usage_key) + "parent_locator": str(parent_usage_key), + "duplicate_source_locator": str(source_usage_key), } if display_name is not None: - data['display_name'] = display_name + data["display_name"] = display_name - resp = self.client.ajax_post(reverse('xblock_handler'), json.dumps(data)) + resp = self.client.ajax_post(reverse("xblock_handler"), json.dumps(data)) return self.response_usage_key(resp) @@ -667,30 +777,39 @@ def setUpClass(cls): cls.start_events_isolation() def setUp(self): - """ Creates the test course structure and a few components to 'duplicate'. """ + """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() # Create a parent chapter (for testing children of children). - resp = self.create_xblock(parent_usage_key=self.usage_key, category='chapter') + resp = self.create_xblock(parent_usage_key=self.usage_key, category="chapter") self.chapter_usage_key = self.response_usage_key(resp) # create a sequential - resp = self.create_xblock(parent_usage_key=self.chapter_usage_key, category='sequential') + resp = self.create_xblock( + parent_usage_key=self.chapter_usage_key, category="sequential" + ) self.seq_usage_key = self.response_usage_key(resp) # create a vertical containing a problem and an html component - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='vertical') + resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, category="vertical" + ) self.vert_usage_key = self.response_usage_key(resp) # create problem and an html component - resp = self.create_xblock(parent_usage_key=self.vert_usage_key, category='problem', - boilerplate='multiplechoice.yaml') + resp = self.create_xblock( + parent_usage_key=self.vert_usage_key, + category="problem", + boilerplate="multiplechoice.yaml", + ) self.problem_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=self.vert_usage_key, category='html') + resp = self.create_xblock(parent_usage_key=self.vert_usage_key, category="html") self.html_usage_key = self.response_usage_key(resp) # Create a second sequential just (testing children of children) - self.create_xblock(parent_usage_key=self.chapter_usage_key, category='sequential2') + self.create_xblock( + parent_usage_key=self.chapter_usage_key, category="sequential2" + ) def test_duplicate_equality(self): """ @@ -721,7 +840,7 @@ def test_duplicate_event(self): source_usage_key=self.vert_usage_key, ), }, - event_receiver.call_args.kwargs + event_receiver.call_args.kwargs, ) def test_ordering(self): @@ -730,27 +849,30 @@ def test_ordering(self): (if duplicate and source share the same parent), else at the end of the children of the parent. """ + def verify_order(source_usage_key, parent_usage_key, source_position=None): usage_key = self._duplicate_item(parent_usage_key, source_usage_key) parent = self.get_item_from_modulestore(parent_usage_key) children = parent.children if source_position is None: - self.assertNotIn(source_usage_key, children, 'source item not expected in children array') + self.assertNotIn( + source_usage_key, + children, + "source item not expected in children array", + ) self.assertEqual( - children[len(children) - 1], - usage_key, - "duplicated item not at end" + children[len(children) - 1], usage_key, "duplicated item not at end" ) else: self.assertEqual( children[source_position], source_usage_key, - "source item at wrong position" + "source item at wrong position", ) self.assertEqual( children[source_position + 1], usage_key, - "duplicated item not ordered after source item" + "duplicated item not ordered after source item", ) verify_order(self.problem_usage_key, self.vert_usage_key, 0) @@ -767,25 +889,45 @@ def test_display_name(self): """ Tests the expected display name for the duplicated xblock. """ - def verify_name(source_usage_key, parent_usage_key, expected_name, display_name=None): - usage_key = self._duplicate_item(parent_usage_key, source_usage_key, display_name) + + def verify_name( + source_usage_key, parent_usage_key, expected_name, display_name=None + ): + usage_key = self._duplicate_item( + parent_usage_key, source_usage_key, display_name + ) duplicated_item = self.get_item_from_modulestore(usage_key) self.assertEqual(duplicated_item.display_name, expected_name) return usage_key # Display name comes from template. - dupe_usage_key = verify_name(self.problem_usage_key, self.vert_usage_key, "Duplicate of 'Multiple Choice'") + dupe_usage_key = verify_name( + self.problem_usage_key, + self.vert_usage_key, + "Duplicate of 'Multiple Choice'", + ) # Test dupe of dupe. - verify_name(dupe_usage_key, self.vert_usage_key, "Duplicate of 'Duplicate of 'Multiple Choice''") + verify_name( + dupe_usage_key, + self.vert_usage_key, + "Duplicate of 'Duplicate of 'Multiple Choice''", + ) # Uses default display_name of 'Text' from HTML component. verify_name(self.html_usage_key, self.vert_usage_key, "Duplicate of 'Text'") # The sequence does not have a display_name set, so category is shown. - verify_name(self.seq_usage_key, self.chapter_usage_key, "Duplicate of sequential") + verify_name( + self.seq_usage_key, self.chapter_usage_key, "Duplicate of sequential" + ) # Now send a custom display name for the duplicate. - verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name") + verify_name( + self.seq_usage_key, + self.chapter_usage_key, + "customized name", + display_name="customized name", + ) def test_shallow_duplicate(self): """ @@ -793,8 +935,10 @@ def test_shallow_duplicate(self): """ source_course = CourseFactory() user = UserFactory.create() - source_chapter = BlockFactory(parent=source_course, category='chapter', display_name='Source Chapter') - BlockFactory(parent=source_chapter, category='html', display_name='Child') + source_chapter = BlockFactory( + parent=source_course, category="chapter", display_name="Source Chapter" + ) + BlockFactory(parent=source_chapter, category="html", display_name="Child") # Refresh. source_chapter = self.store.get_item(source_chapter.location) self.assertEqual(len(source_chapter.get_children()), 1) @@ -809,7 +953,7 @@ def test_shallow_duplicate(self): # Refresh here, too, just to be sure. destination_chapter = self.store.get_item(destination_location) self.assertEqual(len(destination_chapter.get_children()), 0) - self.assertEqual(destination_chapter.display_name, 'Source Chapter') + self.assertEqual(destination_chapter.display_name, "Source Chapter") @ddt.ddt @@ -836,40 +980,77 @@ def setup_course(self, default_store=None): # Create group configurations self.course.user_partitions = [ - UserPartition(0, 'first_partition', 'Test Partition', [Group("0", 'alpha'), Group("1", 'beta')]) + UserPartition( + 0, + "first_partition", + "Test Partition", + [Group("0", "alpha"), Group("1", "beta")], + ) ] self.store.update_item(self.course, self.user.id) # Create a parent chapter - chap1 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter1', category='chapter') + chap1 = self.create_xblock( + parent_usage_key=self.course.location, + display_name="chapter1", + category="chapter", + ) self.chapter_usage_key = self.response_usage_key(chap1) - chap2 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter2', category='chapter') + chap2 = self.create_xblock( + parent_usage_key=self.course.location, + display_name="chapter2", + category="chapter", + ) self.chapter2_usage_key = self.response_usage_key(chap2) # Create a sequential - seq1 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq1', category='sequential') + seq1 = self.create_xblock( + parent_usage_key=self.chapter_usage_key, + display_name="seq1", + category="sequential", + ) self.seq_usage_key = self.response_usage_key(seq1) - seq2 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq2', category='sequential') + seq2 = self.create_xblock( + parent_usage_key=self.chapter_usage_key, + display_name="seq2", + category="sequential", + ) self.seq2_usage_key = self.response_usage_key(seq2) # Create a vertical - vert1 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical1', category='vertical') + vert1 = self.create_xblock( + parent_usage_key=self.seq_usage_key, + display_name="vertical1", + category="vertical", + ) self.vert_usage_key = self.response_usage_key(vert1) - vert2 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical2', category='vertical') + vert2 = self.create_xblock( + parent_usage_key=self.seq_usage_key, + display_name="vertical2", + category="vertical", + ) self.vert2_usage_key = self.response_usage_key(vert2) # Create problem and an html component - problem1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='problem1', category='problem') + problem1 = self.create_xblock( + parent_usage_key=self.vert_usage_key, + display_name="problem1", + category="problem", + ) self.problem_usage_key = self.response_usage_key(problem1) - html1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='html1', category='html') + html1 = self.create_xblock( + parent_usage_key=self.vert_usage_key, display_name="html1", category="html" + ) self.html_usage_key = self.response_usage_key(html1) # Create a content experiment - resp = self.create_xblock(category='split_test', parent_usage_key=self.vert_usage_key) + resp = self.create_xblock( + category="split_test", parent_usage_key=self.vert_usage_key + ) self.split_test_usage_key = self.response_usage_key(resp) def setup_and_verify_content_experiment(self, partition_id): @@ -879,7 +1060,9 @@ def setup_and_verify_content_experiment(self, partition_id): Arguments: partition_id (int): User partition id. """ - split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) + split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True + ) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(split_test.user_partition_id, -1) @@ -888,11 +1071,16 @@ def setup_and_verify_content_experiment(self, partition_id): # Set group configuration self.client.ajax_post( reverse_usage_url("xblock_handler", self.split_test_usage_key), - data={'metadata': {'user_partition_id': str(partition_id)}} + data={"metadata": {"user_partition_id": str(partition_id)}}, + ) + split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True ) - split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) self.assertEqual(split_test.user_partition_id, partition_id) - self.assertEqual(len(split_test.children), len(self.course.user_partitions[partition_id].groups)) + self.assertEqual( + len(split_test.children), + len(self.course.user_partitions[partition_id].groups), + ) return split_test def _move_component(self, source_usage_key, target_usage_key, target_index=None): @@ -908,16 +1096,14 @@ def _move_component(self, source_usage_key, target_usage_key, target_index=None) resp (JsonResponse): Response after the move operation is complete. """ data = { - 'move_source_locator': str(source_usage_key), - 'parent_locator': str(target_usage_key) + "move_source_locator": str(source_usage_key), + "parent_locator": str(target_usage_key), } if target_index is not None: - data['target_index'] = target_index + data["target_index"] = target_index return self.client.patch( - reverse('xblock_handler'), - json.dumps(data), - content_type='application/json' + reverse("xblock_handler"), json.dumps(data), content_type="application/json" ) def assert_move_item(self, source_usage_key, target_usage_key, target_index=None): @@ -933,12 +1119,14 @@ def assert_move_item(self, source_usage_key, target_usage_key, target_index=None parent = self.get_item_from_modulestore(parent_loc) source_index = _get_source_index(source_usage_key, parent) expected_index = target_index if target_index is not None else source_index - response = self._move_component(source_usage_key, target_usage_key, target_index) + response = self._move_component( + source_usage_key, target_usage_key, target_index + ) self.assertEqual(response.status_code, 200) - response = json.loads(response.content.decode('utf-8')) - self.assertEqual(response['move_source_locator'], str(source_usage_key)) - self.assertEqual(response['parent_locator'], str(target_usage_key)) - self.assertEqual(response['source_index'], expected_index) + response = json.loads(response.content.decode("utf-8")) + self.assertEqual(response["move_source_locator"], str(source_usage_key)) + self.assertEqual(response["parent_locator"], str(target_usage_key)) + self.assertEqual(response["source_index"], expected_index) # Verify parent referance has been changed now. new_parent_loc = self.store.get_parent_location(source_usage_key) @@ -963,9 +1151,9 @@ def test_move_component(self, store_type): """ self.setup_course(default_store=store_type) for source_usage_key, target_usage_key in [ - (self.html_usage_key, self.vert2_usage_key), - (self.vert_usage_key, self.seq2_usage_key), - (self.seq_usage_key, self.chapter2_usage_key) + (self.html_usage_key, self.vert2_usage_key), + (self.vert_usage_key, self.seq2_usage_key), + (self.seq_usage_key, self.chapter2_usage_key), ]: self.assert_move_item(source_usage_key, target_usage_key) @@ -978,7 +1166,9 @@ def test_move_source_index(self): self.assertEqual(len(children), 3) # Create a component within vert2. - resp = self.create_xblock(parent_usage_key=self.vert2_usage_key, display_name='html2', category='html') + resp = self.create_xblock( + parent_usage_key=self.vert2_usage_key, display_name="html2", category="html" + ) html2_usage_key = self.response_usage_key(resp) # Move html2_usage_key inside vert_usage_key at second position. @@ -998,8 +1188,8 @@ def test_move_undo(self): # Move component and verify that response contains initial index response = self._move_component(self.html_usage_key, self.vert2_usage_key) - response = json.loads(response.content.decode('utf-8')) - self.assertEqual(original_index, response['source_index']) + response = json.loads(response.content.decode("utf-8")) + self.assertEqual(original_index, response["source_index"]) # Verify that new parent has the moved component at the last index. parent = self.get_item_from_modulestore(self.vert2_usage_key) @@ -1010,9 +1200,11 @@ def test_move_undo(self): self.assertNotEqual(original_index, source_index) # Undo Move to the original index, use the source index fetched from the response. - response = self._move_component(self.html_usage_key, self.vert_usage_key, response['source_index']) - response = json.loads(response.content.decode('utf-8')) - self.assertEqual(original_index, response['source_index']) + response = self._move_component( + self.html_usage_key, self.vert_usage_key, response["source_index"] + ) + response = json.loads(response.content.decode("utf-8")) + self.assertEqual(original_index, response["source_index"]) def test_move_large_target_index(self): """ @@ -1020,15 +1212,18 @@ def test_move_large_target_index(self): """ parent = self.get_item_from_modulestore(self.vert2_usage_key) parent_children_length = len(parent.children) - response = self._move_component(self.html_usage_key, self.vert2_usage_key, parent_children_length + 10) + response = self._move_component( + self.html_usage_key, self.vert2_usage_key, parent_children_length + 10 + ) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - expected_error = 'You can not move {usage_key} at an invalid index ({target_index}).'.format( - usage_key=self.html_usage_key, - target_index=parent_children_length + 10 + expected_error = ( + "You can not move {usage_key} at an invalid index ({target_index}).".format( + usage_key=self.html_usage_key, target_index=parent_children_length + 10 + ) ) - self.assertEqual(expected_error, response['error']) + self.assertEqual(expected_error, response["error"]) new_parent_loc = self.store.get_parent_location(self.html_usage_key) self.assertEqual(new_parent_loc, self.vert_usage_key) @@ -1039,13 +1234,13 @@ def test_invalid_move(self): parent_loc = self.store.get_parent_location(self.html_usage_key) response = self._move_component(self.html_usage_key, self.seq_usage_key) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - expected_error = 'You can not move {source_type} into {target_type}.'.format( + expected_error = "You can not move {source_type} into {target_type}.".format( source_type=self.html_usage_key.block_type, - target_type=self.seq_usage_key.block_type + target_type=self.seq_usage_key.block_type, ) - self.assertEqual(expected_error, response['error']) + self.assertEqual(expected_error, response["error"]) new_parent_loc = self.store.get_parent_location(self.html_usage_key) self.assertEqual(new_parent_loc, parent_loc) @@ -1057,34 +1252,46 @@ def test_move_current_parent(self): self.assertEqual(parent_loc, self.vert_usage_key) response = self._move_component(self.html_usage_key, self.vert_usage_key) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response['error'], 'Item is already present in target location.') - self.assertEqual(self.store.get_parent_location(self.html_usage_key), parent_loc) + self.assertEqual( + response["error"], "Item is already present in target location." + ) + self.assertEqual( + self.store.get_parent_location(self.html_usage_key), parent_loc + ) def test_can_not_move_into_itself(self): """ Test that a component can not be moved to itself. """ library_content = self.create_xblock( - parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content' + parent_usage_key=self.vert_usage_key, + display_name="library content block", + category="library_content", ) library_content_usage_key = self.response_usage_key(library_content) parent_loc = self.store.get_parent_location(library_content_usage_key) self.assertEqual(parent_loc, self.vert_usage_key) - response = self._move_component(library_content_usage_key, library_content_usage_key) + response = self._move_component( + library_content_usage_key, library_content_usage_key + ) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response['error'], 'You can not move an item into itself.') - self.assertEqual(self.store.get_parent_location(self.html_usage_key), parent_loc) + self.assertEqual(response["error"], "You can not move an item into itself.") + self.assertEqual( + self.store.get_parent_location(self.html_usage_key), parent_loc + ) def test_move_library_content(self): """ Test that library content can be moved to any other valid location. """ library_content = self.create_xblock( - parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content' + parent_usage_key=self.vert_usage_key, + display_name="library content block", + category="library_content", ) library_content_usage_key = self.response_usage_key(library_content) parent_loc = self.store.get_parent_location(library_content_usage_key) @@ -1096,7 +1303,9 @@ def test_move_into_library_content(self): Test that a component can be moved into library content. """ library_content = self.create_xblock( - parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content' + parent_usage_key=self.vert_usage_key, + display_name="library content block", + category="library_content", ) library_content_usage_key = self.response_usage_key(library_content) self.assert_move_item(self.html_usage_key, library_content_usage_key) @@ -1118,7 +1327,9 @@ def test_move_content_experiment_components(self): # Add html component to Group A. html1 = self.create_xblock( - parent_usage_key=split_test.children[0], display_name='html1', category='html' + parent_usage_key=split_test.children[0], + display_name="html1", + category="html", ) html_usage_key = self.response_usage_key(html1) @@ -1139,10 +1350,15 @@ def test_can_not_move_into_content_experiment_level(self): self.setup_and_verify_content_experiment(0) response = self._move_component(self.html_usage_key, self.split_test_usage_key) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response['error'], 'You can not move an item directly into content experiment.') - self.assertEqual(self.store.get_parent_location(self.html_usage_key), self.vert_usage_key) + self.assertEqual( + response["error"], + "You can not move an item directly into content experiment.", + ) + self.assertEqual( + self.store.get_parent_location(self.html_usage_key), self.vert_usage_key + ) def test_can_not_move_content_experiment_into_its_children(self): """ @@ -1152,42 +1368,60 @@ def test_can_not_move_content_experiment_into_its_children(self): # Try to move content experiment inside it's child groups. for child_vert_usage_key in split_test.children: - response = self._move_component(self.split_test_usage_key, child_vert_usage_key) + response = self._move_component( + self.split_test_usage_key, child_vert_usage_key + ) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response['error'], 'You can not move an item into it\'s child.') - self.assertEqual(self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key) + self.assertEqual( + response["error"], "You can not move an item into it's child." + ) + self.assertEqual( + self.store.get_parent_location(self.split_test_usage_key), + self.vert_usage_key, + ) # Create content experiment inside group A and set it's group configuration. - resp = self.create_xblock(category='split_test', parent_usage_key=split_test.children[0]) + resp = self.create_xblock( + category="split_test", parent_usage_key=split_test.children[0] + ) child_split_test_usage_key = self.response_usage_key(resp) self.client.ajax_post( reverse_usage_url("xblock_handler", child_split_test_usage_key), - data={'metadata': {'user_partition_id': str(0)}} + data={"metadata": {"user_partition_id": str(0)}}, + ) + child_split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True ) - child_split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) # Try to move content experiment further down the level to a child group A nested inside main group A. - response = self._move_component(self.split_test_usage_key, child_split_test.children[0]) + response = self._move_component( + self.split_test_usage_key, child_split_test.children[0] + ) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - self.assertEqual(response['error'], 'You can not move an item into it\'s child.') - self.assertEqual(self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key) + self.assertEqual(response["error"], "You can not move an item into it's child.") + self.assertEqual( + self.store.get_parent_location(self.split_test_usage_key), + self.vert_usage_key, + ) def test_move_invalid_source_index(self): """ Test moving an item to an invalid index. """ - target_index = 'test_index' + target_index = "test_index" parent_loc = self.store.get_parent_location(self.html_usage_key) - response = self._move_component(self.html_usage_key, self.vert2_usage_key, target_index) + response = self._move_component( + self.html_usage_key, self.vert2_usage_key, target_index + ) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) + response = json.loads(response.content.decode("utf-8")) - error = f'You must provide target_index ({target_index}) as an integer.' - self.assertEqual(response['error'], error) + error = f"You must provide target_index ({target_index}) as an integer." + self.assertEqual(response["error"], error) new_parent_loc = self.store.get_parent_location(self.html_usage_key) self.assertEqual(new_parent_loc, parent_loc) @@ -1195,26 +1429,29 @@ def test_move_no_target_locator(self): """ Test move an item without specifying the target location. """ - data = {'move_source_locator': str(self.html_usage_key)} + data = {"move_source_locator": str(self.html_usage_key)} with self.assertRaises(InvalidKeyError): self.client.patch( - reverse('xblock_handler'), + reverse("xblock_handler"), json.dumps(data), - content_type='application/json' + content_type="application/json", ) def test_no_move_source_locator(self): """ Test patch request without providing a move source locator. """ - response = self.client.patch( - reverse('xblock_handler') - ) + response = self.client.patch(reverse("xblock_handler")) self.assertEqual(response.status_code, 400) - response = json.loads(response.content.decode('utf-8')) - self.assertEqual(response['error'], 'Patch request did not recognise any parameters to handle.') + response = json.loads(response.content.decode("utf-8")) + self.assertEqual( + response["error"], + "Patch request did not recognise any parameters to handle.", + ) - def _verify_validation_message(self, message, expected_message, expected_message_type): + def _verify_validation_message( + self, message, expected_message, expected_message_type + ): """ Verify that the validation message has the expected validation message and type. """ @@ -1241,7 +1478,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): self.course, course_id=self.course.id, ) - html.runtime._services['partitions'] = partitions_service # lint-amnesty, pylint: disable=protected-access + html.runtime._services["partitions"] = partitions_service # lint-amnesty, pylint: disable=protected-access # Set access settings so html will contradict vert2 when moved into that unit vert2.group_access = {self.course.user_partitions[0].id: [group1.id]} @@ -1272,7 +1509,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): validation = html.validate() self.assertEqual(len(validation.messages), 0) - @patch('cms.djangoapps.contentstore.views.block.log') + @patch("cms.djangoapps.contentstore.xblock_services.xblock_service.log") def test_move_logging(self, mock_logger): """ Test logging when an item is successfully moved. @@ -1283,11 +1520,11 @@ def test_move_logging(self, mock_logger): insert_at = 0 self.assert_move_item(self.html_usage_key, self.vert2_usage_key, insert_at) mock_logger.info.assert_called_with( - 'MOVE: %s moved from %s to %s at %d index', + "MOVE: %s moved from %s to %s at %d index", str(self.html_usage_key), str(self.vert_usage_key), str(self.vert2_usage_key), - insert_at + insert_at, ) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @@ -1304,16 +1541,24 @@ def test_move_and_discard_changes(self, store_type): old_parent_loc = self.store.get_parent_location(self.html_usage_key) # Check that old_parent_loc is not yet published. - self.assertFalse(self.store.has_item(old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only)) + self.assertFalse( + self.store.has_item( + old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only + ) + ) # Publish old_parent_loc unit self.client.ajax_post( reverse_usage_url("xblock_handler", old_parent_loc), - data={'publish': 'make_public'} + data={"publish": "make_public"}, ) # Check that old_parent_loc is now published. - self.assertTrue(self.store.has_item(old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only)) + self.assertTrue( + self.store.has_item( + old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only + ) + ) self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) # Move component html_usage_key in vert2_usage_key @@ -1325,17 +1570,23 @@ def test_move_and_discard_changes(self, store_type): # Now discard changes in old_parent_loc self.client.ajax_post( reverse_usage_url("xblock_handler", old_parent_loc), - data={'publish': 'discard_changes'} + data={"publish": "discard_changes"}, ) # Check that old_parent_loc now is reverted to publish. Changes discarded, html_usage_key moved back. - self.assertTrue(self.store.has_item(old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only)) + self.assertTrue( + self.store.has_item( + old_parent_loc, revision=ModuleStoreEnum.RevisionOption.published_only + ) + ) self.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc))) # Now source item should be back in the old parent. source_item = self.get_item_from_modulestore(self.html_usage_key) self.assertEqual(source_item.parent, old_parent_loc) - self.assertEqual(self.store.get_parent_location(self.html_usage_key), source_item.parent) + self.assertEqual( + self.store.get_parent_location(self.html_usage_key), source_item.parent + ) # Also, check that item is not present in target parent but in source parent target_parent = self.get_item_from_modulestore(self.vert2_usage_key) @@ -1354,14 +1605,16 @@ def test_move_item_not_found(self, store_type=ModuleStoreEnum.Type.mongo): self.setup_course(default_store=store_type) data = { - 'move_source_locator': str(self.usage_key.course_key.make_usage_key('html', 'html_test')), - 'parent_locator': str(self.vert2_usage_key) + "move_source_locator": str( + self.usage_key.course_key.make_usage_key("html", "html_test") + ), + "parent_locator": str(self.vert2_usage_key), } with self.assertRaises(ItemNotFoundError): self.client.patch( - reverse('xblock_handler'), + reverse("xblock_handler"), json.dumps(data), - content_type='application/json' + content_type="application/json", ) @@ -1369,34 +1622,43 @@ class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper): """ Test the duplicate method for blocks with asides. """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE def setUp(self): - """ Creates the test course structure and a few components to 'duplicate'. """ + """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() # Create a parent chapter - resp = self.create_xblock(parent_usage_key=self.usage_key, category='chapter') + resp = self.create_xblock(parent_usage_key=self.usage_key, category="chapter") self.chapter_usage_key = self.response_usage_key(resp) # create a sequential containing a problem and an html component - resp = self.create_xblock(parent_usage_key=self.chapter_usage_key, category='sequential') + resp = self.create_xblock( + parent_usage_key=self.chapter_usage_key, category="sequential" + ) self.seq_usage_key = self.response_usage_key(resp) # create problem and an html component - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='problem', - boilerplate='multiplechoice.yaml') + resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, + category="problem", + boilerplate="multiplechoice.yaml", + ) self.problem_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='html') + resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category="html") self.html_usage_key = self.response_usage_key(resp) - @XBlockAside.register_temp_plugin(AsideTest, 'test_aside') - @patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types', - lambda self, block: ['test_aside']) + @XBlockAside.register_temp_plugin(AsideTest, "test_aside") + @patch( + "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types", + lambda self, block: ["test_aside"], + ) def test_duplicate_equality_with_asides(self): """ Tests that a duplicated xblock aside is identical to the original """ + def create_aside(usage_key, block_type): """ Helper function to create aside @@ -1405,26 +1667,35 @@ def create_aside(usage_key, block_type): key_store = DictKeyValueStore() field_data = KvsFieldData(key_store) - runtime = TestRuntime(services={'field-data': field_data}) + runtime = TestRuntime(services={"field-data": field_data}) def_id = runtime.id_generator.create_definition(block_type) usage_id = runtime.id_generator.create_usage(def_id) - aside = AsideTest(scope_ids=ScopeIds('user', block_type, def_id, usage_id), runtime=runtime) - aside.field11 = '%s_new_value11' % block_type - aside.field12 = '%s_new_value12' % block_type - aside.field13 = '%s_new_value13' % block_type + aside = AsideTest( + scope_ids=ScopeIds("user", block_type, def_id, usage_id), + runtime=runtime, + ) + aside.field11 = "%s_new_value11" % block_type + aside.field12 = "%s_new_value12" % block_type + aside.field13 = "%s_new_value13" % block_type self.store.update_item(item, self.user.id, asides=[aside]) - create_aside(self.html_usage_key, 'html') - create_aside(self.problem_usage_key, 'problem') - create_aside(self.seq_usage_key, 'seq') - create_aside(self.chapter_usage_key, 'chapter') + create_aside(self.html_usage_key, "html") + create_aside(self.problem_usage_key, "problem") + create_aside(self.seq_usage_key, "seq") + create_aside(self.chapter_usage_key, "chapter") - self._duplicate_and_verify(self.problem_usage_key, self.seq_usage_key, check_asides=True) - self._duplicate_and_verify(self.html_usage_key, self.seq_usage_key, check_asides=True) - self._duplicate_and_verify(self.seq_usage_key, self.chapter_usage_key, check_asides=True) + self._duplicate_and_verify( + self.problem_usage_key, self.seq_usage_key, check_asides=True + ) + self._duplicate_and_verify( + self.html_usage_key, self.seq_usage_key, check_asides=True + ) + self._duplicate_and_verify( + self.seq_usage_key, self.chapter_usage_key, check_asides=True + ) class TestEditItemSetup(ItemTest): @@ -1433,27 +1704,37 @@ class TestEditItemSetup(ItemTest): """ def setUp(self): - """ Creates the test course structure and a couple problems to 'edit'. """ + """Creates the test course structure and a couple problems to 'edit'.""" super().setUp() # create a chapter - display_name = 'chapter created' - resp = self.create_xblock(display_name=display_name, category='chapter') + display_name = "chapter created" + resp = self.create_xblock(display_name=display_name, category="chapter") chap_usage_key = self.response_usage_key(resp) # create 2 sequentials - resp = self.create_xblock(parent_usage_key=chap_usage_key, category='sequential') + resp = self.create_xblock( + parent_usage_key=chap_usage_key, category="sequential" + ) self.seq_usage_key = self.response_usage_key(resp) self.seq_update_url = reverse_usage_url("xblock_handler", self.seq_usage_key) - resp = self.create_xblock(parent_usage_key=chap_usage_key, category='sequential') + resp = self.create_xblock( + parent_usage_key=chap_usage_key, category="sequential" + ) self.seq2_usage_key = self.response_usage_key(resp) self.seq2_update_url = reverse_usage_url("xblock_handler", self.seq2_usage_key) # create problem w/ boilerplate - template_id = 'multiplechoice.yaml' - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='problem', boilerplate=template_id) + template_id = "multiplechoice.yaml" + resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, + category="problem", + boilerplate=template_id, + ) self.problem_usage_key = self.response_usage_key(resp) - self.problem_update_url = reverse_usage_url("xblock_handler", self.problem_usage_key) + self.problem_update_url = reverse_usage_url( + "xblock_handler", self.problem_usage_key + ) self.course_update_url = reverse_usage_url("xblock_handler", self.usage_key) @@ -1469,29 +1750,32 @@ def test_delete_field(self): Sending null in for a field 'deletes' it """ self.client.ajax_post( - self.problem_update_url, - data={'metadata': {'rerandomize': 'onreset'}} + self.problem_update_url, data={"metadata": {"rerandomize": "onreset"}} + ) + problem = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True ) - problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) - self.assertEqual(problem.rerandomize, 'onreset') + self.assertEqual(problem.rerandomize, "onreset") self.client.ajax_post( - self.problem_update_url, - data={'metadata': {'rerandomize': None}} + self.problem_update_url, data={"metadata": {"rerandomize": None}} + ) + problem = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True ) - problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) - self.assertEqual(problem.rerandomize, 'never') + self.assertEqual(problem.rerandomize, "never") def test_null_field(self): """ Sending null in for a field 'deletes' it """ - problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) + problem = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True + ) self.assertIsNotNone(problem.markdown) - self.client.ajax_post( - self.problem_update_url, - data={'nullout': ['markdown']} + self.client.ajax_post(self.problem_update_url, data={"nullout": ["markdown"]}) + problem = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True ) - problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) self.assertIsNone(problem.markdown) def test_date_fields(self): @@ -1501,58 +1785,55 @@ def test_date_fields(self): sequential = self.get_item_from_modulestore(self.seq_usage_key) self.assertIsNone(sequential.due) self.client.ajax_post( - self.seq_update_url, - data={'metadata': {'due': '2010-11-22T04:00Z'}} + self.seq_update_url, data={"metadata": {"due": "2010-11-22T04:00Z"}} ) sequential = self.get_item_from_modulestore(self.seq_usage_key) self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.client.ajax_post( - self.seq_update_url, - data={'metadata': {'start': '2010-09-12T14:00Z'}} + self.seq_update_url, data={"metadata": {"start": "2010-09-12T14:00Z"}} ) sequential = self.get_item_from_modulestore(self.seq_usage_key) self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) @ddt.data( - '1000-01-01T00:00Z', - '0150-11-21T14:45Z', - '1899-12-31T23:59Z', - '1789-06-06T22:10Z', - '1001-01-15T19:32Z', + "1000-01-01T00:00Z", + "0150-11-21T14:45Z", + "1899-12-31T23:59Z", + "1789-06-06T22:10Z", + "1001-01-15T19:32Z", ) def test_xblock_due_date_validity(self, date): """ Test due date for the subsection is not pre-1900 """ - self.client.ajax_post( - self.seq_update_url, - data={'metadata': {'due': date}} - ) + self.client.ajax_post(self.seq_update_url, data={"metadata": {"due": date}}) sequential = self.get_item_from_modulestore(self.seq_usage_key) xblock_info = create_xblock_info( sequential, include_child_info=True, include_children_predicate=ALWAYS, - user=self.user + user=self.user, ) # Both display and actual value should be None - self.assertEqual(xblock_info['due_date'], '') - self.assertIsNone(xblock_info['due']) + self.assertEqual(xblock_info["due_date"], "") + self.assertIsNone(xblock_info["due"]) def test_update_generic_fields(self): - new_display_name = 'New Display Name' + new_display_name = "New Display Name" new_max_attempts = 2 self.client.ajax_post( self.problem_update_url, data={ - 'fields': { - 'display_name': new_display_name, - 'max_attempts': new_max_attempts, + "fields": { + "display_name": new_display_name, + "max_attempts": new_max_attempts, } - } + }, + ) + problem = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True ) - problem = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) self.assertEqual(problem.display_name, new_display_name) self.assertEqual(problem.max_attempts, new_max_attempts) @@ -1561,8 +1842,8 @@ def test_delete_child(self): Test deleting a child. """ # Create 2 children of main course. - resp_1 = self.create_xblock(display_name='child 1', category='chapter') - resp_2 = self.create_xblock(display_name='child 2', category='chapter') + resp_1 = self.create_xblock(display_name="child 1", category="chapter") + resp_2 = self.create_xblock(display_name="child 2", category="chapter") chapter1_usage_key = self.response_usage_key(resp_1) chapter2_usage_key = self.response_usage_key(resp_2) @@ -1571,7 +1852,9 @@ def test_delete_child(self): self.assertIn(chapter2_usage_key, course.children) # Remove one child from the course. - resp = self.client.delete(reverse_usage_url("xblock_handler", chapter1_usage_key)) + resp = self.client.delete( + reverse_usage_url("xblock_handler", chapter1_usage_key) + ) self.assertEqual(resp.status_code, 204) # Verify that the child is removed. @@ -1585,8 +1868,12 @@ def test_reorder_children(self): """ # Create 2 child units and re-order them. There was a bug about @draft getting added # to the IDs. - unit_1_resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='vertical') - unit_2_resp = self.create_xblock(parent_usage_key=self.seq_usage_key, category='vertical') + unit_1_resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, category="vertical" + ) + unit_2_resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, category="vertical" + ) unit1_usage_key = self.response_usage_key(unit_1_resp) unit2_usage_key = self.response_usage_key(unit_2_resp) @@ -1600,12 +1887,12 @@ def test_reorder_children(self): resp = self.client.ajax_post( self.seq_update_url, data={ - 'children': [ + "children": [ str(self.problem_usage_key), str(unit2_usage_key), - str(unit1_usage_key) + str(unit1_usage_key), ] - } + }, ) self.assertEqual(resp.status_code, 200) @@ -1619,16 +1906,23 @@ def test_move_parented_child(self): Test moving a child from one Section to another """ unit_1_key = self.response_usage_key( - self.create_xblock(parent_usage_key=self.seq_usage_key, category='vertical', display_name='unit 1') + self.create_xblock( + parent_usage_key=self.seq_usage_key, + category="vertical", + display_name="unit 1", + ) ) unit_2_key = self.response_usage_key( - self.create_xblock(parent_usage_key=self.seq2_usage_key, category='vertical', display_name='unit 2') + self.create_xblock( + parent_usage_key=self.seq2_usage_key, + category="vertical", + display_name="unit 2", + ) ) # move unit 1 from sequential1 to sequential2 resp = self.client.ajax_post( - self.seq2_update_url, - data={'children': [str(unit_1_key), str(unit_2_key)]} + self.seq2_update_url, data={"children": [str(unit_1_key), str(unit_2_key)]} ) self.assertEqual(resp.status_code, 200) @@ -1646,19 +1940,21 @@ def test_move_orphaned_child_error(self): """ Test moving an orphan returns an error """ - unit_1_key = self.store.create_item(self.user.id, self.course_key, 'vertical', 'unit1').location + unit_1_key = self.store.create_item( + self.user.id, self.course_key, "vertical", "unit1" + ).location # adding orphaned unit 1 should return an error resp = self.client.ajax_post( - self.seq2_update_url, - data={'children': [str(unit_1_key)]} + self.seq2_update_url, data={"children": [str(unit_1_key)]} + ) + self.assertContains( + resp, "Invalid data, possibly caused by concurrent authors", status_code=400 ) - self.assertContains(resp, "Invalid data, possibly caused by concurrent authors", status_code=400) # verify children self.assertListEqual( - self.get_item_from_modulestore(self.seq2_usage_key).children, - [] + self.get_item_from_modulestore(self.seq2_usage_key).children, [] ) def test_move_child_creates_orphan_error(self): @@ -1666,30 +1962,41 @@ def test_move_child_creates_orphan_error(self): Test creating an orphan returns an error """ unit_1_key = self.response_usage_key( - self.create_xblock(parent_usage_key=self.seq2_usage_key, category='vertical', display_name='unit 1') + self.create_xblock( + parent_usage_key=self.seq2_usage_key, + category="vertical", + display_name="unit 1", + ) ) unit_2_key = self.response_usage_key( - self.create_xblock(parent_usage_key=self.seq2_usage_key, category='vertical', display_name='unit 2') + self.create_xblock( + parent_usage_key=self.seq2_usage_key, + category="vertical", + display_name="unit 2", + ) ) # remove unit 2 should return an error resp = self.client.ajax_post( - self.seq2_update_url, - data={'children': [str(unit_1_key)]} + self.seq2_update_url, data={"children": [str(unit_1_key)]} + ) + self.assertContains( + resp, "Invalid data, possibly caused by concurrent authors", status_code=400 ) - self.assertContains(resp, "Invalid data, possibly caused by concurrent authors", status_code=400) # verify children self.assertListEqual( self.get_item_from_modulestore(self.seq2_usage_key).children, - [unit_1_key, unit_2_key] + [unit_1_key, unit_2_key], ) def _is_location_published(self, location): """ Returns whether or not the item with given location has a published version. """ - return modulestore().has_item(location, revision=ModuleStoreEnum.RevisionOption.published_only) + return modulestore().has_item( + location, revision=ModuleStoreEnum.RevisionOption.published_only + ) def _verify_published_with_no_draft(self, location): """ @@ -1706,34 +2013,32 @@ def _verify_published_with_draft(self, location): self.assertTrue(modulestore().has_changes(modulestore().get_item(location))) def test_make_public(self): - """ Test making a private problem public (publishing it). """ + """Test making a private problem public (publishing it).""" # When the problem is first created, it is only in draft (because of its category). self.assertFalse(self._is_location_published(self.problem_usage_key)) - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) + self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) self._verify_published_with_no_draft(self.problem_usage_key) def test_make_draft(self): - """ Test creating a draft version of a public problem. """ + """Test creating a draft version of a public problem.""" self._make_draft_content_different_from_published() def test_revert_to_published(self): - """ Test reverting draft content to published """ + """Test reverting draft content to published""" self._make_draft_content_different_from_published() self.client.ajax_post( - self.problem_update_url, - data={'publish': 'discard_changes'} + self.problem_update_url, data={"publish": "discard_changes"} ) self._verify_published_with_no_draft(self.problem_usage_key) - published = modulestore().get_item(self.problem_usage_key, - revision=ModuleStoreEnum.RevisionOption.published_only) + published = modulestore().get_item( + self.problem_usage_key, + revision=ModuleStoreEnum.RevisionOption.published_only, + ) self.assertIsNone(published.due) def test_republish(self): - """ Test republishing an item. """ - new_display_name = 'New Display Name' + """Test republishing an item.""" + new_display_name = "New Display Name" # When the problem is first created, it is only in draft (because of its category). self.assertFalse(self._is_location_published(self.problem_usage_key)) @@ -1742,37 +2047,32 @@ def test_republish(self): self.client.ajax_post( self.problem_update_url, data={ - 'publish': 'republish', - 'metadata': { - 'display_name': new_display_name - } - } + "publish": "republish", + "metadata": {"display_name": new_display_name}, + }, ) self.assertFalse(self._is_location_published(self.problem_usage_key)) - draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) + draft = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True + ) self.assertEqual(draft.display_name, new_display_name) # Publish the item - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) + self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) # Now republishing should update the published version - new_display_name_2 = 'New Display Name 2' + new_display_name_2 = "New Display Name 2" self.client.ajax_post( self.problem_update_url, data={ - 'publish': 'republish', - 'metadata': { - 'display_name': new_display_name_2 - } - } + "publish": "republish", + "metadata": {"display_name": new_display_name_2}, + }, ) self._verify_published_with_no_draft(self.problem_usage_key) published = modulestore().get_item( self.problem_usage_key, - revision=ModuleStoreEnum.RevisionOption.published_only + revision=ModuleStoreEnum.RevisionOption.published_only, ) self.assertEqual(published.display_name, new_display_name_2) @@ -1780,14 +2080,20 @@ def test_direct_only_categories_not_republished(self): """Verify that republish is ignored for items in DIRECT_ONLY_CATEGORIES""" # Create a vertical child with published and unpublished versions. # If the parent sequential is not re-published, then the child problem should also not be re-published. - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical', category='vertical') + resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, + display_name="vertical", + category="vertical", + ) vertical_usage_key = self.response_usage_key(resp) - vertical_update_url = reverse_usage_url('xblock_handler', vertical_usage_key) - self.client.ajax_post(vertical_update_url, data={'publish': 'make_public'}) - self.client.ajax_post(vertical_update_url, data={'metadata': {'display_name': 'New Display Name'}}) + vertical_update_url = reverse_usage_url("xblock_handler", vertical_usage_key) + self.client.ajax_post(vertical_update_url, data={"publish": "make_public"}) + self.client.ajax_post( + vertical_update_url, data={"metadata": {"display_name": "New Display Name"}} + ) self._verify_published_with_draft(self.seq_usage_key) - self.client.ajax_post(self.seq_update_url, data={'publish': 'republish'}) + self.client.ajax_post(self.seq_update_url, data={"publish": "republish"}) self._verify_published_with_draft(self.seq_usage_key) def _make_draft_content_different_from_published(self): @@ -1795,100 +2101,110 @@ def _make_draft_content_different_from_published(self): Helper method to create different draft and published versions of a problem. """ # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) + self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) self._verify_published_with_no_draft(self.problem_usage_key) - published = modulestore().get_item(self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only) # lint-amnesty, pylint: disable=line-too-long + published = modulestore().get_item( + self.problem_usage_key, + revision=ModuleStoreEnum.RevisionOption.published_only, + ) # lint-amnesty, pylint: disable=line-too-long # Update the draft version and check that published is different. self.client.ajax_post( - self.problem_update_url, - data={'metadata': {'due': '2077-10-10T04:00Z'}} + self.problem_update_url, data={"metadata": {"due": "2077-10-10T04:00Z"}} + ) + updated_draft = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True ) - updated_draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertIsNone(published.due) # Fetch the published version again to make sure the due date is still unset. - published = modulestore().get_item(published.location, revision=ModuleStoreEnum.RevisionOption.published_only) + published = modulestore().get_item( + published.location, revision=ModuleStoreEnum.RevisionOption.published_only + ) self.assertIsNone(published.due) def test_make_public_with_update(self): - """ Update a problem and make it public at the same time. """ + """Update a problem and make it public at the same time.""" self.client.ajax_post( self.problem_update_url, - data={ - 'metadata': {'due': '2077-10-10T04:00Z'}, - 'publish': 'make_public' - } + data={"metadata": {"due": "2077-10-10T04:00Z"}, "publish": "make_public"}, ) published = self.get_item_from_modulestore(self.problem_usage_key) self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) def test_published_and_draft_contents_with_update(self): - """ Create a draft and publish it then modify the draft and check that published content is not modified """ + """Create a draft and publish it then modify the draft and check that published content is not modified""" # Make problem public. - self.client.ajax_post( - self.problem_update_url, - data={'publish': 'make_public'} - ) + self.client.ajax_post(self.problem_update_url, data={"publish": "make_public"}) self._verify_published_with_no_draft(self.problem_usage_key) - published = modulestore().get_item(self.problem_usage_key, - revision=ModuleStoreEnum.RevisionOption.published_only) + published = modulestore().get_item( + self.problem_usage_key, + revision=ModuleStoreEnum.RevisionOption.published_only, + ) # Now make a draft self.client.ajax_post( self.problem_update_url, data={ - 'id': str(self.problem_usage_key), - 'metadata': {}, - 'data': "

Problem content draft.

" - } + "id": str(self.problem_usage_key), + "metadata": {}, + "data": "

Problem content draft.

", + }, ) # Both published and draft content should be different - draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) + draft = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True + ) self.assertNotEqual(draft.data, published.data) # Get problem by 'xblock_handler' - view_url = reverse_usage_url("xblock_view_handler", self.problem_usage_key, {"view_name": STUDENT_VIEW}) - resp = self.client.get(view_url, HTTP_ACCEPT='application/json') + view_url = reverse_usage_url( + "xblock_view_handler", self.problem_usage_key, {"view_name": STUDENT_VIEW} + ) + resp = self.client.get(view_url, HTTP_ACCEPT="application/json") self.assertEqual(resp.status_code, 200) # Activate the editing view - view_url = reverse_usage_url("xblock_view_handler", self.problem_usage_key, {"view_name": STUDIO_VIEW}) - resp = self.client.get(view_url, HTTP_ACCEPT='application/json') + view_url = reverse_usage_url( + "xblock_view_handler", self.problem_usage_key, {"view_name": STUDIO_VIEW} + ) + resp = self.client.get(view_url, HTTP_ACCEPT="application/json") self.assertEqual(resp.status_code, 200) # Both published and draft content should still be different - draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True) + draft = self.get_item_from_modulestore( + self.problem_usage_key, verify_is_draft=True + ) self.assertNotEqual(draft.data, published.data) # Fetch the published version again to make sure the data is correct. - published = modulestore().get_item(published.location, revision=ModuleStoreEnum.RevisionOption.published_only) + published = modulestore().get_item( + published.location, revision=ModuleStoreEnum.RevisionOption.published_only + ) self.assertNotEqual(draft.data, published.data) def test_publish_states_of_nested_xblocks(self): - """ Test publishing of a unit page containing a nested xblock """ + """Test publishing of a unit page containing a nested xblock""" - resp = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='Test Unit', category='vertical') + resp = self.create_xblock( + parent_usage_key=self.seq_usage_key, + display_name="Test Unit", + category="vertical", + ) unit_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=unit_usage_key, category='wrapper') + resp = self.create_xblock(parent_usage_key=unit_usage_key, category="wrapper") wrapper_usage_key = self.response_usage_key(resp) - resp = self.create_xblock(parent_usage_key=wrapper_usage_key, category='html') + resp = self.create_xblock(parent_usage_key=wrapper_usage_key, category="html") html_usage_key = self.response_usage_key(resp) # The unit and its children should be private initially - unit_update_url = reverse_usage_url('xblock_handler', unit_usage_key) + unit_update_url = reverse_usage_url("xblock_handler", unit_usage_key) self.assertFalse(self._is_location_published(unit_usage_key)) self.assertFalse(self._is_location_published(html_usage_key)) # Make the unit public and verify that the problem is also made public - resp = self.client.ajax_post( - unit_update_url, - data={'publish': 'make_public'} - ) + resp = self.client.ajax_post(unit_update_url, data={"publish": "make_public"}) self.assertEqual(resp.status_code, 200) self._verify_published_with_no_draft(unit_usage_key) self._verify_published_with_no_draft(html_usage_key) @@ -1897,9 +2213,9 @@ def test_publish_states_of_nested_xblocks(self): resp = self.client.ajax_post( unit_update_url, data={ - 'id': str(unit_usage_key), - 'metadata': {}, - } + "id": str(unit_usage_key), + "metadata": {}, + }, ) self.assertEqual(resp.status_code, 200) self._verify_published_with_draft(unit_usage_key) @@ -1910,42 +2226,49 @@ def test_field_value_errors(self): Test that if the user's input causes a ValueError on an XBlock field, we provide a friendly error message back to the user. """ - response = self.create_xblock(parent_usage_key=self.seq_usage_key, category='video') + response = self.create_xblock( + parent_usage_key=self.seq_usage_key, category="video" + ) video_usage_key = self.response_usage_key(response) - update_url = reverse_usage_url('xblock_handler', video_usage_key) + update_url = reverse_usage_url("xblock_handler", video_usage_key) response = self.client.ajax_post( update_url, data={ - 'id': str(video_usage_key), - 'metadata': { - 'saved_video_position': "Not a valid relative time", + "id": str(video_usage_key), + "metadata": { + "saved_video_position": "Not a valid relative time", }, - } + }, ) self.assertEqual(response.status_code, 400) - parsed = json.loads(response.content.decode('utf-8')) + parsed = json.loads(response.content.decode("utf-8")) self.assertIn("error", parsed) - self.assertIn("Incorrect RelativeTime value", parsed["error"]) # See xmodule/fields.py + self.assertIn( + "Incorrect RelativeTime value", parsed["error"] + ) # See xmodule/fields.py class TestEditItemSplitMongo(TestEditItemSetup): """ Tests for EditItem running on top of the SplitMongoModuleStore. """ + def test_editing_view_wrappers(self): """ Verify that the editing view only generates a single wrapper, no matter how many times it's loaded Exposes: PLAT-417 """ - view_url = reverse_usage_url("xblock_view_handler", self.problem_usage_key, {"view_name": STUDIO_VIEW}) + view_url = reverse_usage_url( + "xblock_view_handler", self.problem_usage_key, {"view_name": STUDIO_VIEW} + ) for __ in range(3): - resp = self.client.get(view_url, HTTP_ACCEPT='application/json') + resp = self.client.get(view_url, HTTP_ACCEPT="application/json") self.assertEqual(resp.status_code, 200) - content = json.loads(resp.content.decode('utf-8')) - self.assertEqual(len(PyQuery(content['html'])(f'.xblock-{STUDIO_VIEW}')), 1) + content = json.loads(resp.content.decode("utf-8")) + self.assertEqual(len(PyQuery(content["html"])(f".xblock-{STUDIO_VIEW}")), 1) class TestEditSplitModule(ItemTest): @@ -1957,37 +2280,55 @@ def setUp(self): super().setUp() self.user = UserFactory() - self.first_user_partition_group_1 = Group(str(MINIMUM_STATIC_PARTITION_ID + 1), 'alpha') - self.first_user_partition_group_2 = Group(str(MINIMUM_STATIC_PARTITION_ID + 2), 'beta') + self.first_user_partition_group_1 = Group( + str(MINIMUM_STATIC_PARTITION_ID + 1), "alpha" + ) + self.first_user_partition_group_2 = Group( + str(MINIMUM_STATIC_PARTITION_ID + 2), "beta" + ) self.first_user_partition = UserPartition( - MINIMUM_STATIC_PARTITION_ID, 'first_partition', 'First Partition', - [self.first_user_partition_group_1, self.first_user_partition_group_2] + MINIMUM_STATIC_PARTITION_ID, + "first_partition", + "First Partition", + [self.first_user_partition_group_1, self.first_user_partition_group_2], ) # There is a test point below (test_create_groups) that purposefully wants the group IDs # of the 2 partitions to overlap (which is not something that normally happens). - self.second_user_partition_group_1 = Group(str(MINIMUM_STATIC_PARTITION_ID + 1), 'Group 1') - self.second_user_partition_group_2 = Group(str(MINIMUM_STATIC_PARTITION_ID + 2), 'Group 2') - self.second_user_partition_group_3 = Group(str(MINIMUM_STATIC_PARTITION_ID + 3), 'Group 3') + self.second_user_partition_group_1 = Group( + str(MINIMUM_STATIC_PARTITION_ID + 1), "Group 1" + ) + self.second_user_partition_group_2 = Group( + str(MINIMUM_STATIC_PARTITION_ID + 2), "Group 2" + ) + self.second_user_partition_group_3 = Group( + str(MINIMUM_STATIC_PARTITION_ID + 3), "Group 3" + ) self.second_user_partition = UserPartition( - MINIMUM_STATIC_PARTITION_ID + 10, 'second_partition', 'Second Partition', + MINIMUM_STATIC_PARTITION_ID + 10, + "second_partition", + "Second Partition", [ self.second_user_partition_group_1, self.second_user_partition_group_2, - self.second_user_partition_group_3 - ] + self.second_user_partition_group_3, + ], ) self.course.user_partitions = [ self.first_user_partition, - self.second_user_partition + self.second_user_partition, ] self.store.update_item(self.course, self.user.id) root_usage_key = self._create_vertical() - resp = self.create_xblock(category='split_test', parent_usage_key=root_usage_key) + resp = self.create_xblock( + category="split_test", parent_usage_key=root_usage_key + ) self.split_test_usage_key = self.response_usage_key(resp) - self.split_test_update_url = reverse_usage_url("xblock_handler", self.split_test_usage_key) + self.split_test_update_url = reverse_usage_url( + "xblock_handler", self.split_test_usage_key + ) self.request_factory = RequestFactory() - self.request = self.request_factory.get('/dummy-url') + self.request = self.request_factory.get("/dummy-url") self.request.user = self.user def _update_partition_id(self, partition_id): @@ -2001,11 +2342,13 @@ def _update_partition_id(self, partition_id): # Even though user_partition_id is Scope.content, it will get saved by the Studio editor as # metadata. The code in block.py will update the field correctly, even though it is not the # expected scope. - data={'metadata': {'user_partition_id': str(partition_id)}} + data={"metadata": {"user_partition_id": str(partition_id)}}, ) # Verify the partition_id was saved. - split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) + split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True + ) self.assertEqual(partition_id, split_test.user_partition_id) return split_test @@ -2022,7 +2365,9 @@ def test_create_groups(self): Test that verticals are created for the configuration groups when a spit test block is edited. """ - split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) + split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True + ) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(-1, split_test.user_partition_id) self.assertEqual(0, len(split_test.children)) @@ -2032,23 +2377,39 @@ def test_create_groups(self): # Verify that child verticals have been set to match the groups self.assertEqual(2, len(split_test.children)) - vertical_0 = self.get_item_from_modulestore(split_test.children[0], verify_is_draft=True) - vertical_1 = self.get_item_from_modulestore(split_test.children[1], verify_is_draft=True) + vertical_0 = self.get_item_from_modulestore( + split_test.children[0], verify_is_draft=True + ) + vertical_1 = self.get_item_from_modulestore( + split_test.children[1], verify_is_draft=True + ) self.assertEqual("vertical", vertical_0.category) self.assertEqual("vertical", vertical_1.category) - self.assertEqual("Group ID " + str(MINIMUM_STATIC_PARTITION_ID + 1), vertical_0.display_name) - self.assertEqual("Group ID " + str(MINIMUM_STATIC_PARTITION_ID + 2), vertical_1.display_name) + self.assertEqual( + "Group ID " + str(MINIMUM_STATIC_PARTITION_ID + 1), vertical_0.display_name + ) + self.assertEqual( + "Group ID " + str(MINIMUM_STATIC_PARTITION_ID + 2), vertical_1.display_name + ) # Verify that the group_id_to_child mapping is correct. self.assertEqual(2, len(split_test.group_id_to_child)) - self.assertEqual(vertical_0.location, split_test.group_id_to_child[str(self.first_user_partition_group_1.id)]) - self.assertEqual(vertical_1.location, split_test.group_id_to_child[str(self.first_user_partition_group_2.id)]) + self.assertEqual( + vertical_0.location, + split_test.group_id_to_child[str(self.first_user_partition_group_1.id)], + ) + self.assertEqual( + vertical_1.location, + split_test.group_id_to_child[str(self.first_user_partition_group_2.id)], + ) def test_split_xblock_info_group_name(self): """ Test that concise outline for split test component gives display name as group name. """ - split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True) + split_test = self.get_item_from_modulestore( + self.split_test_usage_key, verify_is_draft=True + ) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(split_test.user_partition_id, -1) self.assertEqual(len(split_test.children), 0) @@ -2064,10 +2425,14 @@ def test_split_xblock_info_group_name(self): include_child_info=True, include_children_predicate=lambda xblock: xblock.has_children, course=self.course, - user=self.request.user + user=self.request.user, + ) + self.assertEqual( + xblock_info["child_info"]["children"][0]["display_name"], "alpha" + ) + self.assertEqual( + xblock_info["child_info"]["children"][1]["display_name"], "beta" ) - self.assertEqual(xblock_info['child_info']['children'][0]['display_name'], 'alpha') - self.assertEqual(xblock_info['child_info']['children'][1]['display_name'], 'beta') def test_change_user_partition_id(self): """ @@ -2086,15 +2451,30 @@ def test_change_user_partition_id(self): self.assertEqual(5, len(split_test.children)) self.assertEqual(initial_vertical_0_location, split_test.children[0]) self.assertEqual(initial_vertical_1_location, split_test.children[1]) - vertical_0 = self.get_item_from_modulestore(split_test.children[2], verify_is_draft=True) - vertical_1 = self.get_item_from_modulestore(split_test.children[3], verify_is_draft=True) - vertical_2 = self.get_item_from_modulestore(split_test.children[4], verify_is_draft=True) + vertical_0 = self.get_item_from_modulestore( + split_test.children[2], verify_is_draft=True + ) + vertical_1 = self.get_item_from_modulestore( + split_test.children[3], verify_is_draft=True + ) + vertical_2 = self.get_item_from_modulestore( + split_test.children[4], verify_is_draft=True + ) # Verify that the group_id_to child mapping is correct. self.assertEqual(3, len(split_test.group_id_to_child)) - self.assertEqual(vertical_0.location, split_test.group_id_to_child[str(self.second_user_partition_group_1.id)]) - self.assertEqual(vertical_1.location, split_test.group_id_to_child[str(self.second_user_partition_group_2.id)]) - self.assertEqual(vertical_2.location, split_test.group_id_to_child[str(self.second_user_partition_group_3.id)]) + self.assertEqual( + vertical_0.location, + split_test.group_id_to_child[str(self.second_user_partition_group_1.id)], + ) + self.assertEqual( + vertical_1.location, + split_test.group_id_to_child[str(self.second_user_partition_group_2.id)], + ) + self.assertEqual( + vertical_2.location, + split_test.group_id_to_child[str(self.second_user_partition_group_3.id)], + ) self.assertNotEqual(initial_vertical_0_location, vertical_0.location) self.assertNotEqual(initial_vertical_1_location, vertical_1.location) @@ -2142,8 +2522,14 @@ def test_add_groups(self): new_group_id = "1002" split_test.user_partitions = [ UserPartition( - self.first_user_partition.id, 'first_partition', 'First Partition', - [self.first_user_partition_group_1, self.first_user_partition_group_2, Group(new_group_id, 'pie')] + self.first_user_partition.id, + "first_partition", + "First Partition", + [ + self.first_user_partition_group_1, + self.first_user_partition_group_2, + Group(new_group_id, "pie"), + ], ) ] self.store.update_item(split_test, self.user.id) @@ -2156,7 +2542,9 @@ def test_add_groups(self): # CachingDescriptorSystem is used in tests. # CachingDescriptorSystem doesn't have user service, that's needed for # SplitTestBlock. So, in this line of code we add this service manually. - split_test.runtime._services['user'] = DjangoXBlockUserService(self.user) # pylint: disable=protected-access + split_test.runtime._services["user"] = DjangoXBlockUserService( # pylint: disable=protected-access + self.user + ) # Call add_missing_groups method to add the missing group. split_test.add_missing_groups(self.request) @@ -2180,7 +2568,7 @@ def setUp(self): self.request_factory = RequestFactory() - patcher = patch('cms.djangoapps.contentstore.views.component.modulestore') + patcher = patch("cms.djangoapps.contentstore.views.component.modulestore") self.modulestore = patcher.start() self.addCleanup(patcher.stop) @@ -2190,23 +2578,28 @@ def setUp(self): self.block = self.modulestore.return_value.get_item.return_value self.usage_key = BlockUsageLocator( - CourseLocator('dummy_org', 'dummy_course', 'dummy_run'), 'dummy_category', 'dummy_name' + CourseLocator("dummy_org", "dummy_course", "dummy_run"), + "dummy_category", + "dummy_name", ) self.usage_key_string = str(self.usage_key) - self.user = StaffFactory(course_key=CourseLocator('dummy_org', 'dummy_course', 'dummy_run')) - self.request = self.request_factory.get('/dummy-url') + self.user = StaffFactory( + course_key=CourseLocator("dummy_org", "dummy_course", "dummy_run") + ) + self.request = self.request_factory.get("/dummy-url") self.request.user = self.user def test_invalid_handler(self): self.block.handle.side_effect = NoSuchHandlerError with self.assertRaises(Http404): - component_handler(self.request, self.usage_key_string, 'invalid_handler') + component_handler(self.request, self.usage_key_string, "invalid_handler") - @ddt.data('GET', 'POST', 'PUT', 'DELETE') + @ddt.data("GET", "POST", "PUT", "DELETE") def test_request_method(self, method): - - def check_handler(handler, request, suffix): # lint-amnesty, pylint: disable=unused-argument + def check_handler( + handler, request, suffix + ): # lint-amnesty, pylint: disable=unused-argument self.assertEqual(request.method, method) return Response() @@ -2214,21 +2607,27 @@ def check_handler(handler, request, suffix): # lint-amnesty, pylint: disable=un # Have to use the right method to create the request to get the HTTP method that we want req_factory_method = getattr(self.request_factory, method.lower()) - request = req_factory_method('/dummy-url') + request = req_factory_method("/dummy-url") request.user = self.user - component_handler(request, self.usage_key_string, 'dummy_handler') + component_handler(request, self.usage_key_string, "dummy_handler") @ddt.data(200, 404, 500) def test_response_code(self, status_code): - def create_response(handler, request, suffix): # lint-amnesty, pylint: disable=unused-argument + def create_response( + handler, request, suffix + ): # lint-amnesty, pylint: disable=unused-argument return Response(status_code=status_code) self.block.handle = create_response - self.assertEqual(component_handler(self.request, self.usage_key_string, 'dummy_handler').status_code, - status_code) + self.assertEqual( + component_handler( + self.request, self.usage_key_string, "dummy_handler" + ).status_code, + status_code, + ) - @patch('cms.djangoapps.contentstore.views.component.log') + @patch("cms.djangoapps.contentstore.views.component.log") def test_submit_studio_edits_checks_author_permission(self, mock_logger): """ Test logging a user without studio write permissions attempts to run a studio submit handler.. @@ -2237,36 +2636,45 @@ def test_submit_studio_edits_checks_author_permission(self, mock_logger): mock_logger (object): A mock logger object. """ - def create_response(handler, request, suffix): # lint-amnesty, pylint: disable=unused-argument + def create_response( + handler, request, suffix + ): # lint-amnesty, pylint: disable=unused-argument """create dummy response""" return Response(status_code=200) self.request.user = UserFactory() - mock_handler = 'dummy_handler' + mock_handler = "dummy_handler" self.block.handle = create_response with patch( - 'cms.djangoapps.contentstore.views.component.is_xblock_aside', - return_value=False - ), patch("cms.djangoapps.contentstore.views.component.webob_to_django_response"): + "cms.djangoapps.contentstore.views.component.is_xblock_aside", + return_value=False, + ), patch( + "cms.djangoapps.contentstore.views.component.webob_to_django_response" + ): component_handler(self.request, self.usage_key_string, mock_handler) mock_logger.warning.assert_called_with( "%s does not have have studio write permissions on course: %s. write operations not performed on %r", - self. request.user.id, + self.request.user.id, UsageKey.from_string(self.usage_key_string).course_key, - mock_handler + mock_handler, ) - @ddt.data((True, True), (False, False),) + @ddt.data( + (True, True), + (False, False), + ) @ddt.unpack def test_aside(self, is_xblock_aside, is_get_aside_called): """ test get_aside_from_xblock called """ - def create_response(handler, request, suffix): # lint-amnesty, pylint: disable=unused-argument + def create_response( + handler, request, suffix + ): # lint-amnesty, pylint: disable=unused-argument """create dummy response""" return Response(status_code=200) @@ -2281,18 +2689,14 @@ def get_usage_key(): self.block.handle = create_response with patch( - 'cms.djangoapps.contentstore.views.component.is_xblock_aside', - return_value=is_xblock_aside + "cms.djangoapps.contentstore.views.component.is_xblock_aside", + return_value=is_xblock_aside, ), patch( - 'cms.djangoapps.contentstore.views.component.get_aside_from_xblock' + "cms.djangoapps.contentstore.views.component.get_aside_from_xblock" ) as mocked_get_aside_from_xblock, patch( "cms.djangoapps.contentstore.views.component.webob_to_django_response" ) as mocked_webob_to_django_response: - component_handler( - self.request, - get_usage_key(), - 'dummy_handler' - ) + component_handler(self.request, get_usage_key(), "dummy_handler") assert mocked_webob_to_django_response.called is True assert mocked_get_aside_from_xblock.called is is_get_aside_called @@ -2306,22 +2710,46 @@ class TestComponentTemplates(CourseTestCase): def setUp(self): super().setUp() # Advanced Module support levels. - XBlockStudioConfiguration.objects.create(name='poll', enabled=True, support_level="fs") - XBlockStudioConfiguration.objects.create(name='survey', enabled=True, support_level="ps") - XBlockStudioConfiguration.objects.create(name='annotatable', enabled=True, support_level="us") + XBlockStudioConfiguration.objects.create( + name="poll", enabled=True, support_level="fs" + ) + XBlockStudioConfiguration.objects.create( + name="survey", enabled=True, support_level="ps" + ) + XBlockStudioConfiguration.objects.create( + name="annotatable", enabled=True, support_level="us" + ) # Basic component support levels. - XBlockStudioConfiguration.objects.create(name='html', enabled=True, support_level="fs") - XBlockStudioConfiguration.objects.create(name='discussion', enabled=True, support_level="ps") - XBlockStudioConfiguration.objects.create(name='problem', enabled=True, support_level="us") - XBlockStudioConfiguration.objects.create(name='video', enabled=True, support_level="us") + XBlockStudioConfiguration.objects.create( + name="html", enabled=True, support_level="fs" + ) + XBlockStudioConfiguration.objects.create( + name="discussion", enabled=True, support_level="ps" + ) + XBlockStudioConfiguration.objects.create( + name="problem", enabled=True, support_level="us" + ) + XBlockStudioConfiguration.objects.create( + name="video", enabled=True, support_level="us" + ) # ORA Block has it's own category. - XBlockStudioConfiguration.objects.create(name='openassessment', enabled=True, support_level="us") + XBlockStudioConfiguration.objects.create( + name="openassessment", enabled=True, support_level="us" + ) # Library Sourced Block and Library Content block has it's own category. - XBlockStudioConfiguration.objects.create(name='library_sourced', enabled=True, support_level="fs") - XBlockStudioConfiguration.objects.create(name='library_content', enabled=True, support_level="fs") + XBlockStudioConfiguration.objects.create( + name="library_sourced", enabled=True, support_level="fs" + ) + XBlockStudioConfiguration.objects.create( + name="library_content", enabled=True, support_level="fs" + ) # XBlock masquerading as a problem - XBlockStudioConfiguration.objects.create(name='drag-and-drop-v2', enabled=True, support_level="fs") - XBlockStudioConfiguration.objects.create(name='staffgradedxblock', enabled=True, support_level="us") + XBlockStudioConfiguration.objects.create( + name="drag-and-drop-v2", enabled=True, support_level="fs" + ) + XBlockStudioConfiguration.objects.create( + name="staffgradedxblock", enabled=True, support_level="us" + ) self.templates = get_component_templates(self.course) @@ -2330,26 +2758,40 @@ def get_templates_of_type(self, template_type): Returns the templates for the specified type, or None if none is found. """ template_dict = self._get_template_dict_of_type(template_type) - return template_dict.get('templates') if template_dict else None + return template_dict.get("templates") if template_dict else None def get_display_name_of_type(self, template_type): """ Returns the display name for the specified type, or None if none found. """ template_dict = self._get_template_dict_of_type(template_type) - return template_dict.get('display_name') if template_dict else None + return template_dict.get("display_name") if template_dict else None def _get_template_dict_of_type(self, template_type): """ Returns a dictionary of values for a category type. """ - return next((template for template in self.templates if template.get('type') == template_type), None) + return next( + ( + template + for template in self.templates + if template.get("type") == template_type + ), + None, + ) def get_template(self, templates, display_name): """ Returns the template which has the specified display name. """ - return next((template for template in templates if template.get('display_name') == display_name), None) + return next( + ( + template + for template in templates + if template.get("display_name") == display_name + ), + None, + ) def test_basic_components(self): """ @@ -2361,15 +2803,15 @@ def test_basic_components(self): self._verify_basic_component_display_name("discussion", "Discussion") self._verify_basic_component_display_name("video", "Video") self._verify_basic_component_display_name("openassessment", "Open Response") - self.assertGreater(len(self.get_templates_of_type('library')), 0) - self.assertGreater(len(self.get_templates_of_type('html')), 0) - self.assertGreater(len(self.get_templates_of_type('problem')), 0) - self.assertIsNone(self.get_templates_of_type('advanced')) + self.assertGreater(len(self.get_templates_of_type("library")), 0) + self.assertGreater(len(self.get_templates_of_type("html")), 0) + self.assertGreater(len(self.get_templates_of_type("problem")), 0) + self.assertIsNone(self.get_templates_of_type("advanced")) # Now fully disable video through XBlockConfiguration - XBlockConfiguration.objects.create(name='video', enabled=False) + XBlockConfiguration.objects.create(name="video", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type('video')) + self.assertIsNone(self.get_templates_of_type("video")) def test_basic_components_support_levels(self): """ @@ -2381,66 +2823,74 @@ def test_basic_components_support_levels(self): self.assertEqual([], self.get_templates_of_type("video")) supported_problem_templates = [ { - 'boilerplate_name': None, - 'category': 'drag-and-drop-v2', - 'display_name': 'Drag and Drop', - 'hinted': False, - 'support_level': 'fs', - 'tab': 'advanced' + "boilerplate_name": None, + "category": "drag-and-drop-v2", + "display_name": "Drag and Drop", + "hinted": False, + "support_level": "fs", + "tab": "advanced", } ] - self.assertEqual(supported_problem_templates, self.get_templates_of_type("problem")) + self.assertEqual( + supported_problem_templates, self.get_templates_of_type("problem") + ) self.course.allow_unsupported_xblocks = True self.templates = get_component_templates(self.course) self._verify_basic_component("video", "Video", "us") - problem_templates = self.get_templates_of_type('problem') - problem_no_boilerplate = self.get_template(problem_templates, 'Blank Advanced Problem') + problem_templates = self.get_templates_of_type("problem") + problem_no_boilerplate = self.get_template( + problem_templates, "Blank Advanced Problem" + ) self.assertIsNotNone(problem_no_boilerplate) - self.assertEqual('us', problem_no_boilerplate['support_level']) + self.assertEqual("us", problem_no_boilerplate["support_level"]) # Now fully disable video through XBlockConfiguration - XBlockConfiguration.objects.create(name='video', enabled=False) + XBlockConfiguration.objects.create(name="video", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type('video')) + self.assertIsNone(self.get_templates_of_type("video")) def test_advanced_components(self): """ Test the handling of advanced component templates. """ - self.course.advanced_modules.append('word_cloud') + self.course.advanced_modules.append("word_cloud") self.templates = get_component_templates(self.course) - advanced_templates = self.get_templates_of_type('advanced') + advanced_templates = self.get_templates_of_type("advanced") self.assertEqual(len(advanced_templates), 1) world_cloud_template = advanced_templates[0] - self.assertEqual(world_cloud_template.get('category'), 'word_cloud') - self.assertEqual(world_cloud_template.get('display_name'), 'Word cloud') - self.assertIsNone(world_cloud_template.get('boilerplate_name', None)) + self.assertEqual(world_cloud_template.get("category"), "word_cloud") + self.assertEqual(world_cloud_template.get("display_name"), "Word cloud") + self.assertIsNone(world_cloud_template.get("boilerplate_name", None)) # Verify that non-advanced components are not added twice - self.course.advanced_modules.append('video') - self.course.advanced_modules.append('drag-and-drop-v2') + self.course.advanced_modules.append("video") + self.course.advanced_modules.append("drag-and-drop-v2") self.templates = get_component_templates(self.course) - advanced_templates = self.get_templates_of_type('advanced') + advanced_templates = self.get_templates_of_type("advanced") self.assertEqual(len(advanced_templates), 1) only_template = advanced_templates[0] - self.assertNotEqual(only_template.get('category'), 'video') - self.assertNotEqual(only_template.get('category'), 'drag-and-drop-v2') + self.assertNotEqual(only_template.get("category"), "video") + self.assertNotEqual(only_template.get("category"), "drag-and-drop-v2") # Now fully disable word_cloud through XBlockConfiguration - XBlockConfiguration.objects.create(name='word_cloud', enabled=False) + XBlockConfiguration.objects.create(name="word_cloud", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type('advanced')) + self.assertIsNone(self.get_templates_of_type("advanced")) def test_advanced_problems(self): """ Test the handling of advanced problem templates. """ - problem_templates = self.get_templates_of_type('problem') - circuit_template = self.get_template(problem_templates, 'Circuit Schematic Builder') + problem_templates = self.get_templates_of_type("problem") + circuit_template = self.get_template( + problem_templates, "Circuit Schematic Builder" + ) self.assertIsNotNone(circuit_template) - self.assertEqual(circuit_template.get('category'), 'problem') - self.assertEqual(circuit_template.get('boilerplate_name'), 'circuitschematic.yaml') + self.assertEqual(circuit_template.get("category"), "problem") + self.assertEqual( + circuit_template.get("boilerplate_name"), "circuitschematic.yaml" + ) def test_deprecated_no_advance_component_button(self): """ @@ -2448,32 +2898,38 @@ def test_deprecated_no_advance_component_button(self): Studio support given that they are the only modules in `Advanced Module List` """ # Update poll and survey to have "enabled=False". - XBlockStudioConfiguration.objects.create(name='poll', enabled=False, support_level="fs") - XBlockStudioConfiguration.objects.create(name='survey', enabled=False, support_level="fs") + XBlockStudioConfiguration.objects.create( + name="poll", enabled=False, support_level="fs" + ) + XBlockStudioConfiguration.objects.create( + name="survey", enabled=False, support_level="fs" + ) XBlockStudioConfigurationFlag.objects.create(enabled=True) - self.course.advanced_modules.extend(['poll', 'survey']) + self.course.advanced_modules.extend(["poll", "survey"]) templates = get_component_templates(self.course) - button_names = [template['display_name'] for template in templates] - self.assertNotIn('Advanced', button_names) + button_names = [template["display_name"] for template in templates] + self.assertNotIn("Advanced", button_names) def test_cannot_create_deprecated_problems(self): """ Test that xblocks that have Studio support disabled do not show on the "new component" menu. """ # Update poll to have "enabled=False". - XBlockStudioConfiguration.objects.create(name='poll', enabled=False, support_level="fs") + XBlockStudioConfiguration.objects.create( + name="poll", enabled=False, support_level="fs" + ) XBlockStudioConfigurationFlag.objects.create(enabled=True) - self.course.advanced_modules.extend(['annotatable', 'poll', 'survey']) + self.course.advanced_modules.extend(["annotatable", "poll", "survey"]) # Annotatable doesn't show up because it is unsupported (in test setUp). - self._verify_advanced_xblocks(['Survey'], ['ps']) + self._verify_advanced_xblocks(["Survey"], ["ps"]) # Now enable unsupported components. self.course.allow_unsupported_xblocks = True - self._verify_advanced_xblocks(['Annotation', 'Survey'], ['us', 'ps']) + self._verify_advanced_xblocks(["Annotation", "Survey"], ["us", "ps"]) # Now disable Annotatable completely through XBlockConfiguration - XBlockConfiguration.objects.create(name='annotatable', enabled=False) - self._verify_advanced_xblocks(['Survey'], ['ps']) + XBlockConfiguration.objects.create(name="annotatable", enabled=False) + self._verify_advanced_xblocks(["Survey"], ["ps"]) def test_create_support_level_flag_off(self): """ @@ -2481,38 +2937,39 @@ def test_create_support_level_flag_off(self): XBlockConfiguration) if XBlockStudioConfigurationFlag is False. """ XBlockStudioConfigurationFlag.objects.create(enabled=False) - self.course.advanced_modules.extend(['annotatable', 'survey']) - self._verify_advanced_xblocks(['Annotation', 'Survey'], [True, True]) + self.course.advanced_modules.extend(["annotatable", "survey"]) + self._verify_advanced_xblocks(["Annotation", "Survey"], [True, True]) def test_xblock_masquerading_as_problem(self): """ Test the integration of xblocks masquerading as problems. """ + def get_xblock_problem(label): """ Helper method to get the template of any XBlock in the problems list """ self.templates = get_component_templates(self.course) - problem_templates = self.get_templates_of_type('problem') + problem_templates = self.get_templates_of_type("problem") return self.get_template(problem_templates, label) def verify_staffgradedxblock_present(support_level): """ Helper method to verify that staffgradedxblock template is present """ - sgp = get_xblock_problem('Staff Graded Points') + sgp = get_xblock_problem("Staff Graded Points") self.assertIsNotNone(sgp) - self.assertEqual(sgp.get('category'), 'staffgradedxblock') - self.assertEqual(sgp.get('support_level'), support_level) + self.assertEqual(sgp.get("category"), "staffgradedxblock") + self.assertEqual(sgp.get("support_level"), support_level) def verify_dndv2_present(support_level): """ Helper method to verify that DnDv2 template is present """ - dndv2 = get_xblock_problem('Drag and Drop') + dndv2 = get_xblock_problem("Drag and Drop") self.assertIsNotNone(dndv2) - self.assertEqual(dndv2.get('category'), 'drag-and-drop-v2') - self.assertEqual(dndv2.get('support_level'), support_level) + self.assertEqual(dndv2.get("category"), "drag-and-drop-v2") + self.assertEqual(dndv2.get("support_level"), support_level) verify_dndv2_present(True) verify_staffgradedxblock_present(True) @@ -2520,27 +2977,27 @@ def verify_dndv2_present(support_level): # Now enable XBlockStudioConfigurationFlag. The staffgradedxblock block is marked # unsupported, so will no longer show up, but DnDv2 will continue to appear. XBlockStudioConfigurationFlag.objects.create(enabled=True) - self.assertIsNone(get_xblock_problem('Staff Graded Points')) - self.assertIsNotNone(get_xblock_problem('Drag and Drop')) + self.assertIsNone(get_xblock_problem("Staff Graded Points")) + self.assertIsNotNone(get_xblock_problem("Drag and Drop")) # Now allow unsupported components. self.course.allow_unsupported_xblocks = True - verify_staffgradedxblock_present('us') - verify_dndv2_present('fs') + verify_staffgradedxblock_present("us") + verify_dndv2_present("fs") # Now disable the blocks completely through XBlockConfiguration - XBlockConfiguration.objects.create(name='staffgradedxblock', enabled=False) - XBlockConfiguration.objects.create(name='drag-and-drop-v2', enabled=False) - self.assertIsNone(get_xblock_problem('Staff Graded Points')) - self.assertIsNone(get_xblock_problem('Drag and Drop')) + XBlockConfiguration.objects.create(name="staffgradedxblock", enabled=False) + XBlockConfiguration.objects.create(name="drag-and-drop-v2", enabled=False) + self.assertIsNone(get_xblock_problem("Staff Graded Points")) + self.assertIsNone(get_xblock_problem("Drag and Drop")) def test_discussion_button_present_no_provider(self): """ Test the Discussion button present when no discussion provider configured for course """ templates = get_component_templates(self.course) - button_names = [template['display_name'] for template in templates] - assert 'Discussion' in button_names + button_names = [template["display_name"] for template in templates] + assert "Discussion" in button_names def test_discussion_button_present_legacy_provider(self): """ @@ -2549,11 +3006,13 @@ def test_discussion_button_present_legacy_provider(self): course_key = self.course.location.course_key # Create a discussion configuration with discussion provider set as legacy - DiscussionsConfiguration.objects.create(context_key=course_key, enabled=True, provider_type='legacy') + DiscussionsConfiguration.objects.create( + context_key=course_key, enabled=True, provider_type="legacy" + ) templates = get_component_templates(self.course) - button_names = [template['display_name'] for template in templates] - assert 'Discussion' in button_names + button_names = [template["display_name"] for template in templates] + assert "Discussion" in button_names def test_discussion_button_absent_non_legacy_provider(self): """ @@ -2562,33 +3021,41 @@ def test_discussion_button_absent_non_legacy_provider(self): course_key = self.course.location.course_key # Create a discussion configuration with discussion provider set as legacy - DiscussionsConfiguration.objects.create(context_key=course_key, enabled=False, provider_type='ed-discuss') + DiscussionsConfiguration.objects.create( + context_key=course_key, enabled=False, provider_type="ed-discuss" + ) templates = get_component_templates(self.course) - button_names = [template['display_name'] for template in templates] - assert 'Discussion' not in button_names + button_names = [template["display_name"] for template in templates] + assert "Discussion" not in button_names def _verify_advanced_xblocks(self, expected_xblocks, expected_support_levels): """ Verify the names of the advanced xblocks showing in the "new component" menu. """ templates = get_component_templates(self.course) - button_names = [template['display_name'] for template in templates] - self.assertIn('Advanced', button_names) - self.assertEqual(len(templates[0]['templates']), len(expected_xblocks)) - template_display_names = [template['display_name'] for template in templates[0]['templates']] + button_names = [template["display_name"] for template in templates] + self.assertIn("Advanced", button_names) + self.assertEqual(len(templates[0]["templates"]), len(expected_xblocks)) + template_display_names = [ + template["display_name"] for template in templates[0]["templates"] + ] self.assertEqual(template_display_names, expected_xblocks) - template_support_levels = [template['support_level'] for template in templates[0]['templates']] + template_support_levels = [ + template["support_level"] for template in templates[0]["templates"] + ] self.assertEqual(template_support_levels, expected_support_levels) - def _verify_basic_component(self, component_type, display_name, support_level=True, no_of_templates=1): + def _verify_basic_component( + self, component_type, display_name, support_level=True, no_of_templates=1 + ): """ Verify the display name and support level of basic components (that have no boilerplates). """ templates = self.get_templates_of_type(component_type) self.assertEqual(no_of_templates, len(templates)) - self.assertEqual(display_name, templates[0]['display_name']) - self.assertEqual(support_level, templates[0]['support_level']) + self.assertEqual(display_name, templates[0]["display_name"]) + self.assertEqual(support_level, templates[0]["support_level"]) def _verify_basic_component_display_name(self, component_type, display_name): """ @@ -2603,27 +3070,40 @@ class TestXBlockInfo(ItemTest): """ Unit tests for XBlock's outline handling. """ + def setUp(self): super().setUp() user_id = self.user.id self.chapter = BlockFactory.create( - parent_location=self.course.location, category='chapter', display_name="Week 1", user_id=user_id, - highlights=['highlight'], + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + user_id=user_id, + highlights=["highlight"], ) self.sequential = BlockFactory.create( - parent_location=self.chapter.location, category='sequential', display_name="Lesson 1", user_id=user_id + parent_location=self.chapter.location, + category="sequential", + display_name="Lesson 1", + user_id=user_id, ) self.vertical = BlockFactory.create( - parent_location=self.sequential.location, category='vertical', display_name='Unit 1', user_id=user_id + parent_location=self.sequential.location, + category="vertical", + display_name="Unit 1", + user_id=user_id, ) self.video = BlockFactory.create( - parent_location=self.vertical.location, category='video', display_name='My Video', user_id=user_id + parent_location=self.vertical.location, + category="video", + display_name="My Video", + user_id=user_id, ) def test_json_responses(self): - outline_url = reverse_usage_url('xblock_outline_handler', self.usage_key) - resp = self.client.get(outline_url, HTTP_ACCEPT='application/json') - json_response = json.loads(resp.content.decode('utf-8')) + outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key) + resp = self.client.get(outline_url, HTTP_ACCEPT="application/json") + json_response = json.loads(resp.content.decode("utf-8")) self.validate_course_xblock_info(json_response, course_outline=True) @ddt.data( @@ -2631,31 +3111,42 @@ def test_json_responses(self): (ModuleStoreEnum.Type.mongo, 8, 12), ) @ddt.unpack - def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1): + def test_xblock_outline_handler_mongo_calls( + self, store_type, chapter_queries, chapter_queries_1 + ): with self.store.default_store(store_type): course = CourseFactory.create() chapter = BlockFactory.create( - parent_location=course.location, category='chapter', display_name='Week 1' + parent_location=course.location, + category="chapter", + display_name="Week 1", ) - outline_url = reverse_usage_url('xblock_outline_handler', chapter.location) + outline_url = reverse_usage_url("xblock_outline_handler", chapter.location) with check_mongo_calls(chapter_queries): - self.client.get(outline_url, HTTP_ACCEPT='application/json') + self.client.get(outline_url, HTTP_ACCEPT="application/json") sequential = BlockFactory.create( - parent_location=chapter.location, category='sequential', display_name='Sequential 1' + parent_location=chapter.location, + category="sequential", + display_name="Sequential 1", ) BlockFactory.create( - parent_location=sequential.location, category='vertical', display_name='Vertical 1' + parent_location=sequential.location, + category="vertical", + display_name="Vertical 1", ) # calls should be same after adding two new children for split only. with check_mongo_calls(chapter_queries_1): - self.client.get(outline_url, HTTP_ACCEPT='application/json') + self.client.get(outline_url, HTTP_ACCEPT="application/json") def test_entrance_exam_chapter_xblock_info(self): chapter = BlockFactory.create( - parent_location=self.course.location, category='chapter', display_name="Entrance Exam", - user_id=self.user.id, is_entrance_exam=True + parent_location=self.course.location, + category="chapter", + display_name="Entrance Exam", + user_id=self.user.id, + is_entrance_exam=True, ) chapter = modulestore().get_item(chapter.location) xblock_info = create_xblock_info( @@ -2664,17 +3155,19 @@ def test_entrance_exam_chapter_xblock_info(self): include_children_predicate=ALWAYS, ) # entrance exam chapter should not be deletable, draggable and childAddable. - actions = xblock_info['actions'] - self.assertEqual(actions['deletable'], False) - self.assertEqual(actions['draggable'], False) - self.assertEqual(actions['childAddable'], False) - self.assertEqual(xblock_info['display_name'], 'Entrance Exam') - self.assertIsNone(xblock_info.get('is_header_visible', None)) + actions = xblock_info["actions"] + self.assertEqual(actions["deletable"], False) + self.assertEqual(actions["draggable"], False) + self.assertEqual(actions["childAddable"], False) + self.assertEqual(xblock_info["display_name"], "Entrance Exam") + self.assertIsNone(xblock_info.get("is_header_visible", None)) def test_none_entrance_exam_chapter_xblock_info(self): chapter = BlockFactory.create( - parent_location=self.course.location, category='chapter', display_name="Test Chapter", - user_id=self.user.id + parent_location=self.course.location, + category="chapter", + display_name="Test Chapter", + user_id=self.user.id, ) chapter = modulestore().get_item(chapter.location) xblock_info = create_xblock_info( @@ -2684,47 +3177,54 @@ def test_none_entrance_exam_chapter_xblock_info(self): ) # chapter should be deletable, draggable and childAddable if not an entrance exam. - actions = xblock_info['actions'] - self.assertEqual(actions['deletable'], True) - self.assertEqual(actions['draggable'], True) - self.assertEqual(actions['childAddable'], True) + actions = xblock_info["actions"] + self.assertEqual(actions["deletable"], True) + self.assertEqual(actions["draggable"], True) + self.assertEqual(actions["childAddable"], True) # chapter xblock info should not contains the key of 'is_header_visible'. - self.assertIsNone(xblock_info.get('is_header_visible', None)) + self.assertIsNone(xblock_info.get("is_header_visible", None)) def test_entrance_exam_sequential_xblock_info(self): chapter = BlockFactory.create( - parent_location=self.course.location, category='chapter', display_name="Entrance Exam", - user_id=self.user.id, is_entrance_exam=True, in_entrance_exam=True + parent_location=self.course.location, + category="chapter", + display_name="Entrance Exam", + user_id=self.user.id, + is_entrance_exam=True, + in_entrance_exam=True, ) subsection = BlockFactory.create( - parent_location=chapter.location, category='sequential', display_name="Subsection - Entrance Exam", - user_id=self.user.id, in_entrance_exam=True + parent_location=chapter.location, + category="sequential", + display_name="Subsection - Entrance Exam", + user_id=self.user.id, + in_entrance_exam=True, ) subsection = modulestore().get_item(subsection.location) xblock_info = create_xblock_info( - subsection, - include_child_info=True, - include_children_predicate=ALWAYS + subsection, include_child_info=True, include_children_predicate=ALWAYS ) # in case of entrance exam subsection, header should be hidden. - self.assertEqual(xblock_info['is_header_visible'], False) - self.assertEqual(xblock_info['display_name'], 'Subsection - Entrance Exam') + self.assertEqual(xblock_info["is_header_visible"], False) + self.assertEqual(xblock_info["display_name"], "Subsection - Entrance Exam") def test_none_entrance_exam_sequential_xblock_info(self): subsection = BlockFactory.create( - parent_location=self.chapter.location, category='sequential', display_name="Subsection - Exam", - user_id=self.user.id + parent_location=self.chapter.location, + category="sequential", + display_name="Subsection - Exam", + user_id=self.user.id, ) subsection = modulestore().get_item(subsection.location) xblock_info = create_xblock_info( subsection, include_child_info=True, include_children_predicate=ALWAYS, - parent_xblock=self.chapter + parent_xblock=self.chapter, ) # sequential xblock info should not contains the key of 'is_header_visible'. - self.assertIsNone(xblock_info.get('is_header_visible', None)) + self.assertIsNone(xblock_info.get("is_header_visible", None)) def test_chapter_xblock_info(self): chapter = modulestore().get_item(self.chapter.location) @@ -2752,7 +3252,7 @@ def test_vertical_xblock_info(self): include_child_info=True, include_children_predicate=ALWAYS, include_ancestor_info=True, - user=self.user + user=self.user, ) add_container_page_publishing_info(vertical, xblock_info) self.validate_vertical_xblock_info(xblock_info) @@ -2760,9 +3260,7 @@ def test_vertical_xblock_info(self): def test_component_xblock_info(self): video = modulestore().get_item(self.video.location) xblock_info = create_xblock_info( - video, - include_child_info=True, - include_children_predicate=ALWAYS + video, include_child_info=True, include_children_predicate=ALWAYS ) self.validate_component_xblock_info(xblock_info) @@ -2774,7 +3272,9 @@ def test_validate_start_date(self, store_type): with self.store.default_store(store_type): course = CourseFactory.create() chapter = BlockFactory.create( - parent_location=course.location, category='chapter', display_name='Week 1' + parent_location=course.location, + category="chapter", + display_name="Week 1", ) chapter.start = datetime(year=1899, month=1, day=1, tzinfo=UTC) @@ -2784,178 +3284,210 @@ def test_validate_start_date(self, store_type): include_child_info=True, include_children_predicate=ALWAYS, include_ancestor_info=True, - user=self.user + user=self.user, ) - self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ')) + self.assertEqual( + xblock_info["start"], DEFAULT_START_DATE.strftime("%Y-%m-%dT%H:%M:%SZ") + ) def test_highlights_enabled(self): self.course.highlights_enabled_for_messaging = True self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertTrue(course_xblock_info['highlights_enabled_for_messaging']) + self.assertTrue(course_xblock_info["highlights_enabled_for_messaging"]) def test_xblock_public_video_sharing_enabled(self): """ Public video sharing is included in the xblock info when enable. """ - self.course.video_sharing_options = 'all-on' - with patch.object(PUBLIC_VIDEO_SHARE, 'is_enabled', return_value=True): + self.course.video_sharing_options = "all-on" + with patch.object(PUBLIC_VIDEO_SHARE, "is_enabled", return_value=True): self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertTrue(course_xblock_info['video_sharing_enabled']) - self.assertEqual(course_xblock_info['video_sharing_options'], 'all-on') + self.assertTrue(course_xblock_info["video_sharing_enabled"]) + self.assertEqual(course_xblock_info["video_sharing_options"], "all-on") def test_xblock_public_video_sharing_disabled(self): """ Public video sharing not is included in the xblock info when disabled. """ - self.course.video_sharing_options = 'arbitrary' - with patch.object(PUBLIC_VIDEO_SHARE, 'is_enabled', return_value=False): + self.course.video_sharing_options = "arbitrary" + with patch.object(PUBLIC_VIDEO_SHARE, "is_enabled", return_value=False): self.store.update_item(self.course, None) course_xblock_info = create_xblock_info(self.course) - self.assertNotIn('video_sharing_enabled', course_xblock_info) - self.assertNotIn('video_sharing_options', course_xblock_info) + self.assertNotIn("video_sharing_enabled", course_xblock_info) + self.assertNotIn("video_sharing_options", course_xblock_info) - def validate_course_xblock_info(self, xblock_info, has_child_info=True, course_outline=False): + def validate_course_xblock_info( + self, xblock_info, has_child_info=True, course_outline=False + ): """ Validate that the xblock info is correct for the test course. """ - self.assertEqual(xblock_info['category'], 'course') - self.assertEqual(xblock_info['id'], str(self.course.location)) - self.assertEqual(xblock_info['display_name'], self.course.display_name) - self.assertTrue(xblock_info['published']) - self.assertFalse(xblock_info['highlights_enabled_for_messaging']) + self.assertEqual(xblock_info["category"], "course") + self.assertEqual(xblock_info["id"], str(self.course.location)) + self.assertEqual(xblock_info["display_name"], self.course.display_name) + self.assertTrue(xblock_info["published"]) + self.assertFalse(xblock_info["highlights_enabled_for_messaging"]) # Finally, validate the entire response for consistency - self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info, course_outline=course_outline) + self.validate_xblock_info_consistency( + xblock_info, has_child_info=has_child_info, course_outline=course_outline + ) def validate_chapter_xblock_info(self, xblock_info, has_child_info=True): """ Validate that the xblock info is correct for the test chapter. """ - self.assertEqual(xblock_info['category'], 'chapter') - self.assertEqual(xblock_info['id'], str(self.chapter.location)) - self.assertEqual(xblock_info['display_name'], 'Week 1') - self.assertTrue(xblock_info['published']) - self.assertIsNone(xblock_info.get('edited_by', None)) - self.assertEqual(xblock_info['course_graders'], ['Homework', 'Lab', 'Midterm Exam', 'Final Exam']) - self.assertEqual(xblock_info['start'], '2030-01-01T00:00:00Z') - self.assertEqual(xblock_info['graded'], False) - self.assertEqual(xblock_info['due'], None) - self.assertEqual(xblock_info['format'], None) - self.assertEqual(xblock_info['highlights'], self.chapter.highlights) - self.assertTrue(xblock_info['highlights_enabled']) + self.assertEqual(xblock_info["category"], "chapter") + self.assertEqual(xblock_info["id"], str(self.chapter.location)) + self.assertEqual(xblock_info["display_name"], "Week 1") + self.assertTrue(xblock_info["published"]) + self.assertIsNone(xblock_info.get("edited_by", None)) + self.assertEqual( + xblock_info["course_graders"], + ["Homework", "Lab", "Midterm Exam", "Final Exam"], + ) + self.assertEqual(xblock_info["start"], "2030-01-01T00:00:00Z") + self.assertEqual(xblock_info["graded"], False) + self.assertEqual(xblock_info["due"], None) + self.assertEqual(xblock_info["format"], None) + self.assertEqual(xblock_info["highlights"], self.chapter.highlights) + self.assertTrue(xblock_info["highlights_enabled"]) # Finally, validate the entire response for consistency - self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) + self.validate_xblock_info_consistency( + xblock_info, has_child_info=has_child_info + ) def validate_sequential_xblock_info(self, xblock_info, has_child_info=True): """ Validate that the xblock info is correct for the test sequential. """ - self.assertEqual(xblock_info['category'], 'sequential') - self.assertEqual(xblock_info['id'], str(self.sequential.location)) - self.assertEqual(xblock_info['display_name'], 'Lesson 1') - self.assertTrue(xblock_info['published']) - self.assertIsNone(xblock_info.get('edited_by', None)) + self.assertEqual(xblock_info["category"], "sequential") + self.assertEqual(xblock_info["id"], str(self.sequential.location)) + self.assertEqual(xblock_info["display_name"], "Lesson 1") + self.assertTrue(xblock_info["published"]) + self.assertIsNone(xblock_info.get("edited_by", None)) # Finally, validate the entire response for consistency - self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) + self.validate_xblock_info_consistency( + xblock_info, has_child_info=has_child_info + ) def validate_vertical_xblock_info(self, xblock_info): """ Validate that the xblock info is correct for the test vertical. """ - self.assertEqual(xblock_info['category'], 'vertical') - self.assertEqual(xblock_info['id'], str(self.vertical.location)) - self.assertEqual(xblock_info['display_name'], 'Unit 1') - self.assertTrue(xblock_info['published']) - self.assertEqual(xblock_info['edited_by'], 'testuser') + self.assertEqual(xblock_info["category"], "vertical") + self.assertEqual(xblock_info["id"], str(self.vertical.location)) + self.assertEqual(xblock_info["display_name"], "Unit 1") + self.assertTrue(xblock_info["published"]) + self.assertEqual(xblock_info["edited_by"], "testuser") # Validate that the correct ancestor info has been included - ancestor_info = xblock_info.get('ancestor_info', None) + ancestor_info = xblock_info.get("ancestor_info", None) self.assertIsNotNone(ancestor_info) - ancestors = ancestor_info['ancestors'] + ancestors = ancestor_info["ancestors"] self.assertEqual(len(ancestors), 3) self.validate_sequential_xblock_info(ancestors[0], has_child_info=True) self.validate_chapter_xblock_info(ancestors[1], has_child_info=False) self.validate_course_xblock_info(ancestors[2], has_child_info=False) # Finally, validate the entire response for consistency - self.validate_xblock_info_consistency(xblock_info, has_child_info=True, has_ancestor_info=True) + self.validate_xblock_info_consistency( + xblock_info, has_child_info=True, has_ancestor_info=True + ) def validate_component_xblock_info(self, xblock_info): """ Validate that the xblock info is correct for the test component. """ - self.assertEqual(xblock_info['category'], 'video') - self.assertEqual(xblock_info['id'], str(self.video.location)) - self.assertEqual(xblock_info['display_name'], 'My Video') - self.assertTrue(xblock_info['published']) - self.assertIsNone(xblock_info.get('edited_by', None)) + self.assertEqual(xblock_info["category"], "video") + self.assertEqual(xblock_info["id"], str(self.video.location)) + self.assertEqual(xblock_info["display_name"], "My Video") + self.assertTrue(xblock_info["published"]) + self.assertIsNone(xblock_info.get("edited_by", None)) # Finally, validate the entire response for consistency self.validate_xblock_info_consistency(xblock_info) - def validate_xblock_info_consistency(self, xblock_info, has_ancestor_info=False, has_child_info=False, - course_outline=False): + def validate_xblock_info_consistency( + self, + xblock_info, + has_ancestor_info=False, + has_child_info=False, + course_outline=False, + ): """ Validate that the xblock info is internally consistent. """ - self.assertIsNotNone(xblock_info['display_name']) - self.assertIsNotNone(xblock_info['id']) - self.assertIsNotNone(xblock_info['category']) - self.assertTrue(xblock_info['published']) + self.assertIsNotNone(xblock_info["display_name"]) + self.assertIsNotNone(xblock_info["id"]) + self.assertIsNotNone(xblock_info["category"]) + self.assertTrue(xblock_info["published"]) if has_ancestor_info: - self.assertIsNotNone(xblock_info.get('ancestor_info', None)) - ancestors = xblock_info['ancestor_info']['ancestors'] - for ancestor in xblock_info['ancestor_info']['ancestors']: + self.assertIsNotNone(xblock_info.get("ancestor_info", None)) + ancestors = xblock_info["ancestor_info"]["ancestors"] + for ancestor in xblock_info["ancestor_info"]["ancestors"]: self.validate_xblock_info_consistency( ancestor, - has_child_info=(ancestor == ancestors[0]), # Only the direct ancestor includes children - course_outline=course_outline + has_child_info=( + ancestor == ancestors[0] + ), # Only the direct ancestor includes children + course_outline=course_outline, ) else: - self.assertIsNone(xblock_info.get('ancestor_info', None)) + self.assertIsNone(xblock_info.get("ancestor_info", None)) if has_child_info: - self.assertIsNotNone(xblock_info.get('child_info', None)) - if xblock_info['child_info'].get('children', None): - for child_response in xblock_info['child_info']['children']: + self.assertIsNotNone(xblock_info.get("child_info", None)) + if xblock_info["child_info"].get("children", None): + for child_response in xblock_info["child_info"]["children"]: self.validate_xblock_info_consistency( child_response, - has_child_info=(not child_response.get('child_info', None) is None), - course_outline=course_outline + has_child_info=( + not child_response.get("child_info", None) is None + ), + course_outline=course_outline, ) else: - self.assertIsNone(xblock_info.get('child_info', None)) + self.assertIsNone(xblock_info.get("child_info", None)) -@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) +@patch.dict("django.conf.settings.FEATURES", {"ENABLE_SPECIAL_EXAMS": True}) @ddt.ddt class TestSpecialExamXBlockInfo(ItemTest): """ Unit tests for XBlock outline handling, specific to special exam XBlocks. """ + patch_get_exam_configuration_dashboard_url = patch.object( - item_module, 'get_exam_configuration_dashboard_url', return_value='test_url' + item_module, "get_exam_configuration_dashboard_url", return_value="test_url" ) patch_does_backend_support_onboarding = patch.object( - item_module, 'does_backend_support_onboarding', return_value=True + item_module, "does_backend_support_onboarding", return_value=True ) patch_get_exam_by_content_id_success = patch.object( - item_module, 'get_exam_by_content_id', return_value={'external_id': 'test_external_id'} + item_module, + "get_exam_by_content_id", + return_value={"external_id": "test_external_id"}, ) patch_get_exam_by_content_id_not_found = patch.object( - item_module, 'get_exam_by_content_id', side_effect=ProctoredExamNotFoundException + item_module, + "get_exam_by_content_id", + side_effect=ProctoredExamNotFoundException, ) def setUp(self): super().setUp() user_id = self.user.id self.chapter = BlockFactory.create( - parent_location=self.course.location, category='chapter', display_name="Week 1", user_id=user_id, - highlights=['highlight'], + parent_location=self.course.location, + category="chapter", + display_name="Week 1", + user_id=user_id, + highlights=["highlight"], ) self.course.enable_proctored_exams = True self.course.save() @@ -2969,20 +3501,20 @@ def test_proctoring_is_enabled_for_course(self): include_children_predicate=ALWAYS, ) # exam proctoring should be enabled and time limited. - assert xblock_info['enable_proctored_exams'] + assert xblock_info["enable_proctored_exams"] @patch_get_exam_configuration_dashboard_url @patch_does_backend_support_onboarding @patch_get_exam_by_content_id_success def test_special_exam_xblock_info( - self, - mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding, - mock_get_exam_configuration_dashboard_url, + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding, + mock_get_exam_configuration_dashboard_url, ): sequential = BlockFactory.create( parent_location=self.chapter.location, - category='sequential', + category="sequential", display_name="Test Lesson 1", user_id=self.user.id, is_proctored_exam=True, @@ -2997,62 +3529,64 @@ def test_special_exam_xblock_info( include_children_predicate=ALWAYS, ) # exam proctoring should be enabled and time limited. - assert xblock_info['is_proctored_exam'] is True - assert xblock_info['was_exam_ever_linked_with_external'] is True - assert xblock_info['is_time_limited'] is True - assert xblock_info['default_time_limit_minutes'] == 100 - assert xblock_info['proctoring_exam_configuration_link'] == 'test_url' - assert xblock_info['supports_onboarding'] is True - assert xblock_info['is_onboarding_exam'] is False - mock_get_exam_configuration_dashboard_url.assert_called_with(self.course.id, xblock_info['id']) + assert xblock_info["is_proctored_exam"] is True + assert xblock_info["was_exam_ever_linked_with_external"] is True + assert xblock_info["is_time_limited"] is True + assert xblock_info["default_time_limit_minutes"] == 100 + assert xblock_info["proctoring_exam_configuration_link"] == "test_url" + assert xblock_info["supports_onboarding"] is True + assert xblock_info["is_onboarding_exam"] is False + mock_get_exam_configuration_dashboard_url.assert_called_with( + self.course.id, xblock_info["id"] + ) @patch_get_exam_configuration_dashboard_url @patch_does_backend_support_onboarding @patch_get_exam_by_content_id_success @ddt.data( - ('test_external_id', True), + ("test_external_id", True), (None, False), ) @ddt.unpack def test_xblock_was_ever_proctortrack_proctored_exam( - self, - external_id, - expected_value, - mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding_patch, - _mock_get_exam_configuration_dashboard_url, + self, + external_id, + expected_value, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding_patch, + _mock_get_exam_configuration_dashboard_url, ): sequential = BlockFactory.create( parent_location=self.chapter.location, - category='sequential', + category="sequential", display_name="Test Lesson 1", user_id=self.user.id, is_proctored_exam=False, is_time_limited=False, is_onboarding_exam=False, ) - mock_get_exam_by_content_id.return_value = {'external_id': external_id} + mock_get_exam_by_content_id.return_value = {"external_id": external_id} sequential = modulestore().get_item(sequential.location) xblock_info = create_xblock_info( sequential, include_child_info=True, include_children_predicate=ALWAYS, ) - assert xblock_info['was_exam_ever_linked_with_external'] is expected_value + assert xblock_info["was_exam_ever_linked_with_external"] is expected_value assert mock_get_exam_by_content_id.call_count == 1 @patch_get_exam_configuration_dashboard_url @patch_does_backend_support_onboarding @patch_get_exam_by_content_id_not_found def test_xblock_was_never_proctortrack_proctored_exam( - self, - mock_get_exam_by_content_id, - _mock_does_backend_support_onboarding_patch, - _mock_get_exam_configuration_dashboard_url, + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding_patch, + _mock_get_exam_configuration_dashboard_url, ): sequential = BlockFactory.create( parent_location=self.chapter.location, - category='sequential', + category="sequential", display_name="Test Lesson 1", user_id=self.user.id, is_proctored_exam=False, @@ -3065,7 +3599,7 @@ def test_xblock_was_never_proctortrack_proctored_exam( include_child_info=True, include_children_predicate=ALWAYS, ) - assert xblock_info['was_exam_ever_linked_with_external'] is False + assert xblock_info["was_exam_ever_linked_with_external"] is False assert mock_get_exam_by_content_id.call_count == 1 @@ -3079,44 +3613,55 @@ def setUp(self): user_id = self.user.id self.library = LibraryFactory.create() self.top_level_html = BlockFactory.create( - parent_location=self.library.location, category='html', user_id=user_id, publish_item=False + parent_location=self.library.location, + category="html", + user_id=user_id, + publish_item=False, ) self.vertical = BlockFactory.create( - parent_location=self.library.location, category='vertical', user_id=user_id, publish_item=False + parent_location=self.library.location, + category="vertical", + user_id=user_id, + publish_item=False, ) self.child_html = BlockFactory.create( - parent_location=self.vertical.location, category='html', display_name='Test HTML Child Block', - user_id=user_id, publish_item=False + parent_location=self.vertical.location, + category="html", + display_name="Test HTML Child Block", + user_id=user_id, + publish_item=False, ) def test_lib_xblock_info(self): html_block = modulestore().get_item(self.top_level_html.location) xblock_info = create_xblock_info(html_block) self.validate_component_xblock_info(xblock_info, html_block) - self.assertIsNone(xblock_info.get('child_info', None)) + self.assertIsNone(xblock_info.get("child_info", None)) def test_lib_child_xblock_info(self): html_block = modulestore().get_item(self.child_html.location) - xblock_info = create_xblock_info(html_block, include_ancestor_info=True, include_child_info=True) + xblock_info = create_xblock_info( + html_block, include_ancestor_info=True, include_child_info=True + ) self.validate_component_xblock_info(xblock_info, html_block) - self.assertIsNone(xblock_info.get('child_info', None)) - ancestors = xblock_info['ancestor_info']['ancestors'] + self.assertIsNone(xblock_info.get("child_info", None)) + ancestors = xblock_info["ancestor_info"]["ancestors"] self.assertEqual(len(ancestors), 2) - self.assertEqual(ancestors[0]['category'], 'vertical') - self.assertEqual(ancestors[0]['id'], str(self.vertical.location)) - self.assertEqual(ancestors[1]['category'], 'library') + self.assertEqual(ancestors[0]["category"], "vertical") + self.assertEqual(ancestors[0]["id"], str(self.vertical.location)) + self.assertEqual(ancestors[1]["category"], "library") def validate_component_xblock_info(self, xblock_info, original_block): """ Validate that the xblock info is correct for the test component. """ - self.assertEqual(xblock_info['category'], original_block.category) - self.assertEqual(xblock_info['id'], str(original_block.location)) - self.assertEqual(xblock_info['display_name'], original_block.display_name) - self.assertIsNone(xblock_info.get('has_changes', None)) - self.assertIsNone(xblock_info.get('published', None)) - self.assertIsNone(xblock_info.get('published_on', None)) - self.assertIsNone(xblock_info.get('graders', None)) + self.assertEqual(xblock_info["category"], original_block.category) + self.assertEqual(xblock_info["id"], str(original_block.location)) + self.assertEqual(xblock_info["display_name"], original_block.display_name) + self.assertIsNone(xblock_info.get("has_changes", None)) + self.assertIsNone(xblock_info.get("published", None)) + self.assertIsNone(xblock_info.get("published_on", None)) + self.assertIsNone(xblock_info.get("graders", None)) class TestLibraryXBlockCreation(ItemTest): @@ -3129,27 +3674,33 @@ def test_add_xblock(self): Verify we can add an XBlock to a Library. """ lib = LibraryFactory.create() - self.create_xblock(parent_usage_key=lib.location, display_name='Test', category="html") + self.create_xblock( + parent_usage_key=lib.location, display_name="Test", category="html" + ) lib = self.store.get_library(lib.location.library_key) self.assertTrue(lib.children) xblock_locator = lib.children[0] - self.assertEqual(self.store.get_item(xblock_locator).display_name, 'Test') + self.assertEqual(self.store.get_item(xblock_locator).display_name, "Test") def test_no_add_discussion(self): """ Verify we cannot add a discussion block to a Library. """ lib = LibraryFactory.create() - response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='discussion') + response = self.create_xblock( + parent_usage_key=lib.location, display_name="Test", category="discussion" + ) self.assertEqual(response.status_code, 400) lib = self.store.get_library(lib.location.library_key) self.assertFalse(lib.children) def test_no_add_advanced(self): lib = LibraryFactory.create() - lib.advanced_modules = ['lti'] + lib.advanced_modules = ["lti"] lib.save() - response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='lti') + response = self.create_xblock( + parent_usage_key=lib.location, display_name="Test", category="lti" + ) self.assertEqual(response.status_code, 400) lib = self.store.get_library(lib.location.library_key) self.assertFalse(lib.children) @@ -3160,17 +3711,23 @@ class TestXBlockPublishingInfo(ItemTest): """ Unit tests for XBlock's outline handling. """ + FIRST_SUBSECTION_PATH = [0] FIRST_UNIT_PATH = [0, 0] SECOND_UNIT_PATH = [0, 1] - def _create_child(self, parent, category, display_name, publish_item=False, staff_only=False): + def _create_child( + self, parent, category, display_name, publish_item=False, staff_only=False + ): """ Creates a child xblock for the given parent. """ child = BlockFactory.create( - parent_location=parent.location, category=category, display_name=display_name, - user_id=self.user.id, publish_item=publish_item + parent_location=parent.location, + category=category, + display_name=display_name, + user_id=self.user.id, + publish_item=publish_item, ) if staff_only: self._enable_staff_only(child.location) @@ -3181,7 +3738,7 @@ def _get_child_xblock_info(self, xblock_info, index): """ Returns the child xblock info at the specified index. """ - children = xblock_info['child_info']['children'] + children = xblock_info["child_info"]["children"] self.assertGreater(len(children), index) return children[index] @@ -3203,7 +3760,7 @@ def _get_xblock_outline_info(self, location): modulestore().get_item(location), include_child_info=True, include_children_predicate=ALWAYS, - course_outline=True + course_outline=True, ) def _set_release_date(self, location, start): @@ -3230,7 +3787,14 @@ def _set_display_name(self, location, display_name): xblock.display_name = display_name self.store.update_item(xblock, self.user.id) - def _verify_xblock_info_state(self, xblock_info, xblock_info_field, expected_state, path=None, should_equal=True): + def _verify_xblock_info_state( + self, + xblock_info, + xblock_info_field, + expected_state, + path=None, + should_equal=True, + ): """ Verify the state of an xblock_info field. If no path is provided then the root item will be verified. If should_equal is True, assert that the current state matches the expected state, otherwise assert that they @@ -3239,8 +3803,13 @@ def _verify_xblock_info_state(self, xblock_info, xblock_info_field, expected_sta if path: direct_child_xblock_info = self._get_child_xblock_info(xblock_info, path[0]) remaining_path = path[1:] if len(path) > 1 else None - self._verify_xblock_info_state(direct_child_xblock_info, xblock_info_field, - expected_state, remaining_path, should_equal) + self._verify_xblock_info_state( + direct_child_xblock_info, + xblock_info_field, + expected_state, + remaining_path, + should_equal, + ) else: if should_equal: self.assertEqual(xblock_info[xblock_info_field], expected_state) @@ -3251,22 +3820,32 @@ def _verify_has_staff_only_message(self, xblock_info, expected_state, path=None) """ Verify the staff_only_message field of xblock_info. """ - self._verify_xblock_info_state(xblock_info, 'staff_only_message', expected_state, path) + self._verify_xblock_info_state( + xblock_info, "staff_only_message", expected_state, path + ) - def _verify_visibility_state(self, xblock_info, expected_state, path=None, should_equal=True): + def _verify_visibility_state( + self, xblock_info, expected_state, path=None, should_equal=True + ): """ Verify the publish state of an item in the xblock_info. """ - self._verify_xblock_info_state(xblock_info, 'visibility_state', expected_state, path, should_equal) + self._verify_xblock_info_state( + xblock_info, "visibility_state", expected_state, path, should_equal + ) - def _verify_explicit_staff_lock_state(self, xblock_info, expected_state, path=None, should_equal=True): + def _verify_explicit_staff_lock_state( + self, xblock_info, expected_state, path=None, should_equal=True + ): """ Verify the explicit staff lock state of an item in the xblock_info. """ - self._verify_xblock_info_state(xblock_info, 'has_explicit_staff_lock', expected_state, path, should_equal) + self._verify_xblock_info_state( + xblock_info, "has_explicit_staff_lock", expected_state, path, should_equal + ) def test_empty_chapter(self): - empty_chapter = self._create_child(self.course, 'chapter', "Empty Chapter") + empty_chapter = self._create_child(self.course, "chapter", "Empty Chapter") xblock_info = self._get_xblock_info(empty_chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.unscheduled) @@ -3275,87 +3854,129 @@ def test_chapter_self_paced_default_start_date(self, store_type): course = CourseFactory.create(default_store=store_type) course.self_paced = True self.store.update_item(course, self.user.id) - chapter = self._create_child(course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) + chapter = self._create_child(course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Published Unit", publish_item=True) self._set_release_date(chapter.location, DEFAULT_START_DATE) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.live) def test_empty_sequential(self): - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - self._create_child(chapter, 'sequential', "Empty Sequential") + chapter = self._create_child(self.course, "chapter", "Test Chapter") + self._create_child(chapter, "sequential", "Empty Sequential") xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.unscheduled) - self._verify_visibility_state(xblock_info, VisibilityState.unscheduled, path=self.FIRST_SUBSECTION_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.unscheduled, path=self.FIRST_SUBSECTION_PATH + ) def test_published_unit(self): """ Tests the visibility state of a published unit with release date in the future. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Published Unit", publish_item=True) + self._create_child(sequential, "vertical", "Staff Only Unit", staff_only=True) self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1)) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.ready) - self._verify_visibility_state(xblock_info, VisibilityState.ready, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.ready, path=self.FIRST_UNIT_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.ready, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.ready, path=self.FIRST_UNIT_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH + ) def test_released_unit(self): """ Tests the visibility state of a published unit with release date in the past. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Published Unit", publish_item=True) + self._create_child(sequential, "vertical", "Staff Only Unit", staff_only=True) self._set_release_date(chapter.location, datetime.now(UTC) - timedelta(days=1)) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.live) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH + ) def test_unpublished_changes(self): """ Tests the visibility state of a published unit with draft (unpublished) changes. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - unit = self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + unit = self._create_child( + sequential, "vertical", "Published Unit", publish_item=True + ) + self._create_child(sequential, "vertical", "Staff Only Unit", staff_only=True) # Setting the display name creates a draft version of unit. - self._set_display_name(unit.location, 'Updated Unit') + self._set_display_name(unit.location, "Updated Unit") xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.needs_attention) - self._verify_visibility_state(xblock_info, VisibilityState.needs_attention, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.needs_attention, path=self.FIRST_UNIT_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, + VisibilityState.needs_attention, + path=self.FIRST_SUBSECTION_PATH, + ) + self._verify_visibility_state( + xblock_info, VisibilityState.needs_attention, path=self.FIRST_UNIT_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH + ) def test_partially_released_section(self): - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - released_sequential = self._create_child(chapter, 'sequential', "Released Sequential") - self._create_child(released_sequential, 'vertical', "Released Unit", publish_item=True) - self._create_child(released_sequential, 'vertical', "Staff Only Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + released_sequential = self._create_child( + chapter, "sequential", "Released Sequential" + ) + self._create_child( + released_sequential, "vertical", "Released Unit", publish_item=True + ) + self._create_child( + released_sequential, "vertical", "Staff Only Unit", staff_only=True + ) self._set_release_date(chapter.location, datetime.now(UTC) - timedelta(days=1)) - published_sequential = self._create_child(chapter, 'sequential', "Published Sequential") - self._create_child(published_sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(published_sequential, 'vertical', "Staff Only Unit", staff_only=True) - self._set_release_date(published_sequential.location, datetime.now(UTC) + timedelta(days=1)) + published_sequential = self._create_child( + chapter, "sequential", "Published Sequential" + ) + self._create_child( + published_sequential, "vertical", "Published Unit", publish_item=True + ) + self._create_child( + published_sequential, "vertical", "Staff Only Unit", staff_only=True + ) + self._set_release_date( + published_sequential.location, datetime.now(UTC) + timedelta(days=1) + ) xblock_info = self._get_xblock_info(chapter.location) # Verify the state of the released sequential self._verify_visibility_state(xblock_info, VisibilityState.live, path=[0]) self._verify_visibility_state(xblock_info, VisibilityState.live, path=[0, 0]) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[0, 1]) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=[0, 1] + ) # Verify the state of the published sequential self._verify_visibility_state(xblock_info, VisibilityState.ready, path=[1]) self._verify_visibility_state(xblock_info, VisibilityState.ready, path=[1, 0]) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[1, 1]) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=[1, 1] + ) # Finally verify the state of the chapter self._verify_visibility_state(xblock_info, VisibilityState.ready) @@ -3364,32 +3985,50 @@ def test_staff_only_section(self): """ Tests that an explicitly staff-locked section and all of its children are visible to staff only. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True) - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - vertical = self._create_child(sequential, 'vertical', "Unit") + chapter = self._create_child( + self.course, "chapter", "Test Chapter", staff_only=True + ) + sequential = self._create_child(chapter, "sequential", "Test Sequential") + vertical = self._create_child(sequential, "vertical", "Unit") xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.staff_only) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH + ) self._verify_explicit_staff_lock_state(xblock_info, True) - self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) - self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH) + self._verify_explicit_staff_lock_state( + xblock_info, False, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_explicit_staff_lock_state( + xblock_info, False, path=self.FIRST_UNIT_PATH + ) vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual(_xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"]) + self.assertEqual( + _xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"] + ) def test_no_staff_only_section(self): """ Tests that a section with a staff-locked subsection and a visible subsection is not staff locked itself. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - self._create_child(chapter, 'sequential', "Test Visible Sequential") - self._create_child(chapter, 'sequential', "Test Staff Locked Sequential", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + self._create_child(chapter, "sequential", "Test Visible Sequential") + self._create_child( + chapter, "sequential", "Test Staff Locked Sequential", staff_only=True + ) xblock_info = self._get_xblock_info(chapter.location) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, should_equal=False) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[0], should_equal=False) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, should_equal=False + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=[0], should_equal=False + ) self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[1]) def test_staff_only_subsection(self): @@ -3397,101 +4036,160 @@ def test_staff_only_subsection(self): Tests that an explicitly staff-locked subsection and all of its children are visible to staff only. In this case the parent section is also visible to staff only because all of its children are staff only. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential", staff_only=True) - vertical = self._create_child(sequential, 'vertical', "Unit") + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child( + chapter, "sequential", "Test Sequential", staff_only=True + ) + vertical = self._create_child(sequential, "vertical", "Unit") xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.staff_only) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH + ) self._verify_explicit_staff_lock_state(xblock_info, False) - self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_SUBSECTION_PATH) - self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH) + self._verify_explicit_staff_lock_state( + xblock_info, True, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_explicit_staff_lock_state( + xblock_info, False, path=self.FIRST_UNIT_PATH + ) vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual(_xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"]) + self.assertEqual( + _xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"] + ) def test_no_staff_only_subsection(self): """ Tests that a subsection with a staff-locked unit and a visible unit is not staff locked itself. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Unit") - self._create_child(sequential, 'vertical', "Locked Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Unit") + self._create_child(sequential, "vertical", "Locked Unit", staff_only=True) xblock_info = self._get_xblock_info(chapter.location) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.FIRST_SUBSECTION_PATH, - should_equal=False) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.FIRST_UNIT_PATH, should_equal=False) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, + VisibilityState.staff_only, + self.FIRST_SUBSECTION_PATH, + should_equal=False, + ) + self._verify_visibility_state( + xblock_info, + VisibilityState.staff_only, + self.FIRST_UNIT_PATH, + should_equal=False, + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, self.SECOND_UNIT_PATH + ) def test_staff_only_unit(self): - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - vertical = self._create_child(sequential, 'vertical', "Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + vertical = self._create_child(sequential, "vertical", "Unit", staff_only=True) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.staff_only) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH + ) self._verify_explicit_staff_lock_state(xblock_info, False) - self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) - self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_UNIT_PATH) + self._verify_explicit_staff_lock_state( + xblock_info, False, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_explicit_staff_lock_state( + xblock_info, True, path=self.FIRST_UNIT_PATH + ) vertical_info = self._get_xblock_info(vertical.location) add_container_page_publishing_info(vertical, vertical_info) - self.assertEqual(_xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"]) + self.assertEqual( + _xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"] + ) def test_unscheduled_section_with_live_subsection(self): - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True) - self._set_release_date(sequential.location, datetime.now(UTC) - timedelta(days=1)) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Published Unit", publish_item=True) + self._create_child(sequential, "vertical", "Staff Only Unit", staff_only=True) + self._set_release_date( + sequential.location, datetime.now(UTC) - timedelta(days=1) + ) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.needs_attention) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH + ) def test_unreleased_section_with_live_subsection(self): - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Published Unit", publish_item=True) - self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Published Unit", publish_item=True) + self._create_child(sequential, "vertical", "Staff Only Unit", staff_only=True) self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1)) - self._set_release_date(sequential.location, datetime.now(UTC) - timedelta(days=1)) + self._set_release_date( + sequential.location, datetime.now(UTC) - timedelta(days=1) + ) xblock_info = self._get_xblock_info(chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.needs_attention) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH) - self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH + ) + self._verify_visibility_state( + xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH + ) def test_locked_section_staff_only_message(self): """ Tests that a locked section has a staff only message and its descendants do not. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True) - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Unit") + chapter = self._create_child( + self.course, "chapter", "Test Chapter", staff_only=True + ) + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Unit") xblock_info = self._get_xblock_outline_info(chapter.location) self._verify_has_staff_only_message(xblock_info, True) - self._verify_has_staff_only_message(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) - self._verify_has_staff_only_message(xblock_info, False, path=self.FIRST_UNIT_PATH) + self._verify_has_staff_only_message( + xblock_info, False, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_has_staff_only_message( + xblock_info, False, path=self.FIRST_UNIT_PATH + ) def test_locked_unit_staff_only_message(self): """ Tests that a lone locked unit has a staff only message along with its ancestors. """ - chapter = self._create_child(self.course, 'chapter', "Test Chapter") - sequential = self._create_child(chapter, 'sequential', "Test Sequential") - self._create_child(sequential, 'vertical', "Unit", staff_only=True) + chapter = self._create_child(self.course, "chapter", "Test Chapter") + sequential = self._create_child(chapter, "sequential", "Test Sequential") + self._create_child(sequential, "vertical", "Unit", staff_only=True) xblock_info = self._get_xblock_outline_info(chapter.location) self._verify_has_staff_only_message(xblock_info, True) - self._verify_has_staff_only_message(xblock_info, True, path=self.FIRST_SUBSECTION_PATH) - self._verify_has_staff_only_message(xblock_info, True, path=self.FIRST_UNIT_PATH) + self._verify_has_staff_only_message( + xblock_info, True, path=self.FIRST_SUBSECTION_PATH + ) + self._verify_has_staff_only_message( + xblock_info, True, path=self.FIRST_UNIT_PATH + ) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_self_paced_item_visibility_state(self, store_type): @@ -3503,7 +4201,7 @@ def test_self_paced_item_visibility_state(self, store_type): # Create course, chapter and setup future release date to make chapter in scheduled state course = CourseFactory.create(default_store=store_type) - chapter = self._create_child(course, 'chapter', "Test Chapter") + chapter = self._create_child(course, "chapter", "Test Chapter") self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1)) # Check that chapter has scheduled state @@ -3521,8 +4219,10 @@ def test_self_paced_item_visibility_state(self, store_type): self._verify_visibility_state(xblock_info, VisibilityState.live) -@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types', - lambda self, block: ['test_aside']) +@patch( + "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types", + lambda self, block: ["test_aside"], +) class TestUpdateFromSource(ModuleStoreTestCase): """ Test update_from_source. @@ -3535,7 +4235,7 @@ def setUp(self): super().setUp() key_store = DictKeyValueStore() field_data = KvsFieldData(key_store) - self.runtime = TestRuntime(services={'field-data': field_data}) + self.runtime = TestRuntime(services={"field-data": field_data}) def create_source_block(self, course): """ @@ -3543,31 +4243,35 @@ def create_source_block(self, course): """ source_block = BlockFactory( parent=course, - category='course_info', - display_name='Source Block', - metadata={'due': datetime(2010, 11, 22, 4, 0, tzinfo=UTC)}, + category="course_info", + display_name="Source Block", + metadata={"due": datetime(2010, 11, 22, 4, 0, tzinfo=UTC)}, ) - def_id = self.runtime.id_generator.create_definition('html') + def_id = self.runtime.id_generator.create_definition("html") usage_id = self.runtime.id_generator.create_usage(def_id) - aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime) - aside.field11 = 'html_new_value1' + aside = AsideTest( + scope_ids=ScopeIds("user", "html", def_id, usage_id), runtime=self.runtime + ) + aside.field11 = "html_new_value1" # The data attribute is handled in a special manner and should be updated. - source_block.data = '
test
' + source_block.data = "
test
" # This field is set on the content scope (definition_data), which should be updated. - source_block.items = ['test', 'beep'] + source_block.items = ["test", "beep"] self.store.update_item(source_block, self.user.id, asides=[aside]) # quick sanity checks source_block = self.store.get_item(source_block.location) self.assertEqual(source_block.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) - self.assertEqual(source_block.display_name, 'Source Block') - self.assertEqual(source_block.runtime.get_asides(source_block)[0].field11, 'html_new_value1') - self.assertEqual(source_block.data, '
test
') - self.assertEqual(source_block.items, ['test', 'beep']) + self.assertEqual(source_block.display_name, "Source Block") + self.assertEqual( + source_block.runtime.get_asides(source_block)[0].field11, "html_new_value1" + ) + self.assertEqual(source_block.data, "
test
") + self.assertEqual(source_block.items, ["test", "beep"]) return source_block @@ -3586,7 +4290,7 @@ def check_updated(self, source_block, destination_key): source_block.runtime.get_asides(source_block)[0].field11, ) - @XBlockAside.register_temp_plugin(AsideTest, 'test_aside') + @XBlockAside.register_temp_plugin(AsideTest, "test_aside") def test_update_from_source(self): """ Test that update_from_source updates the destination block. @@ -3596,11 +4300,17 @@ def test_update_from_source(self): source_block = self.create_source_block(course) - destination_block = BlockFactory(parent=course, category='course_info', display_name='Destination Problem') - update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id) + destination_block = BlockFactory( + parent=course, category="course_info", display_name="Destination Problem" + ) + update_from_source( + source_block=source_block, + destination_block=destination_block, + user_id=user.id, + ) self.check_updated(source_block, destination_block.location) - @XBlockAside.register_temp_plugin(AsideTest, 'test_aside') + @XBlockAside.register_temp_plugin(AsideTest, "test_aside") def test_update_clobbers(self): """ Verify that our update replaces all settings on the block. @@ -3612,18 +4322,24 @@ def test_update_clobbers(self): destination_block = BlockFactory( parent=course, - category='course_info', - display_name='Destination Chapter', - metadata={'due': datetime(2025, 10, 21, 6, 5, tzinfo=UTC)}, + category="course_info", + display_name="Destination Chapter", + metadata={"due": datetime(2025, 10, 21, 6, 5, tzinfo=UTC)}, ) - def_id = self.runtime.id_generator.create_definition('html') + def_id = self.runtime.id_generator.create_definition("html") usage_id = self.runtime.id_generator.create_usage(def_id) - aside = AsideTest(scope_ids=ScopeIds('user', 'html', def_id, usage_id), runtime=self.runtime) - aside.field11 = 'Other stuff' - destination_block.data = '
other stuff
' - destination_block.items = ['other stuff', 'boop'] + aside = AsideTest( + scope_ids=ScopeIds("user", "html", def_id, usage_id), runtime=self.runtime + ) + aside.field11 = "Other stuff" + destination_block.data = "
other stuff
" + destination_block.items = ["other stuff", "boop"] self.store.update_item(destination_block, user.id, asides=[aside]) - update_from_source(source_block=source_block, destination_block=destination_block, user_id=user.id) + update_from_source( + source_block=source_block, + destination_block=destination_block, + user_id=user.id, + ) self.check_updated(source_block, destination_block.location) diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index cf9f2a4197ae..3fb71305925c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -39,7 +39,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order from ..course import _deprecated_blocks_info, course_outline_initial_state, reindex_course_and_check_access -from ..block import VisibilityState, create_xblock_info +from cms.djangoapps.contentstore.xblock_services.xblock_service import VisibilityState, create_xblock_info class TestCourseIndex(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py index 2ac8d56c9579..8e9d52d49448 100644 --- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py +++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py @@ -26,7 +26,8 @@ remove_entrance_exam_milestone_reference, update_entrance_exam ) -from ..helpers import GRADER_TYPES, create_xblock +from cms.djangoapps.contentstore.helpers import GRADER_TYPES +from cms.djangoapps.contentstore.xblock_services.create_xblock import create_xblock @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) @@ -34,6 +35,7 @@ class EntranceExamHandlerTests(CourseTestCase, MilestonesTestCaseMixin): """ Base test class for create, save, and delete """ + def setUp(self): """ Shared scaffolding for individual test runs diff --git a/cms/djangoapps/contentstore/views/tests/test_gating.py b/cms/djangoapps/contentstore/views/tests/test_gating.py index 9f96e57f9356..94a073c2d277 100644 --- a/cms/djangoapps/contentstore/views/tests/test_gating.py +++ b/cms/djangoapps/contentstore/views/tests/test_gating.py @@ -14,7 +14,7 @@ from cms.djangoapps.contentstore.utils import reverse_usage_url from openedx.core.lib.gating.api import GATING_NAMESPACE_QUALIFIER -from ..block import VisibilityState +from cms.djangoapps.contentstore.xblock_services.xblock_service import VisibilityState @ddt.ddt @@ -57,7 +57,7 @@ def setUp(self): ) self.seq2_url = reverse_usage_url('xblock_handler', self.seq2.location) - @patch('cms.djangoapps.contentstore.views.block.gating_api.add_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.add_prerequisite') def test_add_prerequisite(self, mock_add_prereq): """ Test adding a subsection as a prerequisite @@ -69,7 +69,7 @@ def test_add_prerequisite(self, mock_add_prereq): ) mock_add_prereq.assert_called_with(self.course.id, self.seq1.location) - @patch('cms.djangoapps.contentstore.views.block.gating_api.remove_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.remove_prerequisite') def test_remove_prerequisite(self, mock_remove_prereq): """ Test removing a subsection as a prerequisite @@ -81,7 +81,7 @@ def test_remove_prerequisite(self, mock_remove_prereq): ) mock_remove_prereq.assert_called_with(self.seq1.location) - @patch('cms.djangoapps.contentstore.views.block.gating_api.set_required_content') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.set_required_content') def test_add_gate(self, mock_set_required_content): """ Test adding a gated subsection @@ -100,7 +100,7 @@ def test_add_gate(self, mock_set_required_content): '100' ) - @patch('cms.djangoapps.contentstore.views.block.gating_api.set_required_content') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.set_required_content') def test_remove_gate(self, mock_set_required_content): """ Test removing a gated subsection @@ -118,9 +118,9 @@ def test_remove_gate(self, mock_set_required_content): '' ) - @patch('cms.djangoapps.contentstore.views.block.gating_api.get_prerequisites') - @patch('cms.djangoapps.contentstore.views.block.gating_api.get_required_content') - @patch('cms.djangoapps.contentstore.views.block.gating_api.is_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.get_prerequisites') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.get_required_content') + @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.is_prerequisite') @ddt.data( (90, None), (None, 90), diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py index d83c09e58358..10e35710da7c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_helpers.py +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -8,7 +8,7 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from xmodule.modulestore.tests.factories import BlockFactory, LibraryFactory # lint-amnesty, pylint: disable=wrong-import-order -from ..helpers import xblock_studio_url, xblock_type_display_name +from ...helpers import xblock_studio_url, xblock_type_display_name class HelpersTestCase(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/tests/utils.py b/cms/djangoapps/contentstore/views/tests/utils.py index b609b470533e..3f1e292ee54a 100644 --- a/cms/djangoapps/contentstore/views/tests/utils.py +++ b/cms/djangoapps/contentstore/views/tests/utils.py @@ -8,7 +8,7 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from xmodule.modulestore.tests.factories import BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -from ..helpers import xblock_studio_url +from ...helpers import xblock_studio_url class StudioPageTestCase(CourseTestCase): diff --git a/cms/djangoapps/contentstore/xblock_services/__init__.py b/cms/djangoapps/contentstore/xblock_services/__init__.py new file mode 100644 index 000000000000..70ff825691d7 --- /dev/null +++ b/cms/djangoapps/contentstore/xblock_services/__init__.py @@ -0,0 +1,6 @@ +""" +Xblock services that contain the business logic for xblock views. +""" +from .create_xblock import * +from .xblock_helpers import * +from .xblock_service import * diff --git a/cms/djangoapps/contentstore/xblock_services/create_xblock.py b/cms/djangoapps/contentstore/xblock_services/create_xblock.py new file mode 100644 index 000000000000..235510f9ede7 --- /dev/null +++ b/cms/djangoapps/contentstore/xblock_services/create_xblock.py @@ -0,0 +1,111 @@ +""" +Xblock services for creating xblocks. +""" + +from uuid import uuid4 + +from django.utils.translation import gettext as _ +from xmodule.modulestore.django import modulestore +from xmodule.tabs import StaticTab + +from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from openedx.core.toggles import ENTRANCE_EXAMS + +from .xblock_helpers import usage_key_with_run +from ..helpers import GRADER_TYPES, remove_entrance_exam_graders + + +def create_xblock(parent_locator, user, category, display_name, boilerplate=None, is_entrance_exam=False): + """ + Performs the actual grunt work of creating items/xblocks -- knows nothing about requests, views, etc. + """ + store = modulestore() + usage_key = usage_key_with_run(parent_locator) + with store.bulk_operations(usage_key.course_key): + parent = store.get_item(usage_key) + dest_usage_key = usage_key.replace(category=category, name=uuid4().hex) + + # get the metadata, display_name, and definition from the caller + metadata = {} + data = None + template_id = boilerplate + if template_id: + clz = parent.runtime.load_block_type(category) + if clz is not None: + template = clz.get_template(template_id) + if template is not None: + metadata = template.get('metadata', {}) + data = template.get('data') + + if display_name is not None: + metadata['display_name'] = display_name + + # We should use the 'fields' kwarg for newer block settings/values (vs. metadata or data) + fields = {} + + # Entrance Exams: Chapter module positioning + child_position = None + if ENTRANCE_EXAMS.is_enabled(): + if category == 'chapter' and is_entrance_exam: + fields['is_entrance_exam'] = is_entrance_exam + fields['in_entrance_exam'] = True # Inherited metadata, all children will have it + child_position = 0 + + # TODO need to fix components that are sending definition_data as strings, instead of as dicts + # For now, migrate them into dicts here. + if isinstance(data, str): + data = {'data': data} + + created_block = store.create_child( + user.id, + usage_key, + dest_usage_key.block_type, + block_id=dest_usage_key.block_id, + fields=fields, + definition_data=data, + metadata=metadata, + runtime=parent.runtime, + position=child_position, + ) + + # Entrance Exams: Grader assignment + if ENTRANCE_EXAMS.is_enabled(): + course_key = usage_key.course_key + course = store.get_course(course_key) + if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled: + if category == 'sequential' and parent_locator == course.entrance_exam_id: + # Clean up any pre-existing entrance exam graders + remove_entrance_exam_graders(course_key, user) + grader = { + "type": GRADER_TYPES['ENTRANCE_EXAM'], + "min_count": 0, + "drop_count": 0, + "short_label": "Entrance", + "weight": 0 + } + grading_model = CourseGradingModel.update_grader_from_json( + course.id, + grader, + user + ) + CourseGradingModel.update_section_grader_type( + created_block, + grading_model['type'], + user + ) + + # VS[compat] cdodge: This is a hack because static_tabs also have references from the course block, so + # if we add one then we need to also add it to the policy information (i.e. metadata) + # we should remove this once we can break this reference from the course to static tabs + if category == 'static_tab': + display_name = display_name or _("Empty") # Prevent name being None + course = store.get_course(dest_usage_key.course_key) + course.tabs.append( + StaticTab( + name=display_name, + url_slug=dest_usage_key.block_id, + ) + ) + store.update_item(course, user.id) + + return created_block diff --git a/cms/djangoapps/contentstore/xblock_services/xblock_helpers.py b/cms/djangoapps/contentstore/xblock_services/xblock_helpers.py new file mode 100644 index 000000000000..3205b79a8887 --- /dev/null +++ b/cms/djangoapps/contentstore/xblock_services/xblock_helpers.py @@ -0,0 +1,15 @@ +""" +general helper functions for xblocks +""" + +from opaque_keys.edx.keys import UsageKey +from xmodule.modulestore.django import modulestore + + +def usage_key_with_run(usage_key_string): + """ + Converts usage_key_string to a UsageKey, adding a course run if necessary + """ + usage_key = UsageKey.from_string(usage_key_string) + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + return usage_key diff --git a/cms/djangoapps/contentstore/xblock_services/xblock_service.py b/cms/djangoapps/contentstore/xblock_services/xblock_service.py new file mode 100644 index 000000000000..696760a2db9d --- /dev/null +++ b/cms/djangoapps/contentstore/xblock_services/xblock_service.py @@ -0,0 +1,1646 @@ +""" +Service functions for xblock views, as found in: + +- contentstore/views/block.py +- rest_api/v1/viewx/xblock.py + +We extracted all the logic from the `xblock_handler` endpoint that lives in +contentstore/views/block.py to this file, because the logic is reused in another view now. +Along with it, we moved the business logic of the other views in that file, since that is related. +""" + +import logging +from datetime import datetime +from uuid import uuid4 + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import (User) # lint-amnesty, pylint: disable=imported-auth-user +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse, HttpResponseBadRequest +from django.utils.timezone import timezone +from django.utils.translation import gettext as _ +from edx_django_utils.plugins import pluggable_override +from openedx_events.content_authoring.data import DuplicatedXBlockData +from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED +from edx_proctoring.api import ( + does_backend_support_onboarding, + get_exam_by_content_id, + get_exam_configuration_dashboard_url, +) +from edx_proctoring.exceptions import ProctoredExamNotFoundException +from help_tokens.core import HelpUrlExpert +from lti_consumer.models import CourseAllowPIISharingInLTIFlag +from opaque_keys.edx.locator import LibraryUsageLocator +from pytz import UTC +from xblock.core import XBlock +from xblock.fields import Scope + +from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG +from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from common.djangoapps.edxmako.services import MakoService +from common.djangoapps.static_replace import replace_static_urls +from common.djangoapps.student.auth import ( + has_studio_read_access, + has_studio_write_access, +) +from common.djangoapps.util.date_utils import get_default_time_display +from common.djangoapps.util.json_request import JsonResponse, expect_json +from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService +from openedx.core.djangoapps.bookmarks import api as bookmarks_api +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration +from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE +from openedx.core.lib.gating import api as gating_api +from openedx.core.toggles import ENTRANCE_EXAMS +from xmodule.course_block import ( + DEFAULT_START_DATE, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.library_tools import ( + LibraryToolsService, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore import ( + EdxJSONEncoder, + ModuleStoreEnum, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.django import ( + modulestore, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.draft_and_published import ( + DIRECT_ONLY_CATEGORIES, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ( + InvalidLocationError, + ItemNotFoundError, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.inheritance import ( + own_metadata, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.services import ( + ConfigurationService, + SettingsService, + TeamsConfigurationService, +) # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.tabs import ( + CourseTabList, +) # lint-amnesty, pylint: disable=wrong-import-order + +from ..utils import ( + ancestor_has_staff_lock, + find_release_date_source, + find_staff_lock_source, + get_split_group_display_name, + get_user_partition_info, + get_visibility_partition_info, + has_children_visible_to_specific_partition_groups, + is_currently_visible_to_students, + is_self_paced, +) + +from .create_xblock import create_xblock +from .xblock_helpers import usage_key_with_run +from ..helpers import ( + get_parent_xblock, + import_staged_content_from_user_clipboard, + is_unit, + xblock_primary_child_category, + xblock_studio_url, + xblock_type_display_name, +) + +log = logging.getLogger(__name__) + +CREATE_IF_NOT_FOUND = ["course_info"] + +# Useful constants for defining predicates +NEVER = lambda x: False +ALWAYS = lambda x: True + +__all__ = [ + "handle_xblock", + "create_xblock_info", + "load_services_for_studio", + "get_block_info", + "get_xblock", + "delete_orphans", +] + + +def _filter_entrance_exam_grader(graders): + """ + If the entrance exams feature is enabled we need to hide away the grader from + views/controls like the 'Grade as' dropdown that allows a course author to select + the grader type for a given section of a course + """ + if ENTRANCE_EXAMS.is_enabled(): + graders = [ + grader for grader in graders if grader.get("type") != "Entrance Exam" + ] + return graders + + +def _is_library_component_limit_reached(usage_key): + """ + Verify if the library has reached the maximum number of components allowed in it + """ + store = modulestore() + parent = store.get_item(usage_key) + if not parent.has_children: + # Limit cannot be applied on such items + return False + total_children = len(parent.children) + return total_children + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY + + +def handle_xblock(request, usage_key_string=None): + """ + Service method with all business logic for handling xblock requests. + This method is used both by the internal xblock_handler API and by + the public studio content API. + """ + if usage_key_string: + usage_key = usage_key_with_run(usage_key_string) + + access_check = ( + has_studio_read_access + if request.method == "GET" + else has_studio_write_access + ) + if not access_check(request.user, usage_key.course_key): + raise PermissionDenied() + + if request.method == "GET": + accept_header = request.META.get("HTTP_ACCEPT", "application/json") + + if "application/json" in accept_header: + fields = request.GET.get("fields", "").split(",") + if "graderType" in fields: + # right now can't combine output of this w/ output of get_block_info, but worthy goal + return JsonResponse( + CourseGradingModel.get_section_grader_type(usage_key) + ) + elif "ancestorInfo" in fields: + xblock = get_xblock(usage_key, request.user) + ancestor_info = _create_xblock_ancestor_info( + xblock, is_concise=True + ) + return JsonResponse(ancestor_info) + # TODO: pass fields to get_block_info and only return those + with modulestore().bulk_operations(usage_key.course_key): + response = get_block_info(get_xblock(usage_key, request.user)) + return JsonResponse(response) + else: + return HttpResponse(status=406) + + elif request.method == "DELETE": + _delete_item(usage_key, request.user) + return JsonResponse() + else: # Since we have a usage_key, we are updating an existing xblock. + return modify_xblock(usage_key, request) + + elif request.method in ("PUT", "POST"): + if "duplicate_source_locator" in request.json: + parent_usage_key = usage_key_with_run(request.json["parent_locator"]) + duplicate_source_usage_key = usage_key_with_run( + request.json["duplicate_source_locator"] + ) + + source_course = duplicate_source_usage_key.course_key + dest_course = parent_usage_key.course_key + if not has_studio_write_access( + request.user, dest_course + ) or not has_studio_read_access(request.user, source_course): + raise PermissionDenied() + + # Libraries have a maximum component limit enforced on them + if isinstance( + parent_usage_key, LibraryUsageLocator + ) and _is_library_component_limit_reached(parent_usage_key): + return JsonResponse( + { + "error": _( + "Libraries cannot have more than {limit} components" + ).format(limit=settings.MAX_BLOCKS_PER_CONTENT_LIBRARY) + }, + status=400, + ) + + dest_usage_key = _duplicate_block( + parent_usage_key, + duplicate_source_usage_key, + request.user, + request.json.get("display_name"), + ) + return JsonResponse( + { + "locator": str(dest_usage_key), + "courseKey": str(dest_usage_key.course_key), + } + ) + else: + return _create_block(request) + elif request.method == "PATCH": + if "move_source_locator" in request.json: + move_source_usage_key = usage_key_with_run( + request.json.get("move_source_locator") + ) + target_parent_usage_key = usage_key_with_run( + request.json.get("parent_locator") + ) + target_index = request.json.get("target_index") + if not has_studio_write_access( + request.user, target_parent_usage_key.course_key + ) or not has_studio_read_access( + request.user, target_parent_usage_key.course_key + ): + raise PermissionDenied() + return _move_item( + move_source_usage_key, + target_parent_usage_key, + request.user, + target_index, + ) + + return JsonResponse( + {"error": "Patch request did not recognise any parameters to handle."}, + status=400, + ) + else: + return HttpResponseBadRequest( + "Only instance creation is supported without a usage key.", + content_type="text/plain", + ) + + +def modify_xblock(usage_key, request): + request_data = request.json + return _save_xblock( + request.user, + get_xblock(usage_key, request.user), + data=request_data.get("data"), + children_strings=request_data.get("children"), + metadata=request_data.get("metadata"), + nullout=request_data.get("nullout"), + grader_type=request_data.get("graderType"), + is_prereq=request_data.get("isPrereq"), + prereq_usage_key=request_data.get("prereqUsageKey"), + prereq_min_score=request_data.get("prereqMinScore"), + prereq_min_completion=request_data.get("prereqMinCompletion"), + publish=request_data.get("publish"), + fields=request_data.get("fields"), + ) + + +class StudioPermissionsService: + """ + Service that can provide information about a user's permissions. + + Deprecated. To be replaced by a more general authorization service. + + Only used by LibraryContentBlock (and library_tools.py). + """ + + def __init__(self, user): + self._user = user + + def can_read(self, course_key): + """Does the user have read access to the given course/library?""" + return has_studio_read_access(self._user, course_key) + + def can_write(self, course_key): + """Does the user have read access to the given course/library?""" + return has_studio_write_access(self._user, course_key) + + +def load_services_for_studio(runtime, user): + """ + Function to set some required services used for XBlock edits and studio_view. + (i.e. whenever we're not loading _prepare_runtime_for_preview.) This is required to make information + about the current user (especially permissions) available via services as needed. + """ + services = { + "user": DjangoXBlockUserService(user), + "studio_user_permissions": StudioPermissionsService(user), + "mako": MakoService(), + "settings": SettingsService(), + "lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag), + "teams_configuration": TeamsConfigurationService(), + "library_tools": LibraryToolsService(modulestore(), user.id), + } + + runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access + + +def _update_with_callback(xblock, user, old_metadata=None, old_content=None): + """ + Updates the xblock in the modulestore. + But before doing so, it calls the xblock's editor_saved callback function. + """ + if callable(getattr(xblock, "editor_saved", None)): + if old_metadata is None: + old_metadata = own_metadata(xblock) + if old_content is None: + old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) + load_services_for_studio(xblock.runtime, user) + xblock.editor_saved(user, old_metadata, old_content) + + # Update after the callback so any changes made in the callback will get persisted. + return modulestore().update_item(xblock, user.id) + + +def _save_xblock( # lint-amnesty, pylint: disable=too-many-statements + user, + xblock, + data=None, + children_strings=None, + metadata=None, + nullout=None, + grader_type=None, + is_prereq=None, + prereq_usage_key=None, + prereq_min_score=None, + prereq_min_completion=None, + publish=None, + fields=None, +): + """ + Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. + nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert + to default). + + """ + store = modulestore() + # Perform all xblock changes within a (single-versioned) transaction + with store.bulk_operations(xblock.location.course_key): + # Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI). + if publish == "discard_changes": + store.revert_to_published(xblock.location, user.id) + # Returning the same sort of result that we do for other save operations. In the future, + # we may want to return the full XBlockInfo. + return JsonResponse({"id": str(xblock.location)}) + + old_metadata = own_metadata(xblock) + old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) + + if data: + # TODO Allow any scope.content fields not just "data" (exactly like the get below this) + xblock.data = data + else: + data = old_content["data"] if "data" in old_content else None + + if fields: + for field_name in fields: + setattr(xblock, field_name, fields[field_name]) + + if children_strings is not None: + children = [] + for child_string in children_strings: + children.append(usage_key_with_run(child_string)) + + # if new children have been added, remove them from their old parents + new_children = set(children) - set(xblock.children) + for new_child in new_children: + old_parent_location = store.get_parent_location(new_child) + if old_parent_location: + old_parent = store.get_item(old_parent_location) + old_parent.children.remove(new_child) + old_parent = _update_with_callback(old_parent, user) + else: + # the Studio UI currently doesn't present orphaned children, so assume this is an error + return JsonResponse( + { + "error": "Invalid data, possibly caused by concurrent authors." + }, + 400, + ) + + # make sure there are no old children that became orphans + # In a single-author (no-conflict) scenario, all children in the persisted list on the server should be + # present in the updated list. If there are any children that have been dropped as part of this update, + # then that would be an error. + # + # We can be even more restrictive in a multi-author (conflict), by returning an error whenever + # len(old_children) > 0. However, that conflict can still be "merged" if the dropped child had been + # re-parented. Hence, the check for the parent in the any statement below. + # + # Note that this multi-author conflict error should not occur in modulestores (such as Split) that support + # atomic write transactions. In Split, if there was another author who moved one of the "old_children" + # into another parent, then that child would have been deleted from this parent on the server. However, + # this is error could occur in modulestores (such as Draft) that do not support atomic write-transactions + old_children = set(xblock.children) - set(children) + if any( + store.get_parent_location(old_child) == xblock.location + for old_child in old_children + ): + # since children are moved as part of a single transaction, orphans should not be created + return JsonResponse( + {"error": "Invalid data, possibly caused by concurrent authors."}, + 400, + ) + + # set the children on the xblock + xblock.children = children + + # also commit any metadata which might have been passed along + if nullout is not None or metadata is not None: + # the postback is not the complete metadata, as there's system metadata which is + # not presented to the end-user for editing. So let's use the original (existing_item) and + # 'apply' the submitted metadata, so we don't end up deleting system metadata. + if nullout is not None: + for metadata_key in nullout: + setattr(xblock, metadata_key, None) + + # update existing metadata with submitted metadata (which can be partial) + # IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If + # the intent is to make it None, use the nullout field + if metadata is not None: + for metadata_key, value in metadata.items(): + field = xblock.fields[metadata_key] + + if value is None: + field.delete_from(xblock) + else: + try: + value = field.from_json(value) + except ValueError as verr: + reason = _("Invalid data") + if str(verr): + reason = _("Invalid data ({details})").format( + details=str(verr) + ) + return JsonResponse({"error": reason}, 400) + + field.write_to(xblock, value) + + validate_and_update_xblock_due_date(xblock) + # update the xblock and call any xblock callbacks + xblock = _update_with_callback(xblock, user, old_metadata, old_content) + + # for static tabs, their containing course also records their display name + course = store.get_course(xblock.location.course_key) + if xblock.location.block_type == "static_tab": + # find the course's reference to this tab and update the name. + static_tab = CourseTabList.get_tab_by_slug( + course.tabs, xblock.location.name + ) + # only update if changed + if static_tab: + update_tab = False + if static_tab["name"] != xblock.display_name: + static_tab["name"] = xblock.display_name + update_tab = True + if static_tab["course_staff_only"] != xblock.course_staff_only: + static_tab["course_staff_only"] = xblock.course_staff_only + update_tab = True + if update_tab: + store.update_item(course, user.id) + + result = { + "id": str(xblock.location), + "data": data, + "metadata": own_metadata(xblock), + } + + if grader_type is not None: + result.update( + CourseGradingModel.update_section_grader_type(xblock, grader_type, user) + ) + + # Save gating info + if xblock.category == "sequential" and course.enable_subsection_gating: + if is_prereq is not None: + if is_prereq: + gating_api.add_prerequisite( + xblock.location.course_key, xblock.location + ) + else: + gating_api.remove_prerequisite(xblock.location) + result["is_prereq"] = is_prereq + + if prereq_usage_key is not None: + gating_api.set_required_content( + xblock.location.course_key, + xblock.location, + prereq_usage_key, + prereq_min_score, + prereq_min_completion, + ) + + # If publish is set to 'republish' and this item is not in direct only categories and has previously been + # published, then this item should be republished. This is used by staff locking to ensure that changing the + # draft value of the staff lock will also update the published version, but only at the unit level. + if publish == "republish" and xblock.category not in DIRECT_ONLY_CATEGORIES: + if modulestore().has_published_version(xblock): + publish = "make_public" + + # Make public after updating the xblock, in case the caller asked for both an update and a publish. + # Used by Bok Choy tests and by republishing of staff locks. + if publish == "make_public": + modulestore().publish(xblock.location, user.id) + + # Note that children aren't being returned until we have a use case. + return JsonResponse(result, encoder=EdxJSONEncoder) + + +@login_required +@expect_json +def create_item(request): + """ + Exposes internal helper method without breaking existing bindings/dependencies + """ + return _create_block(request) + + +@login_required +@expect_json +def _create_block(request): + """View for create blocks.""" + parent_locator = request.json["parent_locator"] + usage_key = usage_key_with_run(parent_locator) + if not has_studio_write_access(request.user, usage_key.course_key): + raise PermissionDenied() + + if request.json.get("staged_content") == "clipboard": + # Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key': + try: + created_xblock = import_staged_content_from_user_clipboard( + parent_key=usage_key, request=request + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Could not paste component into location {}".format(usage_key) + ) + return JsonResponse( + {"error": _("There was a problem pasting your component.")}, status=400 + ) + if created_xblock is None: + return JsonResponse( + {"error": _("Your clipboard is empty or invalid.")}, status=400 + ) + return JsonResponse( + { + "locator": str(created_xblock.location), + "courseKey": str(created_xblock.location.course_key), + } + ) + + category = request.json["category"] + if isinstance(usage_key, LibraryUsageLocator): + # Only these categories are supported at this time. + if category not in ["html", "problem", "video"]: + return HttpResponseBadRequest( + "Category '%s' not supported for Libraries" % category, + content_type="text/plain", + ) + + if _is_library_component_limit_reached(usage_key): + return JsonResponse( + { + "error": _( + "Libraries cannot have more than {limit} components" + ).format(limit=settings.MAX_BLOCKS_PER_CONTENT_LIBRARY) + }, + status=400, + ) + + created_block = create_xblock( + parent_locator=parent_locator, + user=request.user, + category=category, + display_name=request.json.get("display_name"), + boilerplate=request.json.get("boilerplate"), + ) + + return JsonResponse( + { + "locator": str(created_block.location), + "courseKey": str(created_block.location.course_key), + } + ) + + +def _get_source_index(source_usage_key, source_parent): + """ + Get source index position of the XBlock. + + Arguments: + source_usage_key (BlockUsageLocator): Locator of source item. + source_parent (XBlock): A parent of the source XBlock. + + Returns: + source_index (int): Index position of the xblock in a parent. + """ + try: + source_index = source_parent.children.index(source_usage_key) + return source_index + except ValueError: + return None + + +def is_source_item_in_target_parents(source_item, target_parent): + """ + Returns True if source item is found in target parents otherwise False. + + Arguments: + source_item (XBlock): Source Xblock. + target_parent (XBlock): Target XBlock. + """ + target_ancestors = _create_xblock_ancestor_info(target_parent, is_concise=True)[ + "ancestors" + ] + for target_ancestor in target_ancestors: + if str(source_item.location) == target_ancestor["id"]: + return True + return False + + +def _move_item(source_usage_key, target_parent_usage_key, user, target_index=None): + """ + Move an existing xblock as a child of the supplied target_parent_usage_key. + + Arguments: + source_usage_key (BlockUsageLocator): Locator of source item. + target_parent_usage_key (BlockUsageLocator): Locator of target parent. + target_index (int): If provided, insert source item at provided index location in target_parent_usage_key item. + + Returns: + JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation + is performed. + """ + # Get the list of all parentable component type XBlocks. + parent_component_types = list( + { + name + for name, class_ in XBlock.load_classes() + if getattr(class_, "has_children", False) + } + - set(DIRECT_ONLY_CATEGORIES) + ) + + store = modulestore() + with store.bulk_operations(source_usage_key.course_key): + source_item = store.get_item(source_usage_key) + source_parent = source_item.get_parent() + target_parent = store.get_item(target_parent_usage_key) + source_type = source_item.category + target_parent_type = target_parent.category + error = None + + # Store actual/initial index of the source item. This would be sent back with response, + # so that with Undo operation, it would easier to move back item to it's original/old index. + source_index = _get_source_index(source_usage_key, source_parent) + + valid_move_type = { + "sequential": "vertical", + "chapter": "sequential", + } + + if ( + valid_move_type.get(target_parent_type, "") != source_type + and target_parent_type not in parent_component_types + ): + error = _( + "You can not move {source_type} into {target_parent_type}." + ).format( + source_type=source_type, + target_parent_type=target_parent_type, + ) + elif ( + source_parent.location == target_parent.location + or source_item.location in target_parent.children + ): + error = _("Item is already present in target location.") + elif source_item.location == target_parent.location: + error = _("You can not move an item into itself.") + elif is_source_item_in_target_parents(source_item, target_parent): + error = _("You can not move an item into it's child.") + elif target_parent_type == "split_test": + error = _("You can not move an item directly into content experiment.") + elif source_index is None: + error = _("{source_usage_key} not found in {parent_usage_key}.").format( + source_usage_key=str(source_usage_key), + parent_usage_key=str(source_parent.location), + ) + else: + try: + target_index = int(target_index) if target_index is not None else None + if ( + target_index is not None + and len(target_parent.children) < target_index + ): + error = _( + "You can not move {source_usage_key} at an invalid index ({target_index})." + ).format( + source_usage_key=str(source_usage_key), + target_index=target_index, + ) + except ValueError: + error = _( + "You must provide target_index ({target_index}) as an integer." + ).format(target_index=target_index) + if error: + return JsonResponse({"error": error}, status=400) + + # When target_index is provided, insert xblock at target_index position, otherwise insert at the end. + insert_at = ( + target_index if target_index is not None else len(target_parent.children) + ) + + store.update_item_parent( + item_location=source_item.location, + new_parent_location=target_parent.location, + old_parent_location=source_parent.location, + insert_at=insert_at, + user_id=user.id, + ) + + log.info( + "MOVE: %s moved from %s to %s at %d index", + str(source_usage_key), + str(source_parent.location), + str(target_parent_usage_key), + insert_at, + ) + + context = { + "move_source_locator": str(source_usage_key), + "parent_locator": str(target_parent_usage_key), + "source_index": target_index if target_index is not None else source_index, + } + return JsonResponse(context) + + +def _duplicate_block( + parent_usage_key, + duplicate_source_usage_key, + user, + display_name=None, + is_child=False, +): + """ + Duplicate an existing xblock as a child of the supplied parent_usage_key. + """ + store = modulestore() + with store.bulk_operations(duplicate_source_usage_key.course_key): + source_item = store.get_item(duplicate_source_usage_key) + # Change the blockID to be unique. + dest_usage_key = source_item.location.replace(name=uuid4().hex) + category = dest_usage_key.block_type + + # Update the display name to indicate this is a duplicate (unless display name provided). + # Can't use own_metadata(), b/c it converts data for JSON serialization - + # not suitable for setting metadata of the new block + duplicate_metadata = {} + for field in source_item.fields.values(): + if field.scope == Scope.settings and field.is_set_on(source_item): + duplicate_metadata[field.name] = field.read_from(source_item) + + if is_child: + display_name = ( + display_name or source_item.display_name or source_item.category + ) + + if display_name is not None: + duplicate_metadata["display_name"] = display_name + else: + if source_item.display_name is None: + duplicate_metadata["display_name"] = _("Duplicate of {0}").format( + source_item.category + ) + else: + duplicate_metadata["display_name"] = _("Duplicate of '{0}'").format( + source_item.display_name + ) + + asides_to_create = [] + for aside in source_item.runtime.get_asides(source_item): + for field in aside.fields.values(): + if field.scope in ( + Scope.settings, + Scope.content, + ) and field.is_set_on(aside): + asides_to_create.append(aside) + break + + for aside in asides_to_create: + for field in aside.fields.values(): + if field.scope not in ( + Scope.settings, + Scope.content, + ): + field.delete_from(aside) + + dest_block = store.create_item( + user.id, + dest_usage_key.course_key, + dest_usage_key.block_type, + block_id=dest_usage_key.block_id, + definition_data=source_item.get_explicitly_set_fields_by_scope( + Scope.content + ), + metadata=duplicate_metadata, + runtime=source_item.runtime, + asides=asides_to_create, + ) + + children_handled = False + + if hasattr(dest_block, "studio_post_duplicate"): + # Allow an XBlock to do anything fancy it may need to when duplicated from another block. + # These blocks may handle their own children or parenting if needed. Let them return booleans to + # let us know if we need to handle these or not. + load_services_for_studio(dest_block.runtime, user) + children_handled = dest_block.studio_post_duplicate(store, source_item) + + # Children are not automatically copied over (and not all xblocks have a 'children' attribute). + # Because DAGs are not fully supported, we need to actually duplicate each child as well. + if source_item.has_children and not children_handled: + dest_block.children = dest_block.children or [] + for child in source_item.children: + dupe = _duplicate_block( + dest_block.location, child, user=user, is_child=True + ) + if ( + dupe not in dest_block.children + ): # _duplicate_block may add the child for us. + dest_block.children.append(dupe) + store.update_item(dest_block, user.id) + + # pylint: disable=protected-access + if "detached" not in source_item.runtime.load_block_type(category)._class_tags: + parent = store.get_item(parent_usage_key) + # If source was already a child of the parent, add duplicate immediately afterward. + # Otherwise, add child to end. + if source_item.location in parent.children: + source_index = parent.children.index(source_item.location) + parent.children.insert(source_index + 1, dest_block.location) + else: + parent.children.append(dest_block.location) + store.update_item(parent, user.id) + + # .. event_implemented_name: XBLOCK_DUPLICATED + XBLOCK_DUPLICATED.send_event( + time=datetime.now(timezone.utc), + xblock_info=DuplicatedXBlockData( + usage_key=dest_block.location, + block_type=dest_block.location.block_type, + source_usage_key=duplicate_source_usage_key, + ), + ) + + return dest_block.location + + +@login_required +@expect_json +def delete_item(request, usage_key): + """ + Exposes internal helper method without breaking existing bindings/dependencies + """ + _delete_item(usage_key, request.user) + + +def _delete_item(usage_key, user): + """ + Deletes an existing xblock with the given usage_key. + If the xblock is a Static Tab, removes it from course.tabs as well. + """ + store = modulestore() + + with store.bulk_operations(usage_key.course_key): + # VS[compat] cdodge: This is a hack because static_tabs also have references from the course block, so + # if we add one then we need to also add it to the policy information (i.e. metadata) + # we should remove this once we can break this reference from the course to static tabs + if usage_key.block_type == "static_tab": + course = store.get_course(usage_key.course_key) + existing_tabs = course.tabs or [] + course.tabs = [ + tab + for tab in existing_tabs + if tab.get("url_slug") != usage_key.block_id + ] + store.update_item(course, user.id) + + # Delete user bookmarks + bookmarks_api.delete_bookmarks(usage_key) + store.delete_item(usage_key, user.id) + + +def delete_orphans(course_usage_key, user_id, commit=False): + """ + Helper function to delete orphans for a given course. + If `commit` is False, this function does not actually remove + the orphans. + """ + store = modulestore() + blocks = store.get_orphans(course_usage_key) + branch = course_usage_key.branch + if commit: + with store.bulk_operations(course_usage_key): + for blockloc in blocks: + revision = ModuleStoreEnum.RevisionOption.all + # specify branches when deleting orphans + if branch == ModuleStoreEnum.BranchName.published: + revision = ModuleStoreEnum.RevisionOption.published_only + store.delete_item(blockloc, user_id, revision=revision) + return [str(block) for block in blocks] + + +def get_xblock(usage_key, user): + """ + Returns the xblock for the specified usage key. Note: if failing to find a key with a category + in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically. + """ + store = modulestore() + with store.bulk_operations(usage_key.course_key): + try: + return store.get_item(usage_key, depth=None) + except ItemNotFoundError: + if usage_key.block_type in CREATE_IF_NOT_FOUND: + # Create a new one for certain categories only. Used for course info handouts. + return store.create_item( + user.id, + usage_key.course_key, + usage_key.block_type, + block_id=usage_key.block_id, + ) + else: + raise + except InvalidLocationError: + log.error("Can't find item by location.") + return JsonResponse( + {"error": "Can't find item by location: " + str(usage_key)}, 404 + ) + + +def get_block_info( + xblock, + rewrite_static_links=True, + include_ancestor_info=False, + include_publishing_info=False, +): + """ + metadata, data, id representation of a leaf block fetcher. + :param usage_key: A UsageKey + """ + with modulestore().bulk_operations(xblock.location.course_key): + data = getattr(xblock, "data", "") + if rewrite_static_links: + data = replace_static_urls(data, None, course_id=xblock.location.course_key) + + # Pre-cache has changes for the entire course because we'll need it for the ancestor info + # Except library blocks which don't [yet] use draft/publish + if not isinstance(xblock.location, LibraryUsageLocator): + modulestore().has_changes( + modulestore().get_course(xblock.location.course_key, depth=None) + ) + + # Note that children aren't being returned until we have a use case. + xblock_info = create_xblock_info( + xblock, + data=data, + metadata=own_metadata(xblock), + include_ancestor_info=include_ancestor_info, + ) + if include_publishing_info: + add_container_page_publishing_info(xblock, xblock_info) + + return xblock_info + + +def _get_gating_info(course, xblock): + """ + Returns a dict containing gating information for the given xblock which + can be added to xblock info responses. + + Arguments: + course (CourseBlock): The course + xblock (XBlock): The xblock + + Returns: + dict: Gating information + """ + info = {} + if xblock.category == "sequential" and course.enable_subsection_gating: + if not hasattr(course, "gating_prerequisites"): + # Cache gating prerequisites on course block so that we are not + # hitting the database for every xblock in the course + course.gating_prerequisites = gating_api.get_prerequisites(course.id) + info["is_prereq"] = gating_api.is_prerequisite(course.id, xblock.location) + info["prereqs"] = [ + p + for p in course.gating_prerequisites + if str(xblock.location) not in p["namespace"] + ] + ( + prereq, + prereq_min_score, + prereq_min_completion, + ) = gating_api.get_required_content(course.id, xblock.location) + info["prereq"] = prereq + info["prereq_min_score"] = prereq_min_score + info["prereq_min_completion"] = prereq_min_completion + if prereq: + info["visibility_state"] = VisibilityState.gated + return info + + +@pluggable_override("OVERRIDE_CREATE_XBLOCK_INFO") +def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements + xblock, + data=None, + metadata=None, + include_ancestor_info=False, + include_child_info=False, + course_outline=False, + include_children_predicate=NEVER, + parent_xblock=None, + graders=None, + user=None, + course=None, + is_concise=False, +): + """ + Creates the information needed for client-side XBlockInfo. + + If data or metadata are not specified, their information will not be added + (regardless of whether or not the xblock actually has data or metadata). + + There are three optional boolean parameters: + include_ancestor_info - if true, ancestor info is added to the response + include_child_info - if true, direct child info is included in the response + is_concise - if true, returns the concise version of xblock info, default is false. + course_outline - if true, the xblock is being rendered on behalf of the course outline. + There are certain expensive computations that do not need to be included in this case. + + In addition, an optional include_children_predicate argument can be provided to define whether or + not a particular xblock should have its children included. + + You can customize the behavior of this function using the `OVERRIDE_CREATE_XBLOCK_INFO` pluggable override point. + For example: + >>> def create_xblock_info(default_fn, xblock, *args, **kwargs): + ... xblock_info = default_fn(xblock, *args, **kwargs) + ... xblock_info['icon'] = xblock.icon_override + ... return xblock_info + """ + is_library_block = isinstance(xblock.location, LibraryUsageLocator) + is_xblock_unit = is_unit(xblock, parent_xblock) + # this should not be calculated for Sections and Subsections on Unit page or for library blocks + has_changes = None + if (is_xblock_unit or course_outline) and not is_library_block: + has_changes = modulestore().has_changes(xblock) + + if graders is None: + if not is_library_block: + graders = CourseGradingModel.fetch(xblock.location.course_key).graders + else: + graders = [] + + # Filter the graders data as needed + graders = _filter_entrance_exam_grader(graders) + + # We need to load the course in order to retrieve user partition information. + # For this reason, we load the course once and re-use it when recursively loading children. + if course is None: + course = modulestore().get_course(xblock.location.course_key) + + # Compute the child info first so it can be included in aggregate information for the parent + should_visit_children = include_child_info and ( + course_outline and not is_xblock_unit or not course_outline + ) + if should_visit_children and xblock.has_children: + child_info = _create_xblock_child_info( + xblock, + course_outline, + graders, + include_children_predicate=include_children_predicate, + user=user, + course=course, + is_concise=is_concise, + ) + else: + child_info = None + + release_date = _get_release_date(xblock, user) + + if xblock.category != "course" and not is_concise: + visibility_state = _compute_visibility_state( + xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course) + ) + else: + visibility_state = None + published = ( + modulestore().has_published_version(xblock) if not is_library_block else None + ) + published_on = ( + get_default_time_display(xblock.published_on) + if published and xblock.published_on + else None + ) + + # defining the default value 'True' for delete, duplicate, drag and add new child actions + # in xblock_actions for each xblock. + xblock_actions = { + "deletable": True, + "draggable": True, + "childAddable": True, + "duplicable": True, + } + explanatory_message = None + + # is_entrance_exam is inherited metadata. + if xblock.category == "chapter" and getattr(xblock, "is_entrance_exam", None): + # Entrance exam section should not be deletable, draggable and not have 'New Subsection' button. + xblock_actions["deletable"] = xblock_actions["childAddable"] = xblock_actions[ + "draggable" + ] = False + if parent_xblock is None: + parent_xblock = get_parent_xblock(xblock) + + # Translators: The {pct_sign} here represents the percent sign, i.e., '%' + # in many languages. This is used to avoid Transifex's misinterpreting of + # '% o'. The percent sign is also translatable as a standalone string. + explanatory_message = _( + "Students must score {score}{pct_sign} or higher to access course materials." + ).format( + score=int(parent_xblock.entrance_exam_minimum_score_pct * 100), + # Translators: This is the percent sign. It will be used to represent + # a percent value out of 100, e.g. "58%" means "58/100". + pct_sign=_("%"), + ) + + xblock_info = { + "id": str(xblock.location), + "display_name": xblock.display_name_with_default, + "category": xblock.category, + "has_children": xblock.has_children, + } + + if course is not None and PUBLIC_VIDEO_SHARE.is_enabled(xblock.location.course_key): + xblock_info.update( + { + "video_sharing_enabled": True, + "video_sharing_options": course.video_sharing_options, + "video_sharing_doc_url": HelpUrlExpert.the_one().url_for_token( + "social_sharing" + ), + } + ) + + if xblock.category == "course": + discussions_config = DiscussionsConfiguration.get(course.id) + show_unit_level_discussions_toggle = ( + discussions_config.enabled + and discussions_config.supports_in_context_discussions() + and discussions_config.enable_in_context + and discussions_config.unit_level_visibility + ) + xblock_info["unit_level_discussions"] = show_unit_level_discussions_toggle + + if is_concise: + if child_info and child_info.get("children", []): + xblock_info["child_info"] = child_info + # Groups are labelled with their internal ids, rather than with the group name. Replace id with display name. + group_display_name = get_split_group_display_name(xblock, course) + xblock_info["display_name"] = ( + group_display_name if group_display_name else xblock_info["display_name"] + ) + else: + user_partitions = get_user_partition_info(xblock, course=course) + xblock_info.update( + { + "edited_on": get_default_time_display(xblock.subtree_edited_on) + if xblock.subtree_edited_on + else None, + "published": published, + "published_on": published_on, + "studio_url": xblock_studio_url(xblock, parent_xblock), + "released_to_students": datetime.now(UTC) > xblock.start, + "release_date": release_date, + "visibility_state": visibility_state, + "has_explicit_staff_lock": xblock.fields[ + "visible_to_staff_only" + ].is_set_on(xblock), + "start": xblock.fields["start"].to_json(xblock.start), + "graded": xblock.graded, + "due_date": get_default_time_display(xblock.due), + "due": xblock.fields["due"].to_json(xblock.due), + "relative_weeks_due": xblock.relative_weeks_due, + "format": xblock.format, + "course_graders": [grader.get("type") for grader in graders], + "has_changes": has_changes, + "actions": xblock_actions, + "explanatory_message": explanatory_message, + "group_access": xblock.group_access, + "user_partitions": user_partitions, + "show_correctness": xblock.show_correctness, + } + ) + + if xblock.category == "sequential": + xblock_info.update( + { + "hide_after_due": xblock.hide_after_due, + } + ) + elif xblock.category in ("chapter", "course"): + if xblock.category == "chapter": + xblock_info.update( + { + "highlights": xblock.highlights, + } + ) + elif xblock.category == "course": + xblock_info.update( + { + "highlights_enabled_for_messaging": course.highlights_enabled_for_messaging, + } + ) + xblock_info.update( + { + # used to be controlled by a waffle switch, now just always enabled + "highlights_enabled": True, + # used to be controlled by a waffle flag, now just always disabled + "highlights_preview_only": False, + "highlights_doc_url": HelpUrlExpert.the_one().url_for_token( + "content_highlights" + ), + } + ) + + # update xblock_info with special exam information if the feature flag is enabled + if settings.FEATURES.get("ENABLE_SPECIAL_EXAMS"): + if xblock.category == "course": + xblock_info.update( + { + "enable_proctored_exams": xblock.enable_proctored_exams, + "create_zendesk_tickets": xblock.create_zendesk_tickets, + "enable_timed_exams": xblock.enable_timed_exams, + } + ) + elif xblock.category == "sequential": + rules_url = settings.PROCTORING_SETTINGS.get("LINK_URLS", {}).get( + "online_proctoring_rules", "" + ) + supports_onboarding = does_backend_support_onboarding( + course.proctoring_provider + ) + + proctoring_exam_configuration_link = None + if xblock.is_proctored_exam: + proctoring_exam_configuration_link = ( + get_exam_configuration_dashboard_url( + course.id, xblock_info["id"] + ) + ) + + if course.proctoring_provider == "proctortrack": + show_review_rules = SHOW_REVIEW_RULES_FLAG.is_enabled( + xblock.location.course_key + ) + else: + show_review_rules = True + + xblock_info.update( + { + "is_proctored_exam": xblock.is_proctored_exam, + "was_exam_ever_linked_with_external": _was_xblock_ever_exam_linked_with_external( + course, xblock + ), + "online_proctoring_rules": rules_url, + "is_practice_exam": xblock.is_practice_exam, + "is_onboarding_exam": xblock.is_onboarding_exam, + "is_time_limited": xblock.is_time_limited, + "exam_review_rules": xblock.exam_review_rules, + "default_time_limit_minutes": xblock.default_time_limit_minutes, + "proctoring_exam_configuration_link": proctoring_exam_configuration_link, + "supports_onboarding": supports_onboarding, + "show_review_rules": show_review_rules, + } + ) + + # Update with gating info + xblock_info.update(_get_gating_info(course, xblock)) + if is_xblock_unit: + # if xblock is a Unit we add the discussion_enabled option + xblock_info["discussion_enabled"] = xblock.discussion_enabled + if xblock.category == "sequential": + # Entrance exam subsection should be hidden. in_entrance_exam is + # inherited metadata, all children will have it. + if getattr(xblock, "in_entrance_exam", False): + xblock_info["is_header_visible"] = False + + if data is not None: + xblock_info["data"] = data + if metadata is not None: + xblock_info["metadata"] = metadata + if include_ancestor_info: + xblock_info["ancestor_info"] = _create_xblock_ancestor_info( + xblock, course_outline, include_child_info=True + ) + if child_info: + xblock_info["child_info"] = child_info + if visibility_state == VisibilityState.staff_only: + xblock_info["ancestor_has_staff_lock"] = ancestor_has_staff_lock( + xblock, parent_xblock + ) + else: + xblock_info["ancestor_has_staff_lock"] = False + + if course_outline: + if xblock_info["has_explicit_staff_lock"]: + xblock_info["staff_only_message"] = True + elif child_info and child_info["children"]: + xblock_info["staff_only_message"] = all( + child["staff_only_message"] for child in child_info["children"] + ) + else: + xblock_info["staff_only_message"] = False + + xblock_info[ + "has_partition_group_components" + ] = has_children_visible_to_specific_partition_groups(xblock) + xblock_info["user_partition_info"] = get_visibility_partition_info( + xblock, course=course + ) + + return xblock_info + + +def _was_xblock_ever_exam_linked_with_external(course, xblock): + """ + Determine whether this XBlock is or was ever configured as an external proctored exam. + + If this block is *not* currently an externally linked proctored exam, the best way for us to tell + whether it was was *ever* such is by checking whether + edx-proctoring has an exam record associated with the block's ID, + and the exam record has external_id. + If an exception is not raised, then we know that such a record exists, + indicating that this *was* once an externally linked proctored exam. + + Arguments: + course (CourseBlock) + xblock (XBlock) + + Returns: bool + """ + try: + exam = get_exam_by_content_id(course.id, xblock.location) + return bool("external_id" in exam and exam["external_id"]) + except ProctoredExamNotFoundException: + pass + return False + + +def add_container_page_publishing_info(xblock, xblock_info): + """ + Adds information about the xblock's publish state to the supplied + xblock_info for the container page. + """ + + def safe_get_username(user_id): + """ + Guard against bad user_ids, like the infamous "**replace_user**". + Note that this will ignore our special known IDs (ModuleStoreEnum.UserID). + We should consider adding special handling for those values. + + :param user_id: the user id to get the username of + :return: username, or None if the user does not exist or user_id is None + """ + if user_id: + try: + return User.objects.get(id=user_id).username + except: # pylint: disable=bare-except + pass + + return None + + xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by) + xblock_info["published_by"] = safe_get_username(xblock.published_by) + xblock_info["currently_visible_to_students"] = is_currently_visible_to_students( + xblock + ) + xblock_info[ + "has_partition_group_components" + ] = has_children_visible_to_specific_partition_groups(xblock) + if xblock_info["release_date"]: + xblock_info["release_date_from"] = _get_release_date_from(xblock) + if xblock_info["visibility_state"] == VisibilityState.staff_only: + xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock) + else: + xblock_info["staff_lock_from"] = None + + +class VisibilityState: + """ + Represents the possible visibility states for an xblock: + + live - the block and all of its descendants are live to students (excluding staff only items) + Note: Live means both published and released. + + ready - the block is ready to go live and all of its descendants are live or ready (excluding staff only items) + Note: content is ready when it is published and scheduled with a release date in the future. + + unscheduled - the block and all of its descendants have no release date (excluding staff only items) + Note: it is valid for items to be published with no release date in which case they are still unscheduled. + + needs_attention - the block or its descendants are not fully live, ready or unscheduled + (excluding staff only items) + For example: one subsection has draft content, or there's both unreleased and released content in one section. + + staff_only - all of the block's content is to be shown to staff only + Note: staff only items do not affect their parent's state. + + gated - all of the block's content is to be shown to students only after the configured prerequisite is met + """ + + live = "live" + ready = "ready" + unscheduled = "unscheduled" + needs_attention = "needs_attention" + staff_only = "staff_only" + gated = "gated" + + +def _compute_visibility_state( + xblock, child_info, is_unit_with_changes, is_course_self_paced=False +): + """ + Returns the current publish state for the specified xblock and its children + """ + if xblock.visible_to_staff_only: + return VisibilityState.staff_only + elif is_unit_with_changes: + # Note that a unit that has never been published will fall into this category, + # as well as previously published units with draft content. + return VisibilityState.needs_attention + + is_unscheduled = xblock.start == DEFAULT_START_DATE + is_live = is_course_self_paced or datetime.now(UTC) > xblock.start + if child_info and child_info.get("children", []): + all_staff_only = True + all_unscheduled = True + all_live = True + for child in child_info["children"]: + child_state = child["visibility_state"] + if child_state == VisibilityState.needs_attention: + return child_state + elif not child_state == VisibilityState.staff_only: + all_staff_only = False + if not child_state == VisibilityState.unscheduled: + all_unscheduled = False + if not child_state == VisibilityState.live: + all_live = False + if all_staff_only: + return VisibilityState.staff_only + elif all_unscheduled: + return ( + VisibilityState.unscheduled + if is_unscheduled + else VisibilityState.needs_attention + ) + elif all_live: + return VisibilityState.live if is_live else VisibilityState.needs_attention + else: + return ( + VisibilityState.ready + if not is_unscheduled + else VisibilityState.needs_attention + ) + if is_live: + return VisibilityState.live + elif is_unscheduled: + return VisibilityState.unscheduled + else: + return VisibilityState.ready + + +def _create_xblock_ancestor_info( + xblock, course_outline=False, include_child_info=False, is_concise=False +): + """ + Returns information about the ancestors of an xblock. Note that the direct parent will also return + information about all of its children. + """ + ancestors = [] + + def collect_ancestor_info(ancestor, include_child_info=False, is_concise=False): + """ + Collect xblock info regarding the specified xblock and its ancestors. + """ + if ancestor: + direct_children_only = lambda parent: parent == ancestor + ancestors.append( + create_xblock_info( + ancestor, + include_child_info=include_child_info, + course_outline=course_outline, + include_children_predicate=direct_children_only, + is_concise=is_concise, + ) + ) + collect_ancestor_info(get_parent_xblock(ancestor), is_concise=is_concise) + + collect_ancestor_info( + get_parent_xblock(xblock), + include_child_info=include_child_info, + is_concise=is_concise, + ) + return {"ancestors": ancestors} + + +def _create_xblock_child_info( + xblock, + course_outline, + graders, + include_children_predicate=NEVER, + user=None, + course=None, + is_concise=False, +): + """ + Returns information about the children of an xblock, as well as about the primary category + of xblock expected as children. + """ + child_info = {} + child_category = xblock_primary_child_category(xblock) + if child_category: + child_info = { + "category": child_category, + "display_name": xblock_type_display_name( + child_category, default_display_name=child_category + ), + } + if xblock.has_children and include_children_predicate(xblock): + child_info["children"] = [ + create_xblock_info( + child, + include_child_info=True, + course_outline=course_outline, + include_children_predicate=include_children_predicate, + parent_xblock=xblock, + graders=graders, + user=user, + course=course, + is_concise=is_concise, + ) + for child in xblock.get_children() + ] + return child_info + + +def _get_release_date(xblock, user=None): + """ + Returns the release date for the xblock, or None if the release date has never been set. + """ + # If year of start date is less than 1900 then reset the start date to DEFAULT_START_DATE + reset_to_default = False + try: + reset_to_default = xblock.start.year < 1900 + except ValueError: + # For old mongo courses, accessing the start attribute calls `to_json()`, + # which raises a `ValueError` for years < 1900. + reset_to_default = True + + if reset_to_default and user: + xblock.start = DEFAULT_START_DATE + xblock = _update_with_callback(xblock, user) + + # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set + return ( + get_default_time_display(xblock.start) + if xblock.start != DEFAULT_START_DATE + else None + ) + + +def validate_and_update_xblock_due_date(xblock): + """ + Validates the due date for the xblock, and set to None if pre-1900 due date provided + """ + if xblock.due and xblock.due.year < 1900: + xblock.due = None + + +def _get_release_date_from(xblock): + """ + Returns a string representation of the section or subsection that sets the xblock's release date + """ + return _xblock_type_and_display_name(find_release_date_source(xblock)) + + +def _get_staff_lock_from(xblock): + """ + Returns a string representation of the section or subsection that sets the xblock's release date + """ + source = find_staff_lock_source(xblock) + return _xblock_type_and_display_name(source) if source else None + + +def _xblock_type_and_display_name(xblock): + """ + Returns a string representation of the xblock's type and display name + """ + return _('{section_or_subsection} "{display_name}"').format( + section_or_subsection=xblock_type_display_name(xblock), + display_name=xblock.display_name_with_default, + ) diff --git a/cms/templates/container.html b/cms/templates/container.html index 49d8903b9311..89cb4bbfd62f 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -11,7 +11,7 @@ <%! from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name from openedx.core.djangolib.js_utils import ( dump_js_escaped_json, js_escaped_string ) diff --git a/cms/templates/library.html b/cms/templates/library.html index a1c0f4525f4a..57d0363dad9e 100644 --- a/cms/templates/library.html +++ b/cms/templates/library.html @@ -4,7 +4,7 @@ <% return "content_libraries" %> <%! -from cms.djangoapps.contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name from django.utils.translation import gettext as _ from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.djangolib.markup import HTML, Text diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 494afb47a7a0..747a6b7827f3 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -1,7 +1,7 @@ <%page expression_filter="h"/> <%! from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.views.helpers import xblock_studio_url +from cms.djangoapps.contentstore.helpers import xblock_studio_url from cms.djangoapps.contentstore.utils import is_visible_to_specific_partition_groups, get_editor_page_base_url, determine_label from lms.lib.utils import is_unit from openedx.core.djangolib.js_utils import ( diff --git a/common/djangoapps/util/json_request.py b/common/djangoapps/util/json_request.py index 3c9646b130e5..7cbd38fcb237 100644 --- a/common/djangoapps/util/json_request.py +++ b/common/djangoapps/util/json_request.py @@ -21,6 +21,7 @@ class EDXJSONEncoder(DjangoJSONEncoder): want and also this is different from Django 1.4, In Django 1.4 if Decimal object has zeros after the decimal point then object will be serialized as `int` else `float`, so we are keeping this behavior. """ + def default(self, o): # pylint: disable=method-hidden """ Encode Decimal objects. If decimal object has zeros after the @@ -40,34 +41,61 @@ def expect_json(view_function): CONTENT_TYPE is application/json, parses the json dict from request.body, and updates request.POST with the contents. """ + @wraps(view_function) def parse_json_into_request(request, *args, **kwargs): # cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information # e.g. 'charset', so we can't do a direct string compare - if "application/json" in request.META.get('CONTENT_TYPE', '') and request.body: + if "application/json" in request.META.get("CONTENT_TYPE", "") and request.body: try: - request.json = json.loads(request.body.decode('utf8')) + request.json = json.loads(request.body.decode("utf8")) except ValueError: return JsonResponseBadRequest({"error": "Invalid JSON"}) else: request.json = {} return view_function(request, *args, **kwargs) - return parse_json_into_request +def expect_json_in_class_view(view): + """ + Class-based View decorator for simplifying handing of requests that expect json. If the request's + CONTENT_TYPE is application/json, parses the json dict from request.body, and updates + request.POST with the contents. + """ + + def _wrapper_view(self, request, *args, **kwargs): + if "application/json" in request.META.get("CONTENT_TYPE", "") and request.body: + try: + request.json = json.loads(request.body.decode("utf8")) + except ValueError: + return JsonResponseBadRequest({"error": "Invalid JSON"}) + else: + request.json = {} + return view(self, request, *args, **kwargs) + + return _wrapper_view + + class JsonResponse(HttpResponse): """ Django HttpResponse subclass that has sensible defaults for outputting JSON. """ - def __init__(self, resp_obj=None, status=None, encoder=EDXJSONEncoder, # lint-amnesty, pylint: disable=keyword-arg-before-vararg - *args, **kwargs): + + def __init__( # lint-amnesty, pylint: disable=keyword-arg-before-vararg + self, + resp_obj=None, + status=None, + encoder=EDXJSONEncoder, + *args, + **kwargs + ): if resp_obj in (None, ""): content = "" status = status or 204 elif isinstance(resp_obj, QuerySet): - content = serialize('json', resp_obj) + content = serialize("json", resp_obj) else: content = json.dumps(resp_obj, cls=encoder, indent=2, ensure_ascii=True) kwargs.setdefault("content_type", "application/json") @@ -86,7 +114,10 @@ class JsonResponseBadRequest(HttpResponseBadRequest): status: 400 encoder: DjangoJSONEncoder """ - def __init__(self, obj=None, status=400, encoder=DjangoJSONEncoder, *args, **kwargs): # lint-amnesty, pylint: disable=keyword-arg-before-vararg + + def __init__( + self, obj=None, status=400, encoder=DjangoJSONEncoder, *args, **kwargs + ): # lint-amnesty, pylint: disable=keyword-arg-before-vararg if obj in (None, ""): content = "" else: diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index e796beef1201..5d4a134b20a9 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -964,7 +964,7 @@ def get_allowed_block_types(library_key): # pylint: disable=unused-argument # This import breaks in the LMS so keep it here. The LMS doesn't generally # use content libraries APIs directly but some tests may want to use them to # create libraries and then test library learning or course-library integration. - from cms.djangoapps.contentstore.views.helpers import xblock_type_display_name + from cms.djangoapps.contentstore.helpers import xblock_type_display_name # TODO: return support status and template options # See cms/djangoapps/contentstore/views/component.py block_types = sorted(name for name, class_ in XBlock.load_classes()) diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py index 90e79892fcb3..06b687838f94 100644 --- a/openedx/core/djangoapps/content_staging/serializers.py +++ b/openedx/core/djangoapps/content_staging/serializers.py @@ -3,7 +3,7 @@ """ from rest_framework import serializers -from cms.djangoapps.contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name from common.djangoapps.student.auth import has_studio_read_access from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError