* Consolidates and renames the runtime used as a base for all the others:
* Before: `xmodule.x_module:DescriptorSystem` and
`xmodule.mako_block:MakoDescriptorSystem`.
* After: `xmodule.x_module:ModuleStoreRuntime`.
* Co-locates and renames the runtimes for importing course OLX:
* Before: `xmodule.x_module:XMLParsingSystem` and
`xmodule.modulestore.xml:ImportSystem`.
* After: `xmodule.modulestore.xml:XMLParsingModuleStoreRuntime` and
`xmodule.modulestore.xml:XMLImportingModuleStoreRuntime`.
* Note: I would have liked to consolidate these, but it would have
involved nontrivial test refactoring.
* Renames the stub Old Mongo runtime:
* Before: `xmodule.modulestore.mongo.base:CachingDescriptorSystem`.
* After: `xmodule.modulestore.mongo.base:OldModuleStoreRuntime`.
* Renames the Split Mongo runtime, the which is what runs courses in LMS and CMS:
* Before: `xmodule.modulestore.split_mongo.caching_descriptor_system:CachingDescriptorSystem`.
* After: `xmodule.modulestore.split_mongo.runtime:SplitModuleStoreRuntime`.
* Renames some of the dummy runtimes used only in unit tests.
4633 lines
186 KiB
Python
4633 lines
186 KiB
Python
"""Tests for block views."""
|
|
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import Mock, PropertyMock, patch
|
|
|
|
import ddt
|
|
from django.conf import settings
|
|
from django.http import Http404
|
|
from django.test import TestCase
|
|
from django.test.client import RequestFactory
|
|
from django.urls import reverse
|
|
from edx_toggles.toggles.testutils import override_waffle_flag
|
|
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
|
|
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
|
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
|
from openedx_events.tests.utils import OpenEdxEventsTestMixin
|
|
from edx_proctoring.exceptions import ProctoredExamNotFoundException
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.asides import AsideUsageKeyV2
|
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
|
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
|
from pyquery import PyQuery
|
|
from pytz import UTC
|
|
from bs4 import BeautifulSoup
|
|
from web_fragments.fragment import Fragment
|
|
from webob import Response
|
|
from xblock.core import XBlockAside
|
|
from xblock.exceptions import NoSuchHandlerError
|
|
from xblock.fields import Scope, ScopeIds, String
|
|
from xblock.runtime import DictKeyValueStore, KvsFieldData
|
|
from xblock.test.tools import TestRuntime
|
|
from xblock.validation import ValidationMessage
|
|
from xmodule.course_block import DEFAULT_START_DATE
|
|
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.partitions.partitions import (
|
|
ENROLLMENT_TRACK_PARTITION_ID,
|
|
MINIMUM_UNUSED_PARTITION_ID,
|
|
Group,
|
|
UserPartition,
|
|
)
|
|
from xmodule.partitions.tests.test_partitions import MockPartitionService
|
|
from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW
|
|
|
|
from cms.djangoapps.contentstore import toggles
|
|
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.xblock_storage_handlers import view_handlers as item_module
|
|
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
|
|
from common.djangoapps.xblock_django.models import (
|
|
XBlockConfiguration,
|
|
XBlockStudioConfiguration,
|
|
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 openedx.core.djangoapps.content_tagging import api as tagging_api
|
|
|
|
from ..component import component_handler, DEFAULT_ADVANCED_MODULES, get_component_templates
|
|
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
|
|
ALWAYS,
|
|
VisibilityState,
|
|
get_block_info,
|
|
_get_source_index,
|
|
_xblock_type_and_display_name,
|
|
add_container_page_publishing_info,
|
|
create_xblock_info,
|
|
)
|
|
from common.test.utils import assert_dict_contains_subset
|
|
|
|
|
|
class AsideTest(XBlockAside):
|
|
"""
|
|
Test xblock aside class
|
|
"""
|
|
|
|
FRAG_CONTENT = "<p>Aside Foo rendered</p>"
|
|
|
|
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")
|
|
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"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
self.course_key = self.course.id
|
|
self.usage_key = self.course.location
|
|
|
|
def get_item_from_modulestore(self, usage_key):
|
|
"""
|
|
Get the item referenced by the UsageKey from the modulestore
|
|
"""
|
|
item = self.store.get_item(usage_key)
|
|
return item
|
|
|
|
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"))
|
|
self.assertEqual(response.status_code, 200)
|
|
key = UsageKey.from_string(parsed["locator"])
|
|
if key.course_key.run is None:
|
|
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
|
|
data = {
|
|
"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
|
|
if boilerplate is not None:
|
|
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
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
return self.response_usage_key(resp)
|
|
|
|
|
|
@ddt.ddt
|
|
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"}
|
|
)
|
|
data = data if data else {}
|
|
resp = self.client.get(preview_url, data, HTTP_ACCEPT="application/json")
|
|
return resp
|
|
|
|
def _get_container_preview(self, usage_key, data=None):
|
|
"""
|
|
Returns the HTML and resources required for the xblock at the specified UsageKey
|
|
"""
|
|
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"]
|
|
self.assertTrue(html)
|
|
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"""
|
|
resp = self._get_preview(usage_key, data)
|
|
self.assertEqual(resp.status_code, expected_code)
|
|
if content_contains:
|
|
self.assertContains(resp, content_contains, status_code=expected_code)
|
|
return resp
|
|
|
|
def test_get_vertical(self):
|
|
# Add a 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))
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
def test_get_empty_container_fragment(self):
|
|
root_usage_key = self._create_vertical()
|
|
html, __ = self._get_container_preview(root_usage_key)
|
|
|
|
# XBlock messages are added by the Studio wrapper.
|
|
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[^-]+")
|
|
|
|
# Verify that the header and article tags are still added
|
|
self.assertIn('<header class="xblock-header xblock-header-vertical ">', html)
|
|
self.assertIn('<article class="xblock-render">', html)
|
|
|
|
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",
|
|
)
|
|
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('<header class="xblock-header xblock-header-vertical ">', html)
|
|
self.assertIn('<article class="xblock-render">', html)
|
|
|
|
# Verify that the Studio element wrapper has been added
|
|
self.assertIn("level-element", html)
|
|
|
|
def test_get_container_nested_container_fragment(self):
|
|
"""
|
|
Test the case of the container page containing a link to another container page.
|
|
"""
|
|
# Add a wrapper with child beneath a child vertical
|
|
root_usage_key = self._create_vertical()
|
|
|
|
resp = self.create_xblock(parent_usage_key=root_usage_key, category="wrapper")
|
|
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",
|
|
)
|
|
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.assertRegex(
|
|
html,
|
|
# The instance of the wrapper class will have an auto-generated ID. Allow any
|
|
# characters after wrapper.
|
|
(
|
|
'"/container/{}" class="action-button xblock-view-action-button">'
|
|
'\\s*<span class="action-button-text">View</span>'
|
|
).format(re.escape(str(wrapper_usage_key))),
|
|
)
|
|
|
|
@patch("cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers.get_object_tag_counts")
|
|
def test_tag_count_in_container_fragment(self, mock_get_object_tag_counts):
|
|
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",
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
usage_key = self.response_usage_key(resp)
|
|
|
|
# Get the preview HTML with tags
|
|
mock_get_object_tag_counts.return_value = {
|
|
str(usage_key): 13,
|
|
}
|
|
html, __ = self._get_container_preview(root_usage_key)
|
|
self.assertIn("wrapper-xblock", html)
|
|
self.assertIn('data-testid="tag-count-button"', html)
|
|
|
|
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
|
|
)
|
|
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",
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
resp = self.create_xblock(
|
|
parent_usage_key=split_test_usage_key,
|
|
category="html",
|
|
boilerplate="latex_html.yaml",
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
html, __ = self._get_container_preview(split_test_usage_key)
|
|
self.assertIn("Announcement", html)
|
|
self.assertIn("LaTeX", 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.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
|
|
)
|
|
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)}},
|
|
)
|
|
html, __ = self._get_container_preview(split_test_usage_key)
|
|
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},
|
|
],
|
|
}
|
|
|
|
response = self.client.put(
|
|
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",
|
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
|
)
|
|
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)
|
|
|
|
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:
|
|
retval = Mock()
|
|
type(retval).content = PropertyMock(return_value="Some content")
|
|
type(retval).resources = PropertyMock(return_value=[])
|
|
patched_get_preview_fragment.return_value = retval
|
|
|
|
root_usage_key = self._create_vertical()
|
|
_, _ = self._get_container_preview(
|
|
root_usage_key,
|
|
{"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"])
|
|
|
|
@ddt.data([1, "invalid"], ["invalid", 2])
|
|
@ddt.unpack
|
|
def test_invalid_paging(self, page_number, page_size):
|
|
"""
|
|
Tests that valid paging is passed along to underlying block
|
|
"""
|
|
root_usage_key = self._create_vertical()
|
|
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",
|
|
)
|
|
|
|
def test_get_user_partitions_and_groups(self):
|
|
# Note about UserPartition and UserPartition Group IDs: these must not conflict with IDs used
|
|
# by dynamic user partitions.
|
|
self.course.user_partitions = [
|
|
UserPartition(
|
|
id=MINIMUM_UNUSED_PARTITION_ID,
|
|
name="Random user partition",
|
|
scheme=UserPartition.get_scheme("random"),
|
|
description="Random user partition",
|
|
groups=[
|
|
Group(
|
|
id=MINIMUM_UNUSED_PARTITION_ID + 1, name="Group A"
|
|
), # See note above.
|
|
Group(
|
|
id=MINIMUM_UNUSED_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")
|
|
usage_key = self.response_usage_key(resp)
|
|
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_UNUSED_PARTITION_ID,
|
|
"name": "Random user partition",
|
|
"scheme": "random",
|
|
"groups": [
|
|
{
|
|
"id": MINIMUM_UNUSED_PARTITION_ID + 1,
|
|
"name": "Group A",
|
|
"selected": False,
|
|
"deleted": False,
|
|
},
|
|
{
|
|
"id": MINIMUM_UNUSED_PARTITION_ID + 2,
|
|
"name": "Group B",
|
|
"selected": False,
|
|
"deleted": False,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
)
|
|
self.assertEqual(result["group_access"], {})
|
|
|
|
@ddt.data("ancestorInfo", "")
|
|
def test_ancestor_info(self, field_type):
|
|
"""
|
|
Test that we get correct ancestor info.
|
|
|
|
Arguments:
|
|
field_type (string): If field_type=ancestorInfo, fetch ancestor info of the XBlock otherwise not.
|
|
"""
|
|
|
|
# Create a parent 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",
|
|
)
|
|
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",
|
|
)
|
|
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"
|
|
)
|
|
print(problem1)
|
|
problem_usage_key = self.response_usage_key(problem1)
|
|
|
|
def assert_xblock_info(xblock, xblock_info):
|
|
"""
|
|
Assert we have correct xblock info.
|
|
|
|
Arguments:
|
|
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,
|
|
):
|
|
xblock = self.get_item_from_modulestore(usage_key)
|
|
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"]:
|
|
parent_xblock = xblock.get_parent()
|
|
assert_xblock_info(parent_xblock, ancestor_info)
|
|
xblock = parent_xblock
|
|
else:
|
|
self.assertNotIn("ancestors", response)
|
|
xblock_info = get_block_info(xblock)
|
|
self.assertEqual(xblock_info, response)
|
|
|
|
|
|
@ddt.ddt
|
|
class DeleteItem(ItemTest):
|
|
"""Tests for '/xblock' DELETE url."""
|
|
|
|
def test_delete_static_page(self):
|
|
course = CourseFactory.create()
|
|
# Add static tab
|
|
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))
|
|
self.assertEqual(resp.status_code, 204)
|
|
|
|
|
|
class TestCreateItem(ItemTest):
|
|
"""
|
|
Test the create_item handler thoroughly
|
|
"""
|
|
|
|
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")
|
|
|
|
# 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.display_name, display_name)
|
|
self.assertEqual(new_obj.location.org, self.course.location.org)
|
|
self.assertEqual(new_obj.location.course, self.course.location.course)
|
|
|
|
# get the course and ensure it now points to this one
|
|
course = self.get_item_from_modulestore(self.usage_key)
|
|
self.assertIn(chap_usage_key, course.children)
|
|
|
|
# use default display name
|
|
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"
|
|
resp = self.create_xblock(
|
|
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)
|
|
# check against the template
|
|
course = CourseFactory.create()
|
|
problem_block = BlockFactory.create(category="problem", parent_location=course.location)
|
|
template = problem_block.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"])
|
|
|
|
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"
|
|
)
|
|
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")
|
|
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))
|
|
|
|
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")
|
|
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")
|
|
|
|
|
|
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."""
|
|
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",
|
|
)
|
|
return usage_key
|
|
|
|
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
|
|
known things that may differ).
|
|
"""
|
|
# pylint: disable=no-member
|
|
original_item = self.get_item_from_modulestore(source_usage_key)
|
|
duplicated_item = self.get_item_from_modulestore(duplicate_usage_key)
|
|
|
|
if check_asides:
|
|
original_asides = original_item.runtime.get_asides(original_item)
|
|
duplicated_asides = duplicated_item.runtime.get_asides(duplicated_item)
|
|
self.assertEqual(len(original_asides), 1)
|
|
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(
|
|
str(original_item.location),
|
|
str(duplicated_item.location),
|
|
"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",
|
|
)
|
|
else:
|
|
self.assertNotEqual(
|
|
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
|
|
# duplicate is equal.
|
|
duplicated_item.location = original_item.location
|
|
duplicated_item.parent = original_item.parent
|
|
|
|
# Children will also be duplicated, so for the purposes of testing equality, we will set
|
|
# the children to the original after recursively checking the children.
|
|
if original_item.has_children:
|
|
self.assertEqual(
|
|
len(original_item.children),
|
|
len(duplicated_item.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,
|
|
):
|
|
return False
|
|
duplicated_item.children = original_item.children
|
|
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
|
|
):
|
|
"""
|
|
Verifies display name of duplicated item.
|
|
"""
|
|
if is_child:
|
|
if original_item.display_name is None:
|
|
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.category
|
|
)
|
|
|
|
def _duplicate_item(self, parent_usage_key, source_usage_key, display_name=None):
|
|
"""
|
|
Duplicates the source.
|
|
"""
|
|
# pylint: disable=no-member
|
|
data = {
|
|
"parent_locator": str(parent_usage_key),
|
|
"duplicate_source_locator": str(source_usage_key),
|
|
}
|
|
if display_name is not None:
|
|
data["display_name"] = display_name
|
|
|
|
resp = self.client.ajax_post(reverse("xblock_handler"), json.dumps(data))
|
|
return self.response_usage_key(resp)
|
|
|
|
|
|
class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
|
|
"""
|
|
Test the duplicate method.
|
|
"""
|
|
|
|
ENABLED_OPENEDX_EVENTS = [
|
|
"org.openedx.content_authoring.xblock.duplicated.v1",
|
|
]
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
"""
|
|
Set up class method for the Test class.
|
|
This method starts manually events isolation. Explanation here:
|
|
openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44
|
|
"""
|
|
super().setUpClass()
|
|
cls.start_events_isolation()
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
""" Don't let our event isolation affect other test cases """
|
|
super().tearDownClass()
|
|
cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated.
|
|
|
|
def setUp(self):
|
|
"""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")
|
|
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"
|
|
)
|
|
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"
|
|
)
|
|
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",
|
|
)
|
|
self.problem_usage_key = self.response_usage_key(resp)
|
|
|
|
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='sequential')
|
|
|
|
def test_duplicate_equality(self):
|
|
"""
|
|
Tests that a duplicated xblock is identical to the original,
|
|
except for location and display name.
|
|
"""
|
|
self._duplicate_and_verify(self.problem_usage_key, self.vert_usage_key)
|
|
self._duplicate_and_verify(self.html_usage_key, self.vert_usage_key)
|
|
self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key)
|
|
self._duplicate_and_verify(self.seq_usage_key, self.chapter_usage_key)
|
|
self._duplicate_and_verify(self.chapter_usage_key, self.usage_key)
|
|
|
|
def test_duplicate_event(self):
|
|
"""
|
|
Check that XBLOCK_DUPLICATED event is sent when xblock is duplicated.
|
|
"""
|
|
event_receiver = Mock()
|
|
XBLOCK_DUPLICATED.connect(event_receiver)
|
|
usage_key = self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key)
|
|
event_receiver.assert_called()
|
|
assert_dict_contains_subset(
|
|
self,
|
|
{
|
|
"signal": XBLOCK_DUPLICATED,
|
|
"sender": None,
|
|
"xblock_info": DuplicatedXBlockData(
|
|
usage_key=usage_key,
|
|
block_type=usage_key.block_type,
|
|
source_usage_key=self.vert_usage_key,
|
|
),
|
|
},
|
|
event_receiver.call_args.kwargs,
|
|
)
|
|
|
|
def test_ordering(self):
|
|
"""
|
|
Tests the a duplicated xblock appears immediately after its source
|
|
(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.assertEqual(
|
|
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",
|
|
)
|
|
self.assertEqual(
|
|
children[source_position + 1],
|
|
usage_key,
|
|
"duplicated item not ordered after source item",
|
|
)
|
|
|
|
verify_order(self.problem_usage_key, self.vert_usage_key, 0)
|
|
# 2 because duplicate of problem should be located before.
|
|
verify_order(self.html_usage_key, self.vert_usage_key, 2)
|
|
verify_order(self.vert_usage_key, self.seq_usage_key, 0)
|
|
verify_order(self.seq_usage_key, self.chapter_usage_key, 0)
|
|
|
|
# Test duplicating something into a location that is not the parent of the original item.
|
|
# Duplicated item should appear at the end.
|
|
verify_order(self.html_usage_key, self.usage_key)
|
|
|
|
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
|
|
)
|
|
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'",
|
|
)
|
|
# Test dupe of dupe.
|
|
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"
|
|
)
|
|
|
|
# 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",
|
|
)
|
|
|
|
def test_shallow_duplicate(self):
|
|
"""
|
|
Test that duplicate_block(..., shallow=True) can duplicate a block but ignores its children.
|
|
"""
|
|
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")
|
|
# Refresh.
|
|
source_chapter = self.store.get_item(source_chapter.location)
|
|
self.assertEqual(len(source_chapter.get_children()), 1)
|
|
destination_course = CourseFactory()
|
|
destination_location = duplicate_block(
|
|
parent_usage_key=destination_course.location,
|
|
duplicate_source_usage_key=source_chapter.location,
|
|
user=user,
|
|
display_name=source_chapter.display_name,
|
|
shallow=True,
|
|
)
|
|
# 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")
|
|
|
|
def test_duplicate_library_content_block(self): # pylint: disable=too-many-statements
|
|
"""
|
|
Test the LegacyLibraryContentBlock's special duplication process.
|
|
"""
|
|
store = modulestore()
|
|
|
|
# Create a library with two blocks (HTML 1 and HTML 2).
|
|
# These are the "Original" version of the blocks.
|
|
lib = LibraryFactory()
|
|
BlockFactory(
|
|
parent=lib,
|
|
category="html",
|
|
display_name="HTML 1 Title (Original)",
|
|
data="HTML 1 Content (Original)",
|
|
publish_item=False,
|
|
)
|
|
BlockFactory(
|
|
parent=lib,
|
|
category="html",
|
|
display_name="HTML 2 Title (Original)",
|
|
data="HTML 2 Content (Original)",
|
|
publish_item=False,
|
|
)
|
|
original_lib_version = store.get_library(
|
|
lib.location.library_key, remove_version=False, remove_branch=False,
|
|
).location.library_key.version_guid
|
|
assert original_lib_version is not None
|
|
|
|
# Create a library content block (lc), point it out our library, and sync it.
|
|
unit = BlockFactory(
|
|
parent_location=self.seq_usage_key,
|
|
category="vertical",
|
|
display_name="Parent Unit of LC and its Dupe",
|
|
publish_item=False,
|
|
)
|
|
lc = BlockFactory(
|
|
parent=unit,
|
|
category="library_content",
|
|
source_library_id=str(lib.location.library_key),
|
|
display_name="LC Block",
|
|
max_count=1,
|
|
publish_item=False,
|
|
)
|
|
lc.sync_from_library()
|
|
lc = store.get_item(lc.location) # we must reload because sync_from_library happens out-of-thread
|
|
assert lc.source_library_version == str(original_lib_version)
|
|
lc_html_1 = store.get_item(lc.children[0])
|
|
lc_html_2 = store.get_item(lc.children[1])
|
|
assert lc_html_1.display_name == "HTML 1 Title (Original)"
|
|
assert lc_html_2.display_name == "HTML 2 Title (Original)"
|
|
assert lc_html_1.data == "HTML 1 Content (Original)"
|
|
assert lc_html_2.data == "HTML 2 Content (Original)"
|
|
|
|
# Override the title and data of HTML 1 under lc ("Course Override").
|
|
# Note that title is settings-scoped and data is content-scoped.
|
|
lc_html_1.display_name = "HTML 1 Title (Course Override)"
|
|
lc_html_1.data = "HTML 1 Content (Course Override)"
|
|
store.update_item(lc_html_1, self.user.id)
|
|
|
|
# Now, update the titles and contents of both HTML 1 and HTML 2 ("Lib Update").
|
|
# This will yield a new version of the library (updated_lib_version).
|
|
lib_html_1 = store.get_item(lib.children[0])
|
|
lib_html_2 = store.get_item(lib.children[1])
|
|
assert lib_html_1.display_name == "HTML 1 Title (Original)"
|
|
assert lib_html_2.display_name == "HTML 2 Title (Original)"
|
|
lib_html_1.display_name = "HTML 1 Title (Lib Update)"
|
|
lib_html_2.display_name = "HTML 2 Title (Lib Update)"
|
|
lib_html_1.data = "HTML 1 Content (Lib Update)"
|
|
lib_html_2.data = "HTML 2 Content (Lib Update)"
|
|
store.update_item(lib_html_1, self.user.id)
|
|
store.update_item(lib_html_2, self.user.id)
|
|
updated_lib_version = store.get_library(
|
|
lib.location.library_key, remove_version=False, remove_branch=False,
|
|
).location.library_key.version_guid
|
|
assert updated_lib_version is not None
|
|
assert updated_lib_version != original_lib_version
|
|
|
|
# DUPLICATE lc.
|
|
# Unit should now contain both lc and dupe.
|
|
# All settings should match between lc and dupe.
|
|
dupe = store.get_item(
|
|
self._duplicate_item(
|
|
parent_usage_key=unit.location,
|
|
source_usage_key=lc.location,
|
|
display_name="Dupe LC Block",
|
|
)
|
|
)
|
|
lc = store.get_item(lc.location)
|
|
unit = store.get_item(unit.location)
|
|
assert unit.children == [lc.location, dupe.location]
|
|
assert len(lc.children) == len(dupe.children) == 2
|
|
assert lc.max_count == dupe.max_count == 1
|
|
assert lc.source_library_id == dupe.source_library_id == str(lib.location.library_key)
|
|
assert lc.source_library_version == dupe.source_library_version == str(original_lib_version)
|
|
|
|
# The lc block's children should remain unchanged.
|
|
# That is: HTML 1 has overrides, HTML 2 has originals.
|
|
lc_html_1 = store.get_item(lc.children[0])
|
|
assert lc_html_1.display_name == "HTML 1 Title (Course Override)"
|
|
assert lc_html_1.data == "HTML 1 Content (Course Override)"
|
|
lc_html_2 = store.get_item(lc.children[1])
|
|
assert lc_html_2.display_name == "HTML 2 Title (Original)"
|
|
assert lc_html_2.data == "HTML 2 Content (Original)"
|
|
|
|
# Now, the dupe's children should copy *settings* overrides over from the lc block,
|
|
# but we don't actually expect it to copy *content* overrides over from the lc block.
|
|
# (Yes, this is weird. One would expect it to copy all fields from the lc block, whether settings or content.
|
|
# But that's the existing behavior, so we're going to test for it, for now at least.
|
|
# We may change this in the future: https://github.com/openedx/edx-platform/issues/33739)
|
|
dupe_html_1 = store.get_item(dupe.children[0])
|
|
assert dupe_html_1.display_name == "HTML 1 Title (Course Override)" # <- as you'd expect
|
|
assert dupe_html_1.data == "HTML 1 Content (Original)" # <- weird!
|
|
dupe_html_2 = store.get_item(dupe.children[1])
|
|
assert dupe_html_2.display_name == "HTML 2 Title (Original)" # <- as you'd expect
|
|
assert dupe_html_2.data == "HTML 2 Content (Original)" # <- as you'd expect
|
|
|
|
# Finally, upgrade the dupe's library version, and make sure it pulls in updated library block *content*,
|
|
# whilst preserving *settings overrides* (specifically, HTML 1's title override).
|
|
dupe.sync_from_library(upgrade_to_latest=True)
|
|
dupe = store.get_item(dupe.location)
|
|
assert dupe.source_library_version == str(updated_lib_version)
|
|
assert len(dupe.children) == 2
|
|
dupe_html_1 = store.get_item(dupe.children[0])
|
|
dupe_html_2 = store.get_item(dupe.children[1])
|
|
assert dupe_html_1.display_name == "HTML 1 Title (Course Override)"
|
|
assert dupe_html_1.data == "HTML 1 Content (Lib Update)"
|
|
assert dupe_html_2.display_name == "HTML 2 Title (Lib Update)"
|
|
assert dupe_html_2.data == "HTML 2 Content (Lib Update)"
|
|
|
|
def test_duplicate_tags(self):
|
|
"""
|
|
Test that duplicating a tagged XBlock also duplicates its content tags.
|
|
"""
|
|
source_course = CourseFactory()
|
|
user = UserFactory.create()
|
|
source_chapter = BlockFactory(
|
|
parent=source_course, category="chapter", display_name="Source Chapter"
|
|
)
|
|
source_block = BlockFactory(parent=source_chapter, category="html", display_name="Child")
|
|
|
|
# Create a couple of taxonomies with tags
|
|
taxonomyA = tagging_api.create_taxonomy(name="A", export_id="A")
|
|
taxonomyB = tagging_api.create_taxonomy(name="B", export_id="B")
|
|
tagging_api.set_taxonomy_orgs(taxonomyA, all_orgs=True)
|
|
tagging_api.set_taxonomy_orgs(taxonomyB, all_orgs=True)
|
|
tagging_api.add_tag_to_taxonomy(taxonomyA, "one")
|
|
tagging_api.add_tag_to_taxonomy(taxonomyA, "two")
|
|
tagging_api.add_tag_to_taxonomy(taxonomyB, "three")
|
|
tagging_api.add_tag_to_taxonomy(taxonomyB, "four")
|
|
|
|
# Tag the chapter
|
|
tagging_api.tag_object(str(source_chapter.location), taxonomyA, ["one", "two"])
|
|
tagging_api.tag_object(str(source_chapter.location), taxonomyB, ["three", "four"])
|
|
|
|
# Tag the child block
|
|
tagging_api.tag_object(str(source_block.location), taxonomyA, ["two"],)
|
|
|
|
# Duplicate the chapter (and its children)
|
|
dupe_location = duplicate_block(
|
|
parent_usage_key=source_course.location,
|
|
duplicate_source_usage_key=source_chapter.location,
|
|
user=user,
|
|
)
|
|
dupe_chapter = self.store.get_item(dupe_location)
|
|
self.assertEqual(len(dupe_chapter.get_children()), 1)
|
|
dupe_block = dupe_chapter.get_children()[0]
|
|
|
|
# Check that the duplicated blocks also duplicated tags
|
|
expected_chapter_tags = [
|
|
f'<ObjectTag> {str(dupe_chapter.location)}: A=one',
|
|
f'<ObjectTag> {str(dupe_chapter.location)}: A=two',
|
|
f'<ObjectTag> {str(dupe_chapter.location)}: B=four',
|
|
f'<ObjectTag> {str(dupe_chapter.location)}: B=three',
|
|
]
|
|
dupe_chapter_tags = [str(object_tag) for object_tag in tagging_api.get_object_tags(str(dupe_chapter.location))]
|
|
assert dupe_chapter_tags == expected_chapter_tags
|
|
expected_block_tags = [
|
|
f'<ObjectTag> {str(dupe_block.location)}: A=two',
|
|
]
|
|
dupe_block_tags = [str(object_tag) for object_tag in tagging_api.get_object_tags(str(dupe_block.location))]
|
|
assert dupe_block_tags == expected_block_tags
|
|
|
|
|
|
@ddt.ddt
|
|
class TestMoveItem(ItemTest):
|
|
"""
|
|
Tests for move item.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Creates the test course structure to build course outline tree.
|
|
"""
|
|
super().setUp()
|
|
self.setup_course()
|
|
|
|
def setup_course(self, default_store=None):
|
|
"""
|
|
Helper method to create the course.
|
|
"""
|
|
if not default_store:
|
|
default_store = self.store.default_modulestore.get_modulestore_type()
|
|
|
|
course = CourseFactory.create(default_store=default_store)
|
|
|
|
# Create group configurations
|
|
course.user_partitions = [
|
|
UserPartition(
|
|
0,
|
|
"first_partition",
|
|
"Test Partition",
|
|
[Group("0", "alpha"), Group("1", "beta")],
|
|
)
|
|
]
|
|
self.store.update_item(course, self.user.id)
|
|
|
|
# Create a parent chapter
|
|
chap1 = self.create_xblock(
|
|
parent_usage_key=course.location,
|
|
display_name="chapter1",
|
|
category="chapter",
|
|
)
|
|
self.chapter_usage_key = self.response_usage_key(chap1)
|
|
|
|
chap2 = self.create_xblock(
|
|
parent_usage_key=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",
|
|
)
|
|
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",
|
|
)
|
|
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",
|
|
)
|
|
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",
|
|
)
|
|
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",
|
|
)
|
|
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"
|
|
)
|
|
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
|
|
)
|
|
self.split_test_usage_key = self.response_usage_key(resp)
|
|
|
|
self.course = self.store.get_item(course.location)
|
|
|
|
def setup_and_verify_content_experiment(self, partition_id):
|
|
"""
|
|
Helper method to set up group configurations to content experiment.
|
|
|
|
Arguments:
|
|
partition_id (int): User partition id.
|
|
"""
|
|
split_test = self.get_item_from_modulestore(self.split_test_usage_key)
|
|
|
|
# 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)
|
|
|
|
# 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)}},
|
|
)
|
|
split_test = self.get_item_from_modulestore(self.split_test_usage_key)
|
|
self.assertEqual(split_test.user_partition_id, partition_id)
|
|
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):
|
|
"""
|
|
Helper method to send move request and returns the response.
|
|
|
|
Arguments:
|
|
source_usage_key (BlockUsageLocator): Locator of source item.
|
|
target_usage_key (BlockUsageLocator): Locator of target parent.
|
|
target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
|
|
|
|
Returns:
|
|
resp (JsonResponse): Response after the move operation is complete.
|
|
"""
|
|
data = {
|
|
"move_source_locator": str(source_usage_key),
|
|
"parent_locator": str(target_usage_key),
|
|
}
|
|
if target_index is not None:
|
|
data["target_index"] = target_index
|
|
|
|
return self.client.patch(
|
|
reverse("xblock_handler"), json.dumps(data), content_type="application/json"
|
|
)
|
|
|
|
def assert_move_item(self, source_usage_key, target_usage_key, target_index=None):
|
|
"""
|
|
Assert move component.
|
|
|
|
Arguments:
|
|
source_usage_key (BlockUsageLocator): Locator of source item.
|
|
target_usage_key (BlockUsageLocator): Locator of target parent.
|
|
target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
|
|
"""
|
|
parent_loc = self.store.get_parent_location(source_usage_key)
|
|
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
|
|
)
|
|
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)
|
|
|
|
# Verify parent referance has been changed now.
|
|
new_parent_loc = self.store.get_parent_location(source_usage_key)
|
|
source_item = self.get_item_from_modulestore(source_usage_key)
|
|
self.assertEqual(source_item.parent, new_parent_loc)
|
|
self.assertEqual(new_parent_loc, target_usage_key)
|
|
self.assertNotEqual(parent_loc, new_parent_loc)
|
|
|
|
# Assert item is present in children list of target parent and not source parent
|
|
target_parent = self.get_item_from_modulestore(target_usage_key)
|
|
source_parent = self.get_item_from_modulestore(parent_loc)
|
|
self.assertIn(source_usage_key, target_parent.children)
|
|
self.assertNotIn(source_usage_key, source_parent.children)
|
|
|
|
def test_move_component(self):
|
|
"""
|
|
Test move component with different xblock types.
|
|
"""
|
|
self.setup_course()
|
|
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.assert_move_item(source_usage_key, target_usage_key)
|
|
|
|
def test_move_source_index(self):
|
|
"""
|
|
Test moving an item to a particular index.
|
|
"""
|
|
parent = self.get_item_from_modulestore(self.vert_usage_key)
|
|
children = parent.get_children()
|
|
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"
|
|
)
|
|
html2_usage_key = self.response_usage_key(resp)
|
|
|
|
# Move html2_usage_key inside vert_usage_key at second position.
|
|
self.assert_move_item(html2_usage_key, self.vert_usage_key, 1)
|
|
parent = self.get_item_from_modulestore(self.vert_usage_key)
|
|
children = parent.get_children()
|
|
self.assertEqual(len(children), 4)
|
|
self.assertEqual(children[1].location, html2_usage_key)
|
|
|
|
def test_move_undo(self):
|
|
"""
|
|
Test move a component and move it back (undo).
|
|
"""
|
|
# Get the initial index of the component
|
|
parent = self.get_item_from_modulestore(self.vert_usage_key)
|
|
original_index = _get_source_index(self.html_usage_key, parent)
|
|
|
|
# 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"])
|
|
|
|
# Verify that new parent has the moved component at the last index.
|
|
parent = self.get_item_from_modulestore(self.vert2_usage_key)
|
|
self.assertEqual(self.html_usage_key, parent.children[-1])
|
|
|
|
# Verify original and new index is different now.
|
|
source_index = _get_source_index(self.html_usage_key, parent)
|
|
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"])
|
|
|
|
def test_move_large_target_index(self):
|
|
"""
|
|
Test moving an item at a large index would generate an error message.
|
|
"""
|
|
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
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
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
|
|
)
|
|
)
|
|
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)
|
|
|
|
def test_invalid_move(self):
|
|
"""
|
|
Test invalid move.
|
|
"""
|
|
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"))
|
|
|
|
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,
|
|
)
|
|
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)
|
|
|
|
def test_move_current_parent(self):
|
|
"""
|
|
Test that a component can not be moved to it's current parent.
|
|
"""
|
|
parent_loc = self.store.get_parent_location(self.html_usage_key)
|
|
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"))
|
|
|
|
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",
|
|
)
|
|
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
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
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
|
|
)
|
|
|
|
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",
|
|
)
|
|
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)
|
|
self.assert_move_item(library_content_usage_key, self.vert2_usage_key)
|
|
|
|
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",
|
|
)
|
|
library_content_usage_key = self.response_usage_key(library_content)
|
|
self.assert_move_item(self.html_usage_key, library_content_usage_key)
|
|
|
|
def test_move_content_experiment(self):
|
|
"""
|
|
Test that a content experiment can be moved.
|
|
"""
|
|
self.setup_and_verify_content_experiment(0)
|
|
|
|
# Move content experiment
|
|
self.assert_move_item(self.split_test_usage_key, self.vert2_usage_key)
|
|
|
|
def test_move_content_experiment_components(self):
|
|
"""
|
|
Test that component inside content experiment can be moved to any other valid location.
|
|
"""
|
|
split_test = self.setup_and_verify_content_experiment(0)
|
|
|
|
# Add html component to Group A.
|
|
html1 = self.create_xblock(
|
|
parent_usage_key=split_test.children[0],
|
|
display_name="html1",
|
|
category="html",
|
|
)
|
|
html_usage_key = self.response_usage_key(html1)
|
|
|
|
# Move content experiment
|
|
self.assert_move_item(html_usage_key, self.vert2_usage_key)
|
|
|
|
def test_move_into_content_experiment_groups(self):
|
|
"""
|
|
Test that a component can be moved to content experiment groups.
|
|
"""
|
|
split_test = self.setup_and_verify_content_experiment(0)
|
|
self.assert_move_item(self.html_usage_key, split_test.children[0])
|
|
|
|
def test_can_not_move_into_content_experiment_level(self):
|
|
"""
|
|
Test that a component can not be moved directly to content experiment level.
|
|
"""
|
|
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"))
|
|
|
|
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):
|
|
"""
|
|
Test that a content experiment can not be moved inside any of it's children.
|
|
"""
|
|
split_test = self.setup_and_verify_content_experiment(0)
|
|
|
|
# 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
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
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,
|
|
)
|
|
|
|
# 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]
|
|
)
|
|
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)}},
|
|
)
|
|
child_split_test = self.get_item_from_modulestore(self.split_test_usage_key)
|
|
|
|
# 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]
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
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,
|
|
)
|
|
|
|
def test_move_invalid_source_index(self):
|
|
"""
|
|
Test moving an item to an invalid 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
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
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)
|
|
new_parent_loc = self.store.get_parent_location(self.html_usage_key)
|
|
self.assertEqual(new_parent_loc, parent_loc)
|
|
|
|
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)}
|
|
with self.assertRaises(InvalidKeyError):
|
|
self.client.patch(
|
|
reverse("xblock_handler"),
|
|
json.dumps(data),
|
|
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"))
|
|
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.",
|
|
)
|
|
|
|
def _verify_validation_message(
|
|
self, message, expected_message, expected_message_type
|
|
):
|
|
"""
|
|
Verify that the validation message has the expected validation message and type.
|
|
"""
|
|
self.assertEqual(message.text, expected_message)
|
|
self.assertEqual(message.type, expected_message_type)
|
|
|
|
def test_move_component_nonsensical_access_restriction_validation(self):
|
|
"""
|
|
Test that moving a component with non-contradicting access
|
|
restrictions into a unit that has contradicting access
|
|
restrictions brings up the nonsensical access validation
|
|
message and that the message does not show up when moved
|
|
into a unit where the component's access settings do not
|
|
contradict the unit's access settings.
|
|
"""
|
|
group1 = self.course.user_partitions[0].groups[0]
|
|
group2 = self.course.user_partitions[0].groups[1]
|
|
vert1 = self.store.get_item(self.vert_usage_key)
|
|
vert2 = self.store.get_item(self.vert2_usage_key)
|
|
html = self.store.get_item(self.html_usage_key)
|
|
|
|
# Inject mock partition service as obtaining the course from the draft modulestore
|
|
# (which is the default for these tests) does not work.
|
|
partitions_service = MockPartitionService(
|
|
self.course,
|
|
course_id=self.course.id,
|
|
)
|
|
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
|
|
vert1.group_access = {self.course.user_partitions[0].id: [group2.id]}
|
|
vert2.group_access = {self.course.user_partitions[0].id: [group1.id]}
|
|
html.group_access = {self.course.user_partitions[0].id: [group2.id]}
|
|
vert1 = self.store.update_item(vert1, self.user.id)
|
|
vert2 = self.store.update_item(vert2, self.user.id)
|
|
html = self.store.update_item(html, self.user.id)
|
|
|
|
# Verify that there is no warning when html is in a non contradicting unit
|
|
validation = html.validate()
|
|
self.assertEqual(len(validation.messages), 0)
|
|
|
|
# Now move it and confirm that the html component has been moved into vertical 2
|
|
self.assert_move_item(self.html_usage_key, self.vert2_usage_key)
|
|
html.parent = self.vert2_usage_key
|
|
html = self.store.update_item(html, self.user.id)
|
|
validation = html.validate()
|
|
self.assertEqual(len(validation.messages), 1)
|
|
self._verify_validation_message(
|
|
validation.messages[0],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Move the html component back and confirm that the warning is gone again
|
|
self.assert_move_item(self.html_usage_key, self.vert_usage_key)
|
|
html.parent = self.vert_usage_key
|
|
html = self.store.update_item(html, self.user.id)
|
|
validation = html.validate()
|
|
self.assertEqual(len(validation.messages), 0)
|
|
|
|
@patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.log")
|
|
def test_move_logging(self, mock_logger):
|
|
"""
|
|
Test logging when an item is successfully moved.
|
|
|
|
Arguments:
|
|
mock_logger (object): A mock logger object.
|
|
"""
|
|
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",
|
|
str(self.html_usage_key),
|
|
str(self.vert_usage_key),
|
|
str(self.vert2_usage_key),
|
|
insert_at,
|
|
)
|
|
|
|
def test_move_and_discard_changes(self):
|
|
"""
|
|
Verifies that discard changes operation brings moved component back to source location and removes the component
|
|
from target location.
|
|
"""
|
|
self.setup_course()
|
|
|
|
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
|
|
)
|
|
)
|
|
|
|
# Publish old_parent_loc unit
|
|
self.client.ajax_post(
|
|
reverse_usage_url("xblock_handler", old_parent_loc),
|
|
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.assertFalse(self.store.has_changes(self.store.get_item(old_parent_loc)))
|
|
|
|
# Move component html_usage_key in vert2_usage_key
|
|
self.assert_move_item(self.html_usage_key, self.vert2_usage_key)
|
|
|
|
# Check old_parent_loc becomes in draft mode now.
|
|
self.assertTrue(self.store.has_changes(self.store.get_item(old_parent_loc)))
|
|
|
|
# Now discard changes in old_parent_loc
|
|
self.client.ajax_post(
|
|
reverse_usage_url("xblock_handler", old_parent_loc),
|
|
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.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
|
|
)
|
|
|
|
# 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)
|
|
source_parent = self.get_item_from_modulestore(old_parent_loc)
|
|
self.assertIn(self.html_usage_key, source_parent.children)
|
|
self.assertNotIn(self.html_usage_key, target_parent.children)
|
|
|
|
def test_move_item_not_found(self):
|
|
"""
|
|
Test that an item not found exception raised when an item is not found when getting the item.
|
|
"""
|
|
self.setup_course()
|
|
|
|
data = {
|
|
"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"),
|
|
json.dumps(data),
|
|
content_type="application/json",
|
|
)
|
|
|
|
|
|
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'."""
|
|
super().setUp()
|
|
# Create a parent 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"
|
|
)
|
|
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",
|
|
)
|
|
self.problem_usage_key = self.response_usage_key(resp)
|
|
|
|
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.runtime.SplitModuleStoreRuntime.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
|
|
"""
|
|
item = self.get_item_from_modulestore(usage_key)
|
|
|
|
key_store = DictKeyValueStore()
|
|
field_data = KvsFieldData(key_store)
|
|
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
|
|
|
|
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")
|
|
|
|
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):
|
|
"""
|
|
Setup for xblock update tests.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""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")
|
|
chap_usage_key = self.response_usage_key(resp)
|
|
|
|
# create 2 sequentials
|
|
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"
|
|
)
|
|
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,
|
|
)
|
|
self.problem_usage_key = self.response_usage_key(resp)
|
|
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)
|
|
|
|
|
|
@ddt.ddt
|
|
class TestEditItem(TestEditItemSetup):
|
|
"""
|
|
Test xblock update.
|
|
"""
|
|
|
|
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"}}
|
|
)
|
|
problem = self.get_item_from_modulestore(self.problem_usage_key)
|
|
self.assertEqual(problem.rerandomize, 'onreset')
|
|
self.client.ajax_post(
|
|
self.problem_update_url, data={"metadata": {"rerandomize": None}}
|
|
)
|
|
problem = self.get_item_from_modulestore(self.problem_usage_key)
|
|
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)
|
|
self.assertIsNotNone(problem.markdown)
|
|
self.client.ajax_post(
|
|
self.problem_update_url,
|
|
data={'nullout': ['markdown']}
|
|
)
|
|
problem = self.get_item_from_modulestore(self.problem_usage_key)
|
|
self.assertIsNone(problem.markdown)
|
|
|
|
def test_date_fields(self):
|
|
"""
|
|
Test setting due & start dates on sequential
|
|
"""
|
|
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"}}
|
|
)
|
|
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"}}
|
|
)
|
|
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",
|
|
)
|
|
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}})
|
|
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,
|
|
)
|
|
# Both display and actual value should be None
|
|
self.assertEqual(xblock_info["due_date"], "")
|
|
self.assertIsNone(xblock_info["due"])
|
|
|
|
def test_update_generic_fields(self):
|
|
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,
|
|
}
|
|
},
|
|
)
|
|
problem = self.get_item_from_modulestore(self.problem_usage_key)
|
|
self.assertEqual(problem.display_name, new_display_name)
|
|
self.assertEqual(problem.max_attempts, new_max_attempts)
|
|
|
|
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")
|
|
chapter1_usage_key = self.response_usage_key(resp_1)
|
|
chapter2_usage_key = self.response_usage_key(resp_2)
|
|
|
|
course = self.get_item_from_modulestore(self.usage_key)
|
|
self.assertIn(chapter1_usage_key, course.children)
|
|
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)
|
|
)
|
|
self.assertEqual(resp.status_code, 204)
|
|
|
|
# Verify that the child is removed.
|
|
course = self.get_item_from_modulestore(self.usage_key)
|
|
self.assertNotIn(chapter1_usage_key, course.children)
|
|
self.assertIn(chapter2_usage_key, course.children)
|
|
|
|
def test_reorder_children(self):
|
|
"""
|
|
Test reordering children that can be in the draft store.
|
|
"""
|
|
# 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"
|
|
)
|
|
unit1_usage_key = self.response_usage_key(unit_1_resp)
|
|
unit2_usage_key = self.response_usage_key(unit_2_resp)
|
|
|
|
# The sequential already has a child defined in the setUp (a problem).
|
|
# Children must be on the sequential to reproduce the original bug,
|
|
# as it is important that the parent (sequential) NOT be in the draft store.
|
|
children = self.get_item_from_modulestore(self.seq_usage_key).children
|
|
self.assertEqual(unit1_usage_key, children[1])
|
|
self.assertEqual(unit2_usage_key, children[2])
|
|
|
|
resp = self.client.ajax_post(
|
|
self.seq_update_url,
|
|
data={
|
|
"children": [
|
|
str(self.problem_usage_key),
|
|
str(unit2_usage_key),
|
|
str(unit1_usage_key),
|
|
]
|
|
},
|
|
)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
children = self.get_item_from_modulestore(self.seq_usage_key).children
|
|
self.assertEqual(self.problem_usage_key, children[0])
|
|
self.assertEqual(unit1_usage_key, children[2])
|
|
self.assertEqual(unit2_usage_key, children[1])
|
|
|
|
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",
|
|
)
|
|
)
|
|
unit_2_key = self.response_usage_key(
|
|
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.assertEqual(resp.status_code, 200)
|
|
|
|
# verify children
|
|
self.assertListEqual(
|
|
self.get_item_from_modulestore(self.seq2_usage_key).children,
|
|
[unit_1_key, unit_2_key],
|
|
)
|
|
self.assertListEqual(
|
|
self.get_item_from_modulestore(self.seq_usage_key).children,
|
|
[self.problem_usage_key], # problem child created in setUp
|
|
)
|
|
|
|
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
|
|
|
|
# adding orphaned unit 1 should return an error
|
|
resp = self.client.ajax_post(
|
|
self.seq2_update_url, data={"children": [str(unit_1_key)]}
|
|
)
|
|
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, []
|
|
)
|
|
|
|
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",
|
|
)
|
|
)
|
|
unit_2_key = self.response_usage_key(
|
|
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.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],
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
def _verify_published_with_no_draft(self, location):
|
|
"""
|
|
Verifies the item with given location has a published version and no draft (unpublished changes).
|
|
"""
|
|
self.assertTrue(self._is_location_published(location))
|
|
self.assertFalse(modulestore().has_changes(modulestore().get_item(location)))
|
|
|
|
def _verify_published_with_draft(self, location):
|
|
"""
|
|
Verifies the item with given location has a published version and also a draft version (unpublished changes).
|
|
"""
|
|
self.assertTrue(self._is_location_published(location))
|
|
self.assertTrue(modulestore().has_changes(modulestore().get_item(location)))
|
|
|
|
def test_make_public(self):
|
|
"""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._verify_published_with_no_draft(self.problem_usage_key)
|
|
|
|
def test_make_draft(self):
|
|
"""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"""
|
|
self._make_draft_content_different_from_published()
|
|
self.client.ajax_post(
|
|
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,
|
|
)
|
|
self.assertIsNone(published.due)
|
|
|
|
def test_republish(self):
|
|
"""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))
|
|
|
|
# Republishing when only in draft will update the draft but not cause a public item to be created.
|
|
self.client.ajax_post(
|
|
self.problem_update_url,
|
|
data={
|
|
"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)
|
|
self.assertEqual(draft.display_name, new_display_name)
|
|
|
|
# Publish the item
|
|
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"
|
|
self.client.ajax_post(
|
|
self.problem_update_url,
|
|
data={
|
|
"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,
|
|
)
|
|
self.assertEqual(published.display_name, new_display_name_2)
|
|
|
|
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",
|
|
)
|
|
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"}}
|
|
)
|
|
|
|
self._verify_published_with_draft(self.seq_usage_key)
|
|
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):
|
|
"""
|
|
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._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
|
|
|
|
# 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"}}
|
|
)
|
|
updated_draft = self.get_item_from_modulestore(self.problem_usage_key)
|
|
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
|
|
)
|
|
self.assertIsNone(published.due)
|
|
|
|
def test_make_public_with_update(self):
|
|
"""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"},
|
|
)
|
|
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"""
|
|
|
|
# Make problem 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,
|
|
)
|
|
|
|
# Now make a draft
|
|
self.client.ajax_post(
|
|
self.problem_update_url,
|
|
data={
|
|
"id": str(self.problem_usage_key),
|
|
"metadata": {},
|
|
"data": "<p>Problem content draft.</p>",
|
|
},
|
|
)
|
|
|
|
# Both published and draft content should be different
|
|
draft = self.get_item_from_modulestore(self.problem_usage_key)
|
|
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")
|
|
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")
|
|
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)
|
|
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
|
|
)
|
|
self.assertNotEqual(draft.data, published.data)
|
|
|
|
def test_publish_states_of_nested_xblocks(self):
|
|
"""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",
|
|
)
|
|
unit_usage_key = self.response_usage_key(resp)
|
|
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")
|
|
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)
|
|
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"})
|
|
self.assertEqual(resp.status_code, 200)
|
|
self._verify_published_with_no_draft(unit_usage_key)
|
|
self._verify_published_with_no_draft(html_usage_key)
|
|
|
|
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"
|
|
)
|
|
video_usage_key = self.response_usage_key(response)
|
|
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",
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
parsed = json.loads(response.content.decode("utf-8"))
|
|
self.assertIn("error", parsed)
|
|
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}
|
|
)
|
|
|
|
for __ in range(3):
|
|
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)
|
|
|
|
|
|
class TestEditSplitModule(ItemTest):
|
|
"""
|
|
Tests around editing instances of the split_test block.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.user = UserFactory()
|
|
|
|
self.first_user_partition_group_1 = Group(
|
|
str(MINIMUM_UNUSED_PARTITION_ID + 1), "alpha"
|
|
)
|
|
self.first_user_partition_group_2 = Group(
|
|
str(MINIMUM_UNUSED_PARTITION_ID + 2), "beta"
|
|
)
|
|
self.first_user_partition = UserPartition(
|
|
MINIMUM_UNUSED_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_UNUSED_PARTITION_ID + 1), "Group 1"
|
|
)
|
|
self.second_user_partition_group_2 = Group(
|
|
str(MINIMUM_UNUSED_PARTITION_ID + 2), "Group 2"
|
|
)
|
|
self.second_user_partition_group_3 = Group(
|
|
str(MINIMUM_UNUSED_PARTITION_ID + 3), "Group 3"
|
|
)
|
|
self.second_user_partition = UserPartition(
|
|
MINIMUM_UNUSED_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.course.user_partitions = [
|
|
self.first_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
|
|
)
|
|
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.request_factory = RequestFactory()
|
|
self.request = self.request_factory.get("/dummy-url")
|
|
self.request.user = self.user
|
|
|
|
def _update_partition_id(self, partition_id):
|
|
"""
|
|
Helper method that sets the user_partition_id to the supplied value.
|
|
|
|
The updated split_test instance is returned.
|
|
"""
|
|
self.client.ajax_post(
|
|
self.split_test_update_url,
|
|
# 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)}},
|
|
)
|
|
|
|
# Verify the partition_id was saved.
|
|
split_test = self.get_item_from_modulestore(self.split_test_usage_key)
|
|
self.assertEqual(partition_id, split_test.user_partition_id)
|
|
return split_test
|
|
|
|
def _assert_children(self, expected_number):
|
|
"""
|
|
Verifies the number of children of the split_test instance.
|
|
"""
|
|
split_test = self.get_item_from_modulestore(self.split_test_usage_key)
|
|
self.assertEqual(expected_number, len(split_test.children))
|
|
return split_test
|
|
|
|
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)
|
|
# 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))
|
|
|
|
# Set the user_partition_id to match the first user_partition.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
|
|
# 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])
|
|
vertical_1 = self.get_item_from_modulestore(split_test.children[1])
|
|
self.assertEqual("vertical", vertical_0.category)
|
|
self.assertEqual("vertical", vertical_1.category)
|
|
self.assertEqual(
|
|
"Group ID " + str(MINIMUM_UNUSED_PARTITION_ID + 1), vertical_0.display_name
|
|
)
|
|
self.assertEqual(
|
|
"Group ID " + str(MINIMUM_UNUSED_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)],
|
|
)
|
|
|
|
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)
|
|
# 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)
|
|
# Set the user_partition_id to match the first user_partition.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
# Verify that child verticals have been set to match the groups
|
|
self.assertEqual(len(split_test.children), 2)
|
|
|
|
# Get xblock outline
|
|
xblock_info = create_xblock_info(
|
|
split_test,
|
|
is_concise=True,
|
|
include_child_info=True,
|
|
include_children_predicate=lambda xblock: xblock.has_children,
|
|
course=self.course,
|
|
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"
|
|
)
|
|
|
|
def test_change_user_partition_id(self):
|
|
"""
|
|
Test what happens when the user_partition_id is changed to a different groups
|
|
group configuration.
|
|
"""
|
|
# Set to first group configuration.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
self.assertEqual(2, len(split_test.children))
|
|
initial_vertical_0_location = split_test.children[0]
|
|
initial_vertical_1_location = split_test.children[1]
|
|
|
|
# Set to second group configuration
|
|
split_test = self._update_partition_id(self.second_user_partition.id)
|
|
# We don't remove existing children.
|
|
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])
|
|
vertical_1 = self.get_item_from_modulestore(split_test.children[3])
|
|
vertical_2 = self.get_item_from_modulestore(split_test.children[4])
|
|
|
|
# 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.assertNotEqual(initial_vertical_0_location, vertical_0.location)
|
|
self.assertNotEqual(initial_vertical_1_location, vertical_1.location)
|
|
|
|
def test_change_same_user_partition_id(self):
|
|
"""
|
|
Test that nothing happens when the user_partition_id is set to the same value twice.
|
|
"""
|
|
# Set to first group configuration.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
self.assertEqual(2, len(split_test.children))
|
|
initial_group_id_to_child = split_test.group_id_to_child
|
|
|
|
# Set again to first group configuration.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
self.assertEqual(2, len(split_test.children))
|
|
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
|
|
|
def test_change_non_existent_user_partition_id(self):
|
|
"""
|
|
Test that nothing happens when the user_partition_id is set to a value that doesn't exist.
|
|
|
|
The user_partition_id will be updated, but children and group_id_to_child map will not change.
|
|
"""
|
|
# Set to first group configuration.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
self.assertEqual(2, len(split_test.children))
|
|
initial_group_id_to_child = split_test.group_id_to_child
|
|
|
|
# Set to an group configuration that doesn't exist.
|
|
split_test = self._update_partition_id(-50)
|
|
self.assertEqual(2, len(split_test.children))
|
|
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
|
|
|
def test_add_groups(self):
|
|
"""
|
|
Test the "fix up behavior" when groups are missing (after a group is added to a group configuration).
|
|
|
|
This test actually belongs over in common, but it relies on a mutable modulestore.
|
|
TODO: move tests that can go over to common after the mixed modulestore work is done. # pylint: disable=fixme
|
|
"""
|
|
# Set to first group configuration.
|
|
split_test = self._update_partition_id(self.first_user_partition.id)
|
|
|
|
# Add a group to the first group configuration.
|
|
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.store.update_item(split_test, self.user.id)
|
|
|
|
# group_id_to_child and children have not changed yet.
|
|
split_test = self._assert_children(2)
|
|
group_id_to_child = split_test.group_id_to_child.copy()
|
|
self.assertEqual(2, len(group_id_to_child))
|
|
|
|
# SplitModuleStoreRuntime is used in tests.
|
|
# SplitModuleStoreRuntime 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( # pylint: disable=protected-access
|
|
self.user
|
|
)
|
|
|
|
# Call add_missing_groups method to add the missing group.
|
|
split_test.add_missing_groups(self.request)
|
|
split_test = self._assert_children(3)
|
|
self.assertNotEqual(group_id_to_child, split_test.group_id_to_child)
|
|
group_id_to_child = split_test.group_id_to_child
|
|
self.assertEqual(split_test.children[2], group_id_to_child[new_group_id])
|
|
|
|
# Call add_missing_groups again -- it should be a no-op.
|
|
split_test.add_missing_groups(self.request)
|
|
split_test = self._assert_children(3)
|
|
self.assertEqual(group_id_to_child, split_test.group_id_to_child)
|
|
|
|
|
|
@ddt.ddt
|
|
class TestComponentHandler(TestCase):
|
|
"""Tests for component handler api"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
self.request_factory = RequestFactory()
|
|
|
|
patcher = patch("cms.djangoapps.contentstore.views.component.modulestore")
|
|
self.modulestore = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
# component_handler calls modulestore.get_item to get the requested xBlock.
|
|
# Here, we mock the return value of modulestore.get_item so it can be used to mock the handler
|
|
# of the xBlock.
|
|
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",
|
|
)
|
|
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.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")
|
|
|
|
@ddt.data("GET", "POST", "PUT", "DELETE")
|
|
def test_request_method(self, method):
|
|
def check_handler(
|
|
handler, request, suffix
|
|
): # lint-amnesty, pylint: disable=unused-argument
|
|
self.assertEqual(request.method, method)
|
|
return Response()
|
|
|
|
self.block.handle = check_handler
|
|
|
|
# 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.user = self.user
|
|
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
|
|
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,
|
|
)
|
|
|
|
@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..
|
|
|
|
Arguments:
|
|
mock_logger (object): A mock logger object.
|
|
"""
|
|
|
|
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"
|
|
|
|
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"
|
|
):
|
|
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,
|
|
UsageKey.from_string(self.usage_key_string).course_key,
|
|
mock_handler,
|
|
)
|
|
|
|
@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
|
|
"""create dummy response"""
|
|
return Response(status_code=200)
|
|
|
|
def get_usage_key():
|
|
"""return usage key"""
|
|
return (
|
|
str(AsideUsageKeyV2(self.usage_key, "aside"))
|
|
if is_xblock_aside
|
|
else self.usage_key_string
|
|
)
|
|
|
|
self.block.handle = create_response
|
|
|
|
with patch(
|
|
"cms.djangoapps.contentstore.views.component.is_xblock_aside",
|
|
return_value=is_xblock_aside,
|
|
), patch(
|
|
"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")
|
|
assert mocked_webob_to_django_response.called is True
|
|
|
|
assert mocked_get_aside_from_xblock.called is is_get_aside_called
|
|
|
|
|
|
@override_waffle_flag(toggles.LEGACY_STUDIO_PROBLEM_EDITOR, True)
|
|
class TestComponentTemplates(CourseTestCase):
|
|
"""
|
|
Unit tests for the generation of the component templates for a course.
|
|
"""
|
|
|
|
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"
|
|
)
|
|
# 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"
|
|
)
|
|
# ORA Block has it's own category.
|
|
XBlockStudioConfiguration.objects.create(
|
|
name="openassessment", enabled=True, support_level="us"
|
|
)
|
|
# Library Content block has its own category.
|
|
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"
|
|
)
|
|
|
|
self.templates = get_component_templates(self.course)
|
|
|
|
self.default_advanced_modules_titles = sorted([
|
|
"Google Calendar",
|
|
"Google Document",
|
|
"LTI Consumer",
|
|
"Poll",
|
|
"Content Experiment",
|
|
"Survey",
|
|
"Word cloud",
|
|
])
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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,
|
|
)
|
|
|
|
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,
|
|
)
|
|
|
|
def test_basic_components(self):
|
|
"""
|
|
Test the handling of the basic component templates.
|
|
"""
|
|
self._verify_basic_component("discussion", "Discussion")
|
|
self._verify_basic_component("video", "Video")
|
|
self._verify_basic_component("openassessment", "Peer Assessment Only", True, 5)
|
|
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)
|
|
|
|
# Check for default advanced modules
|
|
advanced_templates = self.get_templates_of_type("advanced")
|
|
advanced_module_keys = [t['category'] for t in advanced_templates]
|
|
self.assertCountEqual(advanced_module_keys, DEFAULT_ADVANCED_MODULES)
|
|
|
|
# Now fully disable video through XBlockConfiguration
|
|
XBlockConfiguration.objects.create(name="video", enabled=False)
|
|
self.templates = get_component_templates(self.course)
|
|
self.assertIsNone(self.get_templates_of_type("video"))
|
|
|
|
def test_basic_components_support_levels(self):
|
|
"""
|
|
Test that support levels can be set on basic component templates.
|
|
"""
|
|
XBlockStudioConfigurationFlag.objects.create(enabled=True)
|
|
self.templates = get_component_templates(self.course)
|
|
self._verify_basic_component("discussion", "Discussion", "ps")
|
|
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",
|
|
}
|
|
]
|
|
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 Problem"
|
|
)
|
|
self.assertIsNotNone(problem_no_boilerplate)
|
|
self.assertEqual("us", problem_no_boilerplate["support_level"])
|
|
|
|
# Now fully disable video through XBlockConfiguration
|
|
XBlockConfiguration.objects.create(name="video", enabled=False)
|
|
self.templates = get_component_templates(self.course)
|
|
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("done")
|
|
EXPECTED_ADVANCED_MODULES_LENGTH = len(DEFAULT_ADVANCED_MODULES) + 1
|
|
self.templates = get_component_templates(self.course)
|
|
advanced_templates = self.get_templates_of_type("advanced")
|
|
self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH)
|
|
done_template = advanced_templates[0]
|
|
self.assertEqual(done_template.get("category"), "done")
|
|
self.assertEqual(done_template.get("display_name"), "Completion")
|
|
self.assertIsNone(done_template.get("boilerplate_name", None))
|
|
|
|
# Verify that components are not added twice
|
|
self.course.advanced_modules.append("video")
|
|
self.course.advanced_modules.append("drag-and-drop-v2")
|
|
# Already defined advanced modules
|
|
self.course.advanced_modules.append("poll")
|
|
self.course.advanced_modules.append("google-document")
|
|
self.course.advanced_modules.append("survey")
|
|
|
|
self.templates = get_component_templates(self.course)
|
|
advanced_templates = self.get_templates_of_type("advanced")
|
|
self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH)
|
|
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"), "poll")
|
|
self.assertNotEqual(only_template.get("category"), "google-document")
|
|
self.assertNotEqual(only_template.get("category"), "survey")
|
|
|
|
# Now fully disable done through XBlockConfiguration
|
|
XBlockConfiguration.objects.create(name="done", enabled=False)
|
|
self.templates = get_component_templates(self.course)
|
|
self.assertTrue((not any(item.get("category") == "done" for item in 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"
|
|
)
|
|
self.assertIsNotNone(circuit_template)
|
|
self.assertEqual(circuit_template.get("category"), "problem")
|
|
self.assertEqual(
|
|
circuit_template.get("boilerplate_name"), "circuitschematic.yaml"
|
|
)
|
|
|
|
def test_deprecated_no_advance_component_button(self):
|
|
"""
|
|
Test that there will be no `Advanced` button on unit page if xblocks have disabled
|
|
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"
|
|
)
|
|
XBlockStudioConfigurationFlag.objects.create(enabled=True)
|
|
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)
|
|
|
|
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"
|
|
)
|
|
XBlockStudioConfigurationFlag.objects.create(enabled=True)
|
|
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"])
|
|
|
|
# Now enable unsupported components.
|
|
self.course.allow_unsupported_xblocks = True
|
|
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"])
|
|
|
|
def test_create_support_level_flag_off(self):
|
|
"""
|
|
Test that we can create any advanced xblock (that isn't completely disabled through
|
|
XBlockConfiguration) if XBlockStudioConfigurationFlag is False.
|
|
"""
|
|
XBlockStudioConfigurationFlag.objects.create(enabled=False)
|
|
self.course.advanced_modules.extend(["annotatable", "done"])
|
|
expected_xblocks = ["Annotation", "Completion"] + self.default_advanced_modules_titles
|
|
self._verify_advanced_xblocks(expected_xblocks, [True] * len(expected_xblocks))
|
|
|
|
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")
|
|
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")
|
|
self.assertIsNotNone(sgp)
|
|
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")
|
|
self.assertIsNotNone(dndv2)
|
|
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)
|
|
|
|
# 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"))
|
|
|
|
# Now allow unsupported components.
|
|
self.course.allow_unsupported_xblocks = True
|
|
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"))
|
|
|
|
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
|
|
|
|
def test_discussion_button_present_legacy_provider(self):
|
|
"""
|
|
Test the Discussion button present when legacy discussion provider configured for course
|
|
"""
|
|
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"
|
|
)
|
|
|
|
templates = get_component_templates(self.course)
|
|
button_names = [template["display_name"] for template in templates]
|
|
assert "Discussion" in button_names
|
|
|
|
def test_discussion_button_absent_non_legacy_provider(self):
|
|
"""
|
|
Test the Discussion button not present when non-legacy discussion provider configured for course
|
|
"""
|
|
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"
|
|
)
|
|
|
|
templates = get_component_templates(self.course)
|
|
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[-1]["templates"]), len(expected_xblocks))
|
|
template_display_names = [
|
|
template["display_name"] for template in templates[-1]["templates"]
|
|
]
|
|
self.assertEqual(template_display_names, expected_xblocks)
|
|
template_support_levels = [
|
|
template["support_level"] for template in templates[-1]["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
|
|
):
|
|
"""
|
|
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"])
|
|
|
|
def _verify_basic_component_display_name(self, component_type, display_name):
|
|
"""
|
|
Verify the display name of basic components.
|
|
"""
|
|
component_display_name = self.get_display_name_of_type(component_type)
|
|
self.assertEqual(display_name, component_display_name)
|
|
|
|
|
|
@ddt.ddt
|
|
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"],
|
|
)
|
|
self.sequential = BlockFactory.create(
|
|
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,
|
|
)
|
|
self.video = BlockFactory.create(
|
|
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"))
|
|
self.validate_course_xblock_info(json_response, course_outline=True)
|
|
|
|
def test_xblock_outline_handler_mongo_calls(self):
|
|
course = CourseFactory.create()
|
|
chapter = BlockFactory.create(
|
|
parent_location=course.location, category='chapter', display_name='Week 1'
|
|
)
|
|
outline_url = reverse_usage_url('xblock_outline_handler', chapter.location)
|
|
with check_mongo_calls(3):
|
|
self.client.get(outline_url, HTTP_ACCEPT='application/json')
|
|
|
|
sequential = BlockFactory.create(
|
|
parent_location=chapter.location, category='sequential', display_name='Sequential 1'
|
|
)
|
|
|
|
BlockFactory.create(
|
|
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(3):
|
|
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,
|
|
)
|
|
chapter = modulestore().get_item(chapter.location)
|
|
xblock_info = create_xblock_info(
|
|
chapter,
|
|
include_child_info=True,
|
|
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))
|
|
|
|
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,
|
|
)
|
|
chapter = modulestore().get_item(chapter.location)
|
|
xblock_info = create_xblock_info(
|
|
chapter,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
|
|
# 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)
|
|
# chapter xblock info should not contains the key of 'is_header_visible'.
|
|
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,
|
|
)
|
|
|
|
subsection = BlockFactory.create(
|
|
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
|
|
)
|
|
# 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")
|
|
|
|
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,
|
|
)
|
|
subsection = modulestore().get_item(subsection.location)
|
|
xblock_info = create_xblock_info(
|
|
subsection,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
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))
|
|
|
|
def test_chapter_xblock_info(self):
|
|
chapter = modulestore().get_item(self.chapter.location)
|
|
xblock_info = create_xblock_info(
|
|
chapter,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
self.validate_chapter_xblock_info(xblock_info)
|
|
|
|
def test_sequential_xblock_info(self):
|
|
sequential = modulestore().get_item(self.sequential.location)
|
|
xblock_info = create_xblock_info(
|
|
sequential,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
self.validate_sequential_xblock_info(xblock_info)
|
|
|
|
def test_vertical_xblock_info(self):
|
|
vertical = modulestore().get_item(self.vertical.location)
|
|
|
|
xblock_info = create_xblock_info(
|
|
vertical,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
include_ancestor_info=True,
|
|
user=self.user,
|
|
)
|
|
add_container_page_publishing_info(vertical, xblock_info)
|
|
self.validate_vertical_xblock_info(xblock_info)
|
|
|
|
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
|
|
)
|
|
self.validate_component_xblock_info(xblock_info)
|
|
|
|
def test_validate_start_date(self):
|
|
"""
|
|
Validate if start-date year is less than 1900 reset the date to DEFAULT_START_DATE.
|
|
"""
|
|
course = CourseFactory.create()
|
|
chapter = BlockFactory.create(
|
|
parent_location=course.location, category='chapter', display_name='Week 1'
|
|
)
|
|
|
|
chapter.start = datetime(year=1899, month=1, day=1, tzinfo=UTC)
|
|
|
|
xblock_info = create_xblock_info(
|
|
chapter,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
include_ancestor_info=True,
|
|
user=self.user
|
|
)
|
|
|
|
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"])
|
|
|
|
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.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")
|
|
|
|
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.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)
|
|
|
|
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"])
|
|
|
|
# Finally, validate the entire response for consistency
|
|
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"])
|
|
|
|
# Finally, validate the entire response for consistency
|
|
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))
|
|
|
|
# Finally, validate the entire response for consistency
|
|
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")
|
|
|
|
# Validate that the correct ancestor info has been included
|
|
ancestor_info = xblock_info.get("ancestor_info", None)
|
|
self.assertIsNotNone(ancestor_info)
|
|
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
|
|
)
|
|
|
|
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))
|
|
|
|
# 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,
|
|
):
|
|
"""
|
|
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"])
|
|
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.validate_xblock_info_consistency(
|
|
ancestor,
|
|
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))
|
|
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.validate_xblock_info_consistency(
|
|
child_response,
|
|
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))
|
|
|
|
|
|
@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"
|
|
)
|
|
patch_does_backend_support_onboarding = patch.object(
|
|
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"},
|
|
)
|
|
patch_get_exam_by_content_id_not_found = patch.object(
|
|
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"],
|
|
)
|
|
# get updated course
|
|
self.course = self.store.get_item(self.course.location)
|
|
self.course.enable_proctored_exams = True
|
|
self.course.save()
|
|
self.course = self.store.update_item(self.course, self.user.id)
|
|
|
|
def test_proctoring_is_enabled_for_course(self):
|
|
course = modulestore().get_item(self.course.location)
|
|
xblock_info = create_xblock_info(
|
|
course,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
# exam proctoring should be enabled and time limited.
|
|
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,
|
|
):
|
|
sequential = BlockFactory.create(
|
|
parent_location=self.chapter.location,
|
|
category="sequential",
|
|
display_name="Test Lesson 1",
|
|
user_id=self.user.id,
|
|
is_proctored_enabled=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=100,
|
|
is_onboarding_exam=False,
|
|
)
|
|
sequential = modulestore().get_item(sequential.location)
|
|
xblock_info = create_xblock_info(
|
|
sequential,
|
|
include_child_info=True,
|
|
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"]
|
|
)
|
|
|
|
@patch_get_exam_configuration_dashboard_url
|
|
@patch_does_backend_support_onboarding
|
|
@patch_get_exam_by_content_id_success
|
|
@ddt.data(
|
|
("lti_external", False, None),
|
|
("other_proctoring_backend", True, "test_url"),
|
|
)
|
|
@ddt.unpack
|
|
def test_proctoring_values_correct_depending_on_lti_external(
|
|
self,
|
|
external_id,
|
|
expected_supports_onboarding_value,
|
|
expected_proctoring_link,
|
|
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",
|
|
display_name="Test Lesson 1",
|
|
user_id=self.user.id,
|
|
is_proctored_enabled=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=100,
|
|
is_onboarding_exam=False,
|
|
)
|
|
|
|
# set course.proctoring_provider to lti_external
|
|
self.course.proctoring_provider = external_id
|
|
mock_get_exam_by_content_id.return_value = {"external_id": external_id}
|
|
|
|
# mock_does_backend_support_onboarding returns True
|
|
mock_does_backend_support_onboarding.return_value = True
|
|
sequential = modulestore().get_item(sequential.location)
|
|
xblock_info = create_xblock_info(
|
|
sequential,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
course=self.course,
|
|
)
|
|
assert xblock_info["supports_onboarding"] is expected_supports_onboarding_value
|
|
assert xblock_info["proctoring_exam_configuration_link"] == expected_proctoring_link
|
|
|
|
@patch_get_exam_configuration_dashboard_url
|
|
@patch_does_backend_support_onboarding
|
|
@patch_get_exam_by_content_id_success
|
|
@ddt.data(
|
|
("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,
|
|
):
|
|
sequential = BlockFactory.create(
|
|
parent_location=self.chapter.location,
|
|
category="sequential",
|
|
display_name="Test Lesson 1",
|
|
user_id=self.user.id,
|
|
is_proctored_enabled=False,
|
|
is_time_limited=False,
|
|
is_onboarding_exam=False,
|
|
)
|
|
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 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,
|
|
):
|
|
sequential = BlockFactory.create(
|
|
parent_location=self.chapter.location,
|
|
category="sequential",
|
|
display_name="Test Lesson 1",
|
|
user_id=self.user.id,
|
|
is_proctored_enabled=False,
|
|
is_time_limited=False,
|
|
is_onboarding_exam=False,
|
|
)
|
|
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 False
|
|
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_success
|
|
def test_special_exam_xblock_info_get_dashboard_error(
|
|
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",
|
|
display_name="Test Lesson 1",
|
|
user_id=self.user.id,
|
|
is_proctored_enabled=True,
|
|
is_time_limited=True,
|
|
default_time_limit_minutes=100,
|
|
is_onboarding_exam=False,
|
|
)
|
|
sequential = modulestore().get_item(sequential.location)
|
|
mock_get_exam_configuration_dashboard_url.side_effect = Exception("proctoring error")
|
|
xblock_info = create_xblock_info(
|
|
sequential,
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
|
|
# no errors should be raised and proctoring_exam_configuration_link is None
|
|
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"] is None
|
|
assert xblock_info["supports_onboarding"] is True
|
|
assert xblock_info["is_onboarding_exam"] is False
|
|
|
|
|
|
class TestLibraryXBlockInfo(ModuleStoreTestCase):
|
|
"""
|
|
Unit tests for XBlock Info for XBlocks in a content library
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
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,
|
|
)
|
|
self.vertical = BlockFactory.create(
|
|
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,
|
|
)
|
|
|
|
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))
|
|
|
|
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
|
|
)
|
|
self.validate_component_xblock_info(xblock_info, html_block)
|
|
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")
|
|
|
|
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))
|
|
|
|
|
|
class TestLibraryXBlockCreation(ItemTest):
|
|
"""
|
|
Tests the adding of XBlocks to Library
|
|
"""
|
|
|
|
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"
|
|
)
|
|
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")
|
|
|
|
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"
|
|
)
|
|
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.save()
|
|
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)
|
|
|
|
|
|
@ddt.ddt
|
|
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
|
|
):
|
|
"""
|
|
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,
|
|
)
|
|
if staff_only:
|
|
self._enable_staff_only(child.location)
|
|
# In case the staff_only state was set, return the updated xblock.
|
|
return modulestore().get_item(child.location)
|
|
|
|
def _get_child_xblock_info(self, xblock_info, index):
|
|
"""
|
|
Returns the child xblock info at the specified index.
|
|
"""
|
|
children = xblock_info["child_info"]["children"]
|
|
self.assertGreater(len(children), index)
|
|
return children[index]
|
|
|
|
def _get_xblock_info(self, location):
|
|
"""
|
|
Returns the xblock info for the specified location.
|
|
"""
|
|
return create_xblock_info(
|
|
modulestore().get_item(location),
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
)
|
|
|
|
def _get_xblock_outline_info(self, location):
|
|
"""
|
|
Returns the xblock info for the specified location as neeeded for the course outline page.
|
|
"""
|
|
return create_xblock_info(
|
|
modulestore().get_item(location),
|
|
include_child_info=True,
|
|
include_children_predicate=ALWAYS,
|
|
course_outline=True,
|
|
)
|
|
|
|
def _set_release_date(self, location, start):
|
|
"""
|
|
Sets the release date for the specified xblock.
|
|
"""
|
|
xblock = modulestore().get_item(location)
|
|
xblock.start = start
|
|
self.store.update_item(xblock, self.user.id)
|
|
|
|
def _enable_staff_only(self, location):
|
|
"""
|
|
Enables staff only for the specified xblock.
|
|
"""
|
|
xblock = modulestore().get_item(location)
|
|
xblock.visible_to_staff_only = True
|
|
self.store.update_item(xblock, self.user.id)
|
|
|
|
def _set_display_name(self, location, display_name):
|
|
"""
|
|
Sets the display name for the specified xblock.
|
|
"""
|
|
xblock = modulestore().get_item(location)
|
|
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,
|
|
):
|
|
"""
|
|
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
|
|
do not match.
|
|
"""
|
|
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,
|
|
)
|
|
else:
|
|
if should_equal:
|
|
self.assertEqual(xblock_info[xblock_info_field], expected_state)
|
|
else:
|
|
self.assertNotEqual(xblock_info[xblock_info_field], expected_state)
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
def test_empty_chapter(self):
|
|
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)
|
|
|
|
def test_chapter_self_paced_default_start_date(self):
|
|
course = CourseFactory.create()
|
|
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)
|
|
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")
|
|
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
|
|
)
|
|
|
|
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)
|
|
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
|
|
)
|
|
|
|
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)
|
|
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
|
|
)
|
|
|
|
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)
|
|
# Setting the display name creates a draft version of 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
|
|
)
|
|
|
|
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 1", 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 2", 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]
|
|
)
|
|
|
|
# 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]
|
|
)
|
|
|
|
# Finally verify the state of the chapter
|
|
self._verify_visibility_state(xblock_info, VisibilityState.ready)
|
|
|
|
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")
|
|
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_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
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
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
|
|
)
|
|
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, path=[1])
|
|
|
|
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")
|
|
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_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
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
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)
|
|
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
|
|
)
|
|
|
|
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)
|
|
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_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
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
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)
|
|
)
|
|
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
|
|
)
|
|
|
|
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)
|
|
self._set_release_date(chapter.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
|
|
)
|
|
|
|
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")
|
|
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
|
|
)
|
|
|
|
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)
|
|
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
|
|
)
|
|
|
|
def test_self_paced_item_visibility_state(self):
|
|
"""
|
|
Test that in self-paced course, item has `live` visibility state.
|
|
Test that when item was initially in `scheduled` state in instructor mode, change course pacing to self-paced,
|
|
now in self-paced course, item should have `live` visibility state.
|
|
"""
|
|
|
|
# Create course, chapter and setup future release date to make chapter in scheduled state
|
|
course = CourseFactory.create()
|
|
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
|
|
xblock_info = self._get_xblock_info(chapter.location)
|
|
self._verify_visibility_state(xblock_info, VisibilityState.ready)
|
|
self.assertFalse(course.self_paced)
|
|
|
|
# Change course pacing to self paced
|
|
course.self_paced = True
|
|
self.store.update_item(course, self.user.id)
|
|
self.assertTrue(course.self_paced)
|
|
|
|
# Check that in self paced course content has live state now
|
|
xblock_info = self._get_xblock_info(chapter.location)
|
|
self._verify_visibility_state(xblock_info, VisibilityState.live)
|
|
|
|
|
|
@patch(
|
|
"xmodule.modulestore.split_mongo.runtime.SplitModuleStoreRuntime.applicable_aside_types",
|
|
lambda self, block: ["test_aside"],
|
|
)
|
|
class TestUpdateFromSource(ModuleStoreTestCase):
|
|
"""
|
|
Test update_from_source.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Set up the runtime for tests.
|
|
"""
|
|
super().setUp()
|
|
key_store = DictKeyValueStore()
|
|
field_data = KvsFieldData(key_store)
|
|
self.runtime = TestRuntime(services={"field-data": field_data})
|
|
|
|
def create_source_block(self, course):
|
|
"""
|
|
Create a chapter with all the fixings.
|
|
"""
|
|
source_block = BlockFactory(
|
|
parent=course,
|
|
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")
|
|
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"
|
|
|
|
# The data attribute is handled in a special manner and should be updated.
|
|
source_block.data = "<div>test</div>"
|
|
# This field is set on the content scope (definition_data), which should be updated.
|
|
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, "<div>test</div>")
|
|
self.assertEqual(source_block.items, ["test", "beep"])
|
|
|
|
return source_block
|
|
|
|
def check_updated(self, source_block, destination_key):
|
|
"""
|
|
Check that the destination block has been updated to match our source block.
|
|
"""
|
|
revised = self.store.get_item(destination_key)
|
|
self.assertEqual(source_block.display_name, revised.display_name)
|
|
self.assertEqual(source_block.due, revised.due)
|
|
self.assertEqual(revised.data, source_block.data)
|
|
self.assertEqual(revised.items, source_block.items)
|
|
|
|
self.assertEqual(
|
|
revised.runtime.get_asides(revised)[0].field11,
|
|
source_block.runtime.get_asides(source_block)[0].field11,
|
|
)
|
|
|
|
@XBlockAside.register_temp_plugin(AsideTest, "test_aside")
|
|
def test_update_from_source(self):
|
|
"""
|
|
Test that update_from_source updates the destination block.
|
|
"""
|
|
course = CourseFactory()
|
|
user = UserFactory.create()
|
|
|
|
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,
|
|
)
|
|
self.check_updated(source_block, destination_block.location)
|
|
|
|
@XBlockAside.register_temp_plugin(AsideTest, "test_aside")
|
|
def test_update_clobbers(self):
|
|
"""
|
|
Verify that our update replaces all settings on the block.
|
|
"""
|
|
course = CourseFactory()
|
|
user = UserFactory.create()
|
|
|
|
source_block = self.create_source_block(course)
|
|
|
|
destination_block = BlockFactory(
|
|
parent=course,
|
|
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")
|
|
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 = "<div>other stuff</div>"
|
|
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,
|
|
)
|
|
self.check_updated(source_block, destination_block.location)
|
|
|
|
|
|
class TestXblockEditView(CourseTestCase):
|
|
"""
|
|
Test xblock_edit_view.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.chapter = self._create_block(self.course, "chapter", "Week 1")
|
|
self.sequential = self._create_block(self.chapter, "sequential", "Lesson 1")
|
|
self.vertical = self._create_block(self.sequential, "vertical", "Unit")
|
|
self.html = self._create_block(self.vertical, "html", "HTML")
|
|
self.child_container = self._create_block(
|
|
self.vertical, "split_test", "Split Test"
|
|
)
|
|
self.child_vertical = self._create_block(
|
|
self.child_container, "vertical", "Child Vertical"
|
|
)
|
|
self.video = self._create_block(self.child_vertical, "video", "My Video")
|
|
self.store = modulestore()
|
|
|
|
self.store.publish(self.vertical.location, self.user.id)
|
|
|
|
def _create_block(self, parent, category, display_name, **kwargs):
|
|
"""
|
|
creates a block in the module store, without publishing it.
|
|
"""
|
|
return BlockFactory.create(
|
|
parent=parent,
|
|
category=category,
|
|
display_name=display_name,
|
|
publish_item=False,
|
|
user_id=self.user.id,
|
|
**kwargs,
|
|
)
|
|
|
|
def test_xblock_edit_view(self):
|
|
url = reverse_usage_url("xblock_edit_handler", self.video.location)
|
|
resp = self.client.get_html(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
html_content = resp.content.decode(resp.charset)
|
|
self.assertIn("var decodedActionName = 'edit';", html_content)
|
|
|
|
def test_xblock_edit_view_contains_resources(self):
|
|
url = reverse_usage_url("xblock_edit_handler", self.video.location)
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200)
|
|
|
|
html_content = resp.content.decode(resp.charset)
|
|
soup = BeautifulSoup(html_content, "html.parser")
|
|
|
|
resource_links = [link["href"] for link in soup.find_all("link", {"rel": "stylesheet"})]
|
|
script_sources = [script["src"] for script in soup.find_all("script") if script.get("src")]
|
|
|
|
self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}")
|
|
self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}")
|