Merge pull request #3999 from edx/christina/updating-split-test
Ability to change the associated group configuration for a split test
This commit is contained in:
@@ -7,6 +7,9 @@ the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Move Peer Assessment into advanced problems menu.
|
||||
|
||||
Studio: Support creation and editing of split_test instances (Content Experiments)
|
||||
entirely in Studio. STUD-1658.
|
||||
|
||||
Blades: Add context-aware video index. BLD-933
|
||||
|
||||
Blades: Fix bug with incorrect link format and redirection. BLD-1049
|
||||
|
||||
@@ -23,6 +23,7 @@ import xmodule
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW
|
||||
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
from util.string_utils import str_to_bool
|
||||
@@ -180,15 +181,15 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
xblock = store.get_item(usage_key)
|
||||
is_read_only = _is_xblock_read_only(xblock)
|
||||
container_views = ['container_preview', 'reorderable_container_child_preview']
|
||||
unit_views = ['student_view']
|
||||
unit_views = PREVIEW_VIEWS
|
||||
|
||||
# wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
xblock.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime', usage_id_serializer=unicode))
|
||||
|
||||
if view_name == 'studio_view':
|
||||
if view_name == STUDIO_VIEW:
|
||||
try:
|
||||
fragment = xblock.render('studio_view')
|
||||
fragment = xblock.render(STUDIO_VIEW)
|
||||
# catch exceptions indiscriminately, since after this point they escape the
|
||||
# dungeon and surface as uneditable, unsaveable, and undeletable
|
||||
# component-goblins.
|
||||
@@ -213,7 +214,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
# Note: this special case logic can be removed once the unit page is replaced
|
||||
# with the new container view.
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': is_container_view,
|
||||
'read_only': is_read_only,
|
||||
'root_xblock': xblock if (view_name == 'container_preview') else None,
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from edxmako.shortcuts import render_to_string
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xblock, wrap_fragment
|
||||
from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.modulestore.django import modulestore, ModuleI18nService
|
||||
@@ -21,13 +22,14 @@ from xblock.exceptions import NoSuchHandlerError
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
from lms.lib.xblock.field_data import LmsFieldData
|
||||
from cms.lib.xblock.field_data import CmsFieldData
|
||||
from cms.lib.xblock.runtime import local_resource_url
|
||||
|
||||
from util.sandboxing import can_execute_unsafe_code
|
||||
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .helpers import render_from_lms, xblock_has_own_studio_page
|
||||
from .helpers import render_from_lms
|
||||
|
||||
from contentstore.views.access import get_user_role
|
||||
|
||||
@@ -143,15 +145,20 @@ def _preview_module_system(request, descriptor):
|
||||
|
||||
def _load_preview_module(request, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor.
|
||||
Return a preview XModule instantiated from the supplied descriptor. Will use mutable fields
|
||||
if XModule supports an author_view. Otherwise, will use immutable fields and student_view.
|
||||
|
||||
request: The active django request
|
||||
descriptor: An XModuleDescriptor
|
||||
"""
|
||||
student_data = KvsFieldData(SessionKeyValueStore(request))
|
||||
if _has_author_view(descriptor):
|
||||
field_data = CmsFieldData(descriptor._field_data, student_data) # pylint: disable=protected-access
|
||||
else:
|
||||
field_data = LmsFieldData(descriptor._field_data, student_data) # pylint: disable=protected-access
|
||||
descriptor.bind_for_student(
|
||||
_preview_module_system(request, descriptor),
|
||||
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
|
||||
field_data
|
||||
)
|
||||
return descriptor
|
||||
|
||||
@@ -169,7 +176,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
|
||||
"""
|
||||
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
|
||||
if context.get('container_view', None) and view == 'student_view':
|
||||
if context.get('container_view', None) and view in PREVIEW_VIEWS:
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and xblock.location == root_xblock.location
|
||||
is_reorderable = _is_xblock_reorderable(xblock, context)
|
||||
@@ -187,14 +194,25 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
|
||||
def get_preview_fragment(request, descriptor, context):
|
||||
"""
|
||||
Returns the HTML returned by the XModule's student_view,
|
||||
Returns the HTML returned by the XModule's student_view or author_view (if available),
|
||||
specified by the descriptor and idx.
|
||||
"""
|
||||
module = _load_preview_module(request, descriptor)
|
||||
|
||||
preview_view = AUTHOR_VIEW if _has_author_view(module) else STUDENT_VIEW
|
||||
|
||||
try:
|
||||
fragment = module.render("student_view", context)
|
||||
fragment = module.render(preview_view, context)
|
||||
except Exception as exc: # pylint: disable=W0703
|
||||
log.warning("Unable to render student_view for %r", module, exc_info=True)
|
||||
log.warning("Unable to render %s for %r", preview_view, module, exc_info=True)
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
return fragment
|
||||
|
||||
|
||||
def _has_author_view(descriptor):
|
||||
"""
|
||||
Returns True if the xmodule linked to the descriptor supports "author_view".
|
||||
|
||||
If False, "student_view" and LmsFieldData should be used.
|
||||
"""
|
||||
return getattr(descriptor, 'has_author_view', False)
|
||||
|
||||
@@ -22,6 +22,7 @@ from student.tests.factories import UserFactory
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
@@ -106,7 +107,7 @@ class GetItem(ItemTest):
|
||||
self.assertNotIn('wrapper-xblock', html)
|
||||
|
||||
# Verify that the header and article tags are still added
|
||||
self.assertIn('<header class="xblock-header">', html)
|
||||
self.assertIn('<header class="xblock-header xblock-header-vertical">', html)
|
||||
self.assertIn('<article class="xblock-render">', html)
|
||||
|
||||
def test_get_container_fragment(self):
|
||||
@@ -122,7 +123,7 @@ class GetItem(ItemTest):
|
||||
|
||||
# Verify that the Studio nesting wrapper has been added
|
||||
self.assertIn('level-nesting', html)
|
||||
self.assertIn('<header class="xblock-header">', 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
|
||||
@@ -712,12 +713,12 @@ class TestEditItem(ItemTest):
|
||||
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"})
|
||||
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"})
|
||||
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)
|
||||
|
||||
@@ -811,7 +812,15 @@ class TestEditSplitModule(ItemTest):
|
||||
self.assertEqual(partition_id, split_test.user_partition_id)
|
||||
return split_test
|
||||
|
||||
def test_split_create_groups(self):
|
||||
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, True)
|
||||
self.assertEqual(expected_number, len(split_test.children))
|
||||
return split_test
|
||||
|
||||
def test_create_groups(self):
|
||||
"""
|
||||
Test that verticals are created for the experiment groups when
|
||||
a spit test module is edited.
|
||||
@@ -833,16 +842,14 @@ class TestEditSplitModule(ItemTest):
|
||||
self.assertEqual("alpha", vertical_0.display_name)
|
||||
self.assertEqual("beta", vertical_1.display_name)
|
||||
|
||||
# Verify that the group_id_to child mapping is correct.
|
||||
# Verify that the group_id_to_child mapping is correct.
|
||||
self.assertEqual(2, len(split_test.group_id_to_child))
|
||||
split_test.group_id_to_child['0'] = vertical_0.location
|
||||
split_test.group_id_to_child['1'] = vertical_1.location
|
||||
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
|
||||
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
|
||||
|
||||
def test_split_change_user_partition_id(self):
|
||||
def test_change_user_partition_id(self):
|
||||
"""
|
||||
Test what happens when the user_partition_id is changed to a different experiment.
|
||||
|
||||
This is not currently supported by the Studio UI.
|
||||
"""
|
||||
# Set to first experiment.
|
||||
split_test = self._update_partition_id(0)
|
||||
@@ -852,21 +859,23 @@ class TestEditSplitModule(ItemTest):
|
||||
|
||||
# Set to second experiment
|
||||
split_test = self._update_partition_id(1)
|
||||
# We don't currently remove existing children.
|
||||
# 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], True)
|
||||
vertical_1 = self.get_item_from_modulestore(split_test.children[3], True)
|
||||
vertical_2 = self.get_item_from_modulestore(split_test.children[4], True)
|
||||
|
||||
# Verify that the group_id_to child mapping is correct.
|
||||
self.assertEqual(3, len(split_test.group_id_to_child))
|
||||
split_test.group_id_to_child['0'] = vertical_0.location
|
||||
split_test.group_id_to_child['1'] = vertical_1.location
|
||||
split_test.group_id_to_child['2'] = vertical_2.location
|
||||
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
|
||||
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
|
||||
self.assertEqual(vertical_2.location, split_test.group_id_to_child['2'])
|
||||
self.assertNotEqual(initial_vertical_0_location, vertical_0.location)
|
||||
self.assertNotEqual(initial_vertical_1_location, vertical_1.location)
|
||||
|
||||
def test_split_same_user_partition_id(self):
|
||||
def test_change_same_user_partition_id(self):
|
||||
"""
|
||||
Test that nothing happens when the user_partition_id is set to the same value twice.
|
||||
"""
|
||||
@@ -880,7 +889,7 @@ class TestEditSplitModule(ItemTest):
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
||||
|
||||
def test_split_non_existent_user_partition_id(self):
|
||||
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.
|
||||
|
||||
@@ -896,6 +905,80 @@ class TestEditSplitModule(ItemTest):
|
||||
self.assertEqual(2, len(split_test.children))
|
||||
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
|
||||
|
||||
def test_delete_children(self):
|
||||
"""
|
||||
Test that deleting a child in the group_id_to_child map updates the map.
|
||||
|
||||
Also test that deleting a child not in the group_id_to_child_map behaves properly.
|
||||
"""
|
||||
# Set to first experiment.
|
||||
self._update_partition_id(0)
|
||||
split_test = self._assert_children(2)
|
||||
vertical_1_usage_key = split_test.children[1]
|
||||
|
||||
# Add an extra child to the split_test
|
||||
resp = self.create_xblock(category='html', parent_usage_key=self.split_test_usage_key)
|
||||
extra_child_usage_key = self.response_usage_key(resp)
|
||||
self._assert_children(3)
|
||||
|
||||
# Remove the first child (which is part of the group configuration).
|
||||
resp = self.client.ajax_post(
|
||||
self.split_test_update_url,
|
||||
data={'children': [unicode(vertical_1_usage_key), unicode(extra_child_usage_key)]}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
split_test = self._assert_children(2)
|
||||
|
||||
# Check that group_id_to_child was updated appropriately
|
||||
group_id_to_child = split_test.group_id_to_child
|
||||
self.assertEqual(1, len(group_id_to_child))
|
||||
self.assertEqual(vertical_1_usage_key, group_id_to_child['1'])
|
||||
|
||||
# Remove the "extra" child and make sure that group_id_to_child did not change.
|
||||
resp = self.client.ajax_post(
|
||||
self.split_test_update_url,
|
||||
data={'children': [unicode(vertical_1_usage_key)]}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
split_test = self._assert_children(1)
|
||||
self.assertEqual(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(0)
|
||||
|
||||
# Add a group to the first group configuration.
|
||||
split_test.user_partitions = [
|
||||
UserPartition(
|
||||
0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", '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
|
||||
self.assertEqual(2, len(group_id_to_child))
|
||||
|
||||
# Call add_missing_groups method to add the missing group.
|
||||
split_test.add_missing_groups(None)
|
||||
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["2"])
|
||||
|
||||
# Call add_missing_groups again -- it should be a no-op.
|
||||
split_test.add_missing_groups(None)
|
||||
split_test = self._assert_children(3)
|
||||
self.assertEqual(group_id_to_child, split_test.group_id_to_child)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestComponentHandler(TestCase):
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from contentstore.views import tabs
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.tabs import CourseTabList, WikiTab
|
||||
from contentstore.utils import reverse_course_url
|
||||
@@ -178,7 +178,7 @@ class TabsPageTests(CourseTestCase):
|
||||
"""
|
||||
Verify that the static tab renders itself with the correct HTML
|
||||
"""
|
||||
preview_url = '/xblock/{}/student_view'.format(self.test_tab.location)
|
||||
preview_url = '/xblock/{}/{}'.format(self.test_tab.location, STUDENT_VIEW)
|
||||
|
||||
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@@ -5,6 +5,7 @@ Unit tests for the unit page.
|
||||
from contentstore.views.tests.utils import StudioPageTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
|
||||
|
||||
class UnitPageTestCase(StudioPageTestCase):
|
||||
@@ -38,7 +39,7 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
"""
|
||||
Verify that a public xblock's preview returns the expected HTML.
|
||||
"""
|
||||
self.validate_preview_html(self.video, 'student_view',
|
||||
self.validate_preview_html(self.video, STUDENT_VIEW,
|
||||
can_edit=True, can_reorder=True, can_add=False)
|
||||
|
||||
def test_draft_component_preview_html(self):
|
||||
@@ -47,7 +48,7 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
"""
|
||||
modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
draft_video = modulestore('draft').convert_to_draft(self.video.location)
|
||||
self.validate_preview_html(draft_video, 'student_view',
|
||||
self.validate_preview_html(draft_video, STUDENT_VIEW,
|
||||
can_edit=True, can_reorder=True, can_add=False)
|
||||
|
||||
def test_public_child_container_preview_html(self):
|
||||
@@ -59,7 +60,7 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
category='split_test', display_name='Split Test')
|
||||
ItemFactory.create(parent_location=child_container.location,
|
||||
category='html', display_name='grandchild')
|
||||
self.validate_preview_html(child_container, 'student_view',
|
||||
self.validate_preview_html(child_container, STUDENT_VIEW,
|
||||
can_reorder=True, can_edit=True, can_add=False)
|
||||
|
||||
def test_draft_child_container_preview_html(self):
|
||||
@@ -73,5 +74,5 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
category='html', display_name='grandchild')
|
||||
modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
draft_child_container = modulestore('draft').get_item(child_container.location)
|
||||
self.validate_preview_html(draft_child_container, 'student_view',
|
||||
self.validate_preview_html(draft_child_container, STUDENT_VIEW,
|
||||
can_reorder=True, can_edit=True, can_add=False)
|
||||
|
||||
32
cms/lib/xblock/field_data.py
Normal file
32
cms/lib/xblock/field_data.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
:class:`~xblock.field_data.FieldData` subclasses used by the CMS
|
||||
"""
|
||||
|
||||
from xblock.field_data import SplitFieldData
|
||||
from xblock.fields import Scope
|
||||
|
||||
|
||||
class CmsFieldData(SplitFieldData):
|
||||
"""
|
||||
A :class:`~xblock.field_data.FieldData` that
|
||||
reads all UserScope.ONE and UserScope.ALL fields from `student_data`
|
||||
and all UserScope.NONE fields from `authored_data`. It allows writing to`authored_data`.
|
||||
"""
|
||||
def __init__(self, authored_data, student_data):
|
||||
# Make sure that we don't repeatedly nest CmsFieldData instances
|
||||
if isinstance(authored_data, CmsFieldData):
|
||||
authored_data = authored_data._authored_data # pylint: disable=protected-access
|
||||
|
||||
self._authored_data = authored_data
|
||||
self._student_data = student_data
|
||||
|
||||
super(CmsFieldData, self).__init__({
|
||||
Scope.content: authored_data,
|
||||
Scope.settings: authored_data,
|
||||
Scope.parent: authored_data,
|
||||
Scope.children: authored_data,
|
||||
Scope.user_state_summary: student_data,
|
||||
Scope.user_state: student_data,
|
||||
Scope.user_info: student_data,
|
||||
Scope.preferences: student_data,
|
||||
})
|
||||
@@ -233,6 +233,8 @@ define([
|
||||
"js/spec/views/modals/base_modal_spec",
|
||||
"js/spec/views/modals/edit_xblock_spec",
|
||||
|
||||
"js/spec/xblock/cms.runtime.v1_spec",
|
||||
|
||||
# these tests are run separately in the cms-squire suite, due to process
|
||||
# isolation issues with Squire.js
|
||||
# "coffee/spec/views/assets_spec"
|
||||
|
||||
@@ -1,80 +1,96 @@
|
||||
define [
|
||||
"jquery", "xblock/runtime.v1", "URI", "gettext",
|
||||
"jquery", "backbone", "xblock/runtime.v1", "URI", "gettext",
|
||||
"js/utils/modal", "js/views/feedback_notification"
|
||||
], ($, XBlock, URI, gettext, ModalUtils, NotificationView) ->
|
||||
@PreviewRuntime = {}
|
||||
], ($, Backbone, XBlock, URI, gettext, ModalUtils, NotificationView) ->
|
||||
|
||||
class PreviewRuntime.v1 extends XBlock.Runtime.v1
|
||||
@BaseRuntime = {}
|
||||
|
||||
class BaseRuntime.v1 extends XBlock.Runtime.v1
|
||||
handlerUrl: (element, handlerName, suffix, query, thirdparty) ->
|
||||
uri = URI("/preview/xblock").segment($(element).data('usage-id'))
|
||||
uri = URI(@handlerPrefix).segment($(element).data('usage-id'))
|
||||
.segment('handler')
|
||||
.segment(handlerName)
|
||||
if suffix? then uri.segment(suffix)
|
||||
if query? then uri.search(query)
|
||||
uri.toString()
|
||||
|
||||
@StudioRuntime = {}
|
||||
|
||||
class StudioRuntime.v1 extends XBlock.Runtime.v1
|
||||
constructor: () ->
|
||||
super()
|
||||
@savingNotification = new NotificationView.Mini
|
||||
title: gettext('Saving…')
|
||||
@alert = new NotificationView.Error
|
||||
title: "OpenAssessment Save Error",
|
||||
closeIcon: false,
|
||||
shown: false
|
||||
@dispatcher = _.clone(Backbone.Events)
|
||||
@listenTo('save', @_handleSave)
|
||||
@listenTo('cancel', @_handleCancel)
|
||||
@listenTo('error', @_handleError)
|
||||
@listenTo('modal-shown', (data) ->
|
||||
@modal = data)
|
||||
@listenTo('modal-hidden', () ->
|
||||
@modal = null)
|
||||
@listenTo('page-shown', (data) ->
|
||||
@page = data)
|
||||
|
||||
handlerUrl: (element, handlerName, suffix, query, thirdparty) ->
|
||||
uri = URI("/xblock").segment($(element).data('usage-id'))
|
||||
.segment('handler')
|
||||
.segment(handlerName)
|
||||
if suffix? then uri.segment(suffix)
|
||||
if query? then uri.search(query)
|
||||
uri.toString()
|
||||
|
||||
# Notify the Studio client-side runtime so it can update
|
||||
# the UI in a consistent way. Currently, this is used
|
||||
# for save / cancel when editing an XBlock.
|
||||
# Although native XBlocks should handle their own persistence,
|
||||
# Studio still needs to update the UI in a consistent way
|
||||
# (showing the "Saving..." notification, closing the modal editing dialog, etc.)
|
||||
# Notify the Studio client-side runtime of an event so that it can update the UI in a consistent way.
|
||||
notify: (name, data) ->
|
||||
if name == 'save'
|
||||
if 'state' of data
|
||||
@dispatcher.trigger(name, data)
|
||||
|
||||
# Starting to save, so show the "Saving..." notification
|
||||
if data.state == 'start'
|
||||
@savingNotification.show()
|
||||
# Listen to a Studio event and invoke the specified callback when it is triggered.
|
||||
listenTo: (name, callback) ->
|
||||
@dispatcher.bind(name, callback, this)
|
||||
|
||||
# Finished saving, so hide the "Saving..." notification
|
||||
else if data.state == 'end'
|
||||
@_hideAlerts()
|
||||
# Refresh the view for the xblock represented by the specified element.
|
||||
refreshXBlock: (element) ->
|
||||
if @page
|
||||
@page.refreshXBlock(element)
|
||||
|
||||
# Notify the modal that the save has completed so that it can hide itself
|
||||
# and then refresh the xblock.
|
||||
if @modal
|
||||
@modal.onSave()
|
||||
_handleError: (data) ->
|
||||
message = data.message || data.msg
|
||||
if message
|
||||
# TODO: remove 'Open Assessment' specific default title
|
||||
title = data.title || gettext("OpenAssessment Save Error")
|
||||
@alert = new NotificationView.Error
|
||||
title: title
|
||||
message: message
|
||||
closeIcon: false
|
||||
shown: false
|
||||
@alert.show()
|
||||
|
||||
@savingNotification.hide()
|
||||
_handleSave: (data) ->
|
||||
# Starting to save, so show a notification
|
||||
if data.state == 'start'
|
||||
message = data.message || gettext('Saving…')
|
||||
@notification = new NotificationView.Mini
|
||||
title: message
|
||||
@notification.show()
|
||||
|
||||
else if name == 'edit-modal-shown'
|
||||
@modal = data
|
||||
|
||||
else if name == 'edit-modal-hidden'
|
||||
@modal = null
|
||||
|
||||
else if name == 'cancel'
|
||||
# Finished saving, so hide the notification and refresh appropriately
|
||||
else if data.state == 'end'
|
||||
@_hideAlerts()
|
||||
if @modal
|
||||
@modal.cancel()
|
||||
|
||||
else if name == 'error'
|
||||
if 'msg' of data
|
||||
@alert.options.message = data.msg
|
||||
@alert.show()
|
||||
# Notify the modal that the save has completed so that it can hide itself
|
||||
# and then refresh the xblock.
|
||||
if @modal and @modal.onSave
|
||||
@modal.onSave()
|
||||
# ... else ask it to refresh the newly saved xblock
|
||||
else if data.element
|
||||
@refreshXBlock(data.element)
|
||||
|
||||
@notification.hide()
|
||||
|
||||
_handleCancel: () ->
|
||||
@_hideAlerts()
|
||||
if @modal
|
||||
@modal.cancel()
|
||||
@notify('modal-hidden')
|
||||
|
||||
_hideAlerts: () ->
|
||||
# Hide any alerts that are being shown
|
||||
if @alert.options.shown
|
||||
if @alert && @alert.options.shown
|
||||
@alert.hide()
|
||||
|
||||
@PreviewRuntime = {}
|
||||
|
||||
class PreviewRuntime.v1 extends BaseRuntime.v1
|
||||
handlerPrefix: '/preview/xblock'
|
||||
|
||||
@StudioRuntime = {}
|
||||
|
||||
class StudioRuntime.v1 extends BaseRuntime.v1
|
||||
handlerPrefix: '/xblock'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info"],
|
||||
function ($, _, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
|
||||
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
|
||||
|
||||
describe("ContainerPage", function() {
|
||||
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
|
||||
@@ -96,7 +96,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
editButtons.first().click();
|
||||
|
||||
// Expect a request to be made to show the studio view for the container
|
||||
expect(lastRequest().url.startsWith('/xblock/locator-container/studio_view')).toBeTruthy();
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/studio_view')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockContainerXBlockHtml,
|
||||
resources: []
|
||||
@@ -112,7 +112,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
expect(edit_helpers.isShowingModal()).toBeFalsy();
|
||||
|
||||
// Expect the last request be to refresh the container page
|
||||
expect(lastRequest().url.startsWith('/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockUpdatedContainerXBlockHtml,
|
||||
resources: []
|
||||
@@ -149,7 +149,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
expect(editButtons.length).toBe(6);
|
||||
editButtons[0].click();
|
||||
// Make sure that the correct xblock is requested to be edited
|
||||
expect(lastRequest().url.startsWith('/xblock/locator-component-A1/studio_view')).toBeTruthy();
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/studio_view')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
|
||||
define(["jquery", "underscore.string", "jasmine", "coffee/src/views/unit", "js/models/module_info",
|
||||
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
|
||||
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
|
||||
function ($, str, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
|
||||
var requests, unitView, initialize, lastRequest, respondWithHtml, verifyComponents, i,
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
|
||||
|
||||
@@ -150,7 +150,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
|
||||
});
|
||||
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var link, i,
|
||||
var link,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
@@ -204,7 +204,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
|
||||
expect(editButtons.length).toBe(2);
|
||||
editButtons[1].click();
|
||||
// Make sure that the correct xblock is requested to be edited
|
||||
expect(lastRequest().url.startsWith('/xblock/loc_2/studio_view')).toBeTruthy();
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/loc_2/studio_view')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
|
||||
80
cms/static/js/spec/xblock/cms.runtime.v1_spec.js
Normal file
80
cms/static/js/spec/xblock/cms.runtime.v1_spec.js
Normal file
@@ -0,0 +1,80 @@
|
||||
define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cms.runtime.v1"],
|
||||
function (edit_helpers, BaseModal) {
|
||||
|
||||
describe("Studio Runtime v1", function() {
|
||||
var runtime;
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installEditTemplates();
|
||||
runtime = new window.StudioRuntime.v1();
|
||||
});
|
||||
|
||||
it('allows events to be listened to', function() {
|
||||
var canceled = false;
|
||||
runtime.listenTo('cancel', function() {
|
||||
canceled = true;
|
||||
});
|
||||
expect(canceled).toBeFalsy();
|
||||
runtime.notify('cancel', {});
|
||||
expect(canceled).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows save notifications', function() {
|
||||
var title = "Mock saving...",
|
||||
notificationSpy = edit_helpers.createNotificationSpy();
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
message: title
|
||||
});
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, title);
|
||||
runtime.notify('save', {
|
||||
state: 'end'
|
||||
});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('shows error messages', function() {
|
||||
var title = "Mock Error",
|
||||
message = "This is a mock error.",
|
||||
notificationSpy = edit_helpers.createNotificationSpy("Error");
|
||||
runtime.notify('error', {
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, title);
|
||||
});
|
||||
|
||||
describe("Modal Dialogs", function() {
|
||||
var MockModal, modal, showMockModal;
|
||||
|
||||
MockModal = BaseModal.extend({
|
||||
getContentHtml: function() {
|
||||
return readFixtures('mock/mock-modal.underscore');
|
||||
}
|
||||
});
|
||||
|
||||
showMockModal = function() {
|
||||
modal = new MockModal({
|
||||
title: "Mock Modal"
|
||||
});
|
||||
modal.show();
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installEditTemplates();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.hideModalIfShowing(modal);
|
||||
});
|
||||
|
||||
it('cancels a modal dialog', function () {
|
||||
showMockModal();
|
||||
runtime.notify('modal-shown', modal);
|
||||
expect(edit_helpers.isShowingModal(modal)).toBeTruthy();
|
||||
runtime.notify('cancel');
|
||||
expect(edit_helpers.isShowingModal(modal)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,8 @@ define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sino
|
||||
appendSetFixtures('<div id="page-notification"></div>');
|
||||
};
|
||||
|
||||
createNotificationSpy = function() {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
createNotificationSpy = function(type) {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, type || "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
return notificationSpy;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
|
||||
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification",
|
||||
"jquery.ui"], // The container view uses sortable, which is provided by jquery.ui.
|
||||
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
|
||||
var reorderableClass = '.reorderable-container',
|
||||
sortableInitializedClass = '.ui-sortable',
|
||||
|
||||
@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
// Notify the runtime that the modal has been shown
|
||||
if (runtime) {
|
||||
this.runtime = runtime;
|
||||
runtime.notify("edit-modal-shown", this);
|
||||
runtime.notify('modal-shown', this);
|
||||
}
|
||||
|
||||
// Update the modal's header
|
||||
@@ -166,12 +166,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
|
||||
// Notify the runtime that the modal has been hidden
|
||||
if (this.runtime) {
|
||||
this.runtime.notify('edit-modal-hidden');
|
||||
this.runtime.notify('modal-hidden');
|
||||
}
|
||||
|
||||
// Completely clear the contents of the modal
|
||||
this.undelegateEvents();
|
||||
this.$el.html("");
|
||||
},
|
||||
|
||||
findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) {
|
||||
@@ -180,7 +176,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
displayName;
|
||||
if (xblockWrapperElement.length > 0) {
|
||||
xblockElement = xblockWrapperElement.find('.xblock');
|
||||
displayName = xblockWrapperElement.find('.xblock-header .header-details').text().trim();
|
||||
displayName = xblockWrapperElement.find('.xblock-header .header-details .xblock-display-name').text().trim();
|
||||
// If not found, try looking for the old unit page style rendering
|
||||
if (!displayName) {
|
||||
displayName = this.xblockElement.find('.component-header').text().trim();
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* XBlockContainerPage is used to display Studio's container page for an xblock which has children.
|
||||
* This page allows the user to understand and manipulate the xblock and its children.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
"js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock",
|
||||
"js/views/modals/edit_xblock", "js/models/xblock_info"],
|
||||
function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
|
||||
EditXBlockModal, XBlockInfo) {
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container",
|
||||
"js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
|
||||
function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo) {
|
||||
var XBlockContainerPage = BaseView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
@@ -30,12 +28,16 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
// Hide both blocks until we know which one to show
|
||||
xblockView.$el.addClass('is-hidden');
|
||||
|
||||
// Add actions to any top level buttons, e.g. "Edit" of the container itself
|
||||
self.addButtonActions(this.$el);
|
||||
if (!options || !options.refresh) {
|
||||
// Add actions to any top level buttons, e.g. "Edit" of the container itself.
|
||||
// Do not add the actions on "refresh" though, as the handlers are already registered.
|
||||
self.addButtonActions(this.$el);
|
||||
}
|
||||
|
||||
// Render the xblock
|
||||
xblockView.render({
|
||||
success: function(xblock) {
|
||||
xblockView.xblock.runtime.notify("page-shown", self);
|
||||
xblockView.$el.removeClass('is-hidden');
|
||||
self.renderAddXBlockComponents();
|
||||
self.onXBlockRefresh(xblockView);
|
||||
@@ -55,7 +57,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
},
|
||||
|
||||
refreshTitle: function() {
|
||||
var title = this.$('.xblock-header .header-details span').first().text().trim();
|
||||
var title = this.$('.xblock-header .header-details .xblock-display-name').first().text().trim();
|
||||
this.$('.page-header-title').text(title);
|
||||
this.$('.page-header .subtitle a').last().text(title);
|
||||
},
|
||||
@@ -112,12 +114,16 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
buttonPanel = target.closest('.add-xblock-component'),
|
||||
listPanel = buttonPanel.prev(),
|
||||
scrollOffset = this.getScrollOffset(buttonPanel),
|
||||
placeholderElement = $('<div></div>').appendTo(listPanel),
|
||||
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').appendTo(listPanel),
|
||||
requestData = _.extend(template, {
|
||||
parent_locator: parentLocator
|
||||
});
|
||||
return $.postJSON(this.getURLRoot() + '/', requestData,
|
||||
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset));
|
||||
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset))
|
||||
.fail(function() {
|
||||
// Remove the placeholder if the update failed
|
||||
placeholderElement.remove();
|
||||
});
|
||||
},
|
||||
|
||||
duplicateComponent: function(xblockElement) {
|
||||
@@ -129,14 +135,18 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
this.runOperationShowingMessage(gettext('Duplicating…'),
|
||||
function() {
|
||||
var scrollOffset = self.getScrollOffset(xblockElement),
|
||||
placeholderElement = $('<div></div>').insertAfter(xblockElement),
|
||||
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').insertAfter(xblockElement),
|
||||
parentElement = self.findXBlockElement(parent),
|
||||
requestData = {
|
||||
duplicate_source_locator: xblockElement.data('locator'),
|
||||
parent_locator: parentElement.data('locator')
|
||||
};
|
||||
return $.postJSON(self.getURLRoot() + '/', requestData,
|
||||
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset));
|
||||
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset))
|
||||
.fail(function() {
|
||||
// Remove the placeholder if the update failed
|
||||
placeholderElement.remove();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -153,16 +163,21 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
url: self.getURLRoot() + "/" +
|
||||
xblockElement.data('locator') + "?" +
|
||||
$.param({recurse: true, all_versions: false})
|
||||
}).success(function() {
|
||||
// get the parent so we can remove this component from its parent.
|
||||
var parent = self.findXBlockElement(xblockElement.parent());
|
||||
xblockElement.remove();
|
||||
self.xblockView.updateChildren(parent);
|
||||
});
|
||||
}).success(_.bind(self.onDelete, self, xblockElement));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onDelete: function(xblockElement) {
|
||||
// get the parent so we can remove this component from its parent.
|
||||
var xblockView = this.xblockView,
|
||||
xblock = xblockView.xblock,
|
||||
parent = this.findXBlockElement(xblockElement.parent());
|
||||
xblockElement.remove();
|
||||
xblockView.updateChildren(parent);
|
||||
xblock.runtime.notify('deleted-child', parent.data('locator'));
|
||||
},
|
||||
|
||||
onNewXBlock: function(xblockElement, scrollOffset, data) {
|
||||
this.setScrollOffset(xblockElement, scrollOffset);
|
||||
xblockElement.data('locator', data.locator);
|
||||
@@ -173,13 +188,14 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
* Refreshes the specified xblock's display. If the xblock is an inline child of a
|
||||
* reorderable container then the element will be refreshed inline. If not, then the
|
||||
* parent container will be refreshed instead.
|
||||
* @param xblockElement The element representing the xblock to be refreshed.
|
||||
* @param element An element representing the xblock to be refreshed.
|
||||
*/
|
||||
refreshXBlock: function(xblockElement) {
|
||||
var parentElement = xblockElement.parent(),
|
||||
refreshXBlock: function(element) {
|
||||
var xblockElement = this.findXBlockElement(element),
|
||||
parentElement = xblockElement.parent(),
|
||||
rootLocator = this.xblockView.model.id;
|
||||
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
|
||||
this.render({ });
|
||||
this.render({refresh: true});
|
||||
} else if (parentElement.hasClass('reorderable-container')) {
|
||||
this.refreshChildXBlock(xblockElement);
|
||||
} else {
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
@include transition(all $tmg-f3 linear 0s);
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
padding: 3px ($baseline/2);
|
||||
color: $gray-l1;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
vertical-align: middle;
|
||||
|
||||
.xblock-display-name {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -147,47 +152,51 @@
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
color: $white;
|
||||
|
||||
.message-text {
|
||||
display: inline-block;
|
||||
width: 93%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-sub1;
|
||||
background-color: $gray-l5;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
&.validation {
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $white;
|
||||
|
||||
.icon-warning-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $orange;
|
||||
a {
|
||||
color: $blue-l2;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
display: inline-block;
|
||||
width: 93%;
|
||||
vertical-align: top;
|
||||
&.has-warnings {
|
||||
border-bottom: 3px solid $orange;
|
||||
|
||||
.icon-warning-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $white;
|
||||
&.has-errors {
|
||||
border-bottom: 3px solid $red-l2;
|
||||
|
||||
.icon-exclamation-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $red-l2;
|
||||
.icon-exclamation-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.xblock-message-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xblock-message-actions {
|
||||
@extend %actions-header;
|
||||
padding: ($baseline/2) $baseline;
|
||||
background-color: $gray-d1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
.no-container-content {
|
||||
@extend %ui-well;
|
||||
padding: ($baseline*2);
|
||||
padding: $baseline;
|
||||
background-color: $gray-l4;
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
@@ -78,7 +78,7 @@
|
||||
@extend %t-action4;
|
||||
padding: 8px 20px 10px;
|
||||
text-align: center;
|
||||
margin-left: $baseline;
|
||||
margin: ($baseline/2) 0 ($baseline/2) $baseline;
|
||||
|
||||
[class^="icon-"] {
|
||||
margin-right: ($baseline/2);
|
||||
@@ -158,6 +158,8 @@ body.view-container .content-primary {
|
||||
// CASE: page level xblock rendering
|
||||
&.level-page {
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
|
||||
.xblock-header {
|
||||
display: none;
|
||||
@@ -166,15 +168,36 @@ body.view-container .content-primary {
|
||||
.xblock-message {
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
&.validation {
|
||||
padding-top: ($baseline*.75);
|
||||
}
|
||||
|
||||
.xblock-message-list {
|
||||
margin: ($baseline/5) ($baseline*2.5);
|
||||
list-style-type: disc;
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
.xblock-message-item {
|
||||
padding-bottom: ($baseline/4);
|
||||
}
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-base;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
padding: 0 0 ($baseline/2) 0;
|
||||
background-color: $gray-l5;
|
||||
color: $gray-d1;
|
||||
}
|
||||
}
|
||||
|
||||
.no-container-content {
|
||||
|
||||
.xblock-message-list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
color: $gray-d2;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// CASE: nesting level xblock rendering
|
||||
@@ -250,6 +273,74 @@ body.view-container .content-primary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-xblock-message {
|
||||
|
||||
.xblock-message {
|
||||
border-radius: 0 0 3px 3px;
|
||||
|
||||
.xblock-message-list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-sub2;
|
||||
padding: 0 0 ($baseline/2) $baseline;
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
&.validation.has-warnings {
|
||||
border: 0;
|
||||
border-top: 3px solid $orange;
|
||||
}
|
||||
|
||||
&.validation.has-errors {
|
||||
border: 0;
|
||||
border-top: 3px solid $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// groups in experiments
|
||||
.wrapper-groups {
|
||||
|
||||
.title {
|
||||
@extend %t-title7;
|
||||
margin-left: ($baseline/2);
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
|
||||
// Don't show delete buttons on active groups
|
||||
.wrapper-xblock.level-nesting > .xblock-header .action-delete {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
&.is-inactive {
|
||||
margin: ($baseline*1.5) 0 0 0;
|
||||
border-top: 2px dotted $gray-l2;
|
||||
padding: ($baseline*.75) 0;
|
||||
background-color: $gray-l4;
|
||||
|
||||
.wrapper-xblock.level-nesting {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
opacity: .7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.new-component-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,16 @@ body.course.unit,
|
||||
padding-top: 0;
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
&.has-warnings {
|
||||
border: 0;
|
||||
border-top: 3px solid $orange;
|
||||
}
|
||||
|
||||
&.has-errors {
|
||||
border: 0;
|
||||
border-top: 3px solid $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
<span>Test Container</span>
|
||||
<span class="xblock-display-name">Test Container</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
@@ -22,7 +22,7 @@
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">Expand or Collapse</span>
|
||||
</a>
|
||||
<span>Group A</span>
|
||||
<span class="xblock-display-name">Group A</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
@@ -131,7 +131,7 @@
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">Expand or Collapse</span>
|
||||
</a>
|
||||
<span>Group B</span>
|
||||
<span class="xblock-display-name">Group B</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">Expand or Collapse</span>
|
||||
</a>
|
||||
<span>Empty Vertical Test</span>
|
||||
<span class="xblock-display-name">Empty Vertical Test</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
<span>Updated Test Container</span>
|
||||
<span class="xblock-display-name">Updated Test Container</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<li class="studio-xblock-wrapper is-draggable">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
<span class="xblock-display-name">Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<li class="studio-xblock-wrapper is-draggable">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
<span class="xblock-display-name">Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">${_('Expand or Collapse')}</span>
|
||||
</a>
|
||||
<span>${xblock.display_name_with_default | h}</span>
|
||||
<span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
@@ -20,7 +20,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
|
||||
<section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}">
|
||||
% endif
|
||||
|
||||
<header class="xblock-header">
|
||||
<header class="xblock-header xblock-header-${xblock.category}">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
% if show_inline:
|
||||
@@ -29,7 +29,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
|
||||
<span class="sr">${_('Expand or Collapse')}</span>
|
||||
</a>
|
||||
% endif
|
||||
<span>${xblock.display_name_with_default | h}</span>
|
||||
<span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
@@ -47,13 +47,13 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
|
||||
<span class="sr">${_("Duplicate")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% if is_reorderable:
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%include file="metadata-edit.html" />
|
||||
% if disable_user_partition_editing:
|
||||
<div class="message setting-message">
|
||||
% if not selected_partition:
|
||||
<p>${_("This content experiment refers to a group configuration that has been deleted.")}</p>
|
||||
% else:
|
||||
<p>${_("This content experiment uses group configuration '{0}'.".format("<strong>"+str(selected_partition.name)+"</strong>"))}</p>
|
||||
% endif
|
||||
<p class="tip setting-help">${_("After you select the group configuration and save the content experiment, you cannot change this setting.")}</p>
|
||||
</div>
|
||||
% endif
|
||||
@@ -15,8 +15,7 @@ from xblock.fragment import Fragment
|
||||
|
||||
from xmodule.seq_module import SequenceModule
|
||||
from xmodule.vertical_module import VerticalModule
|
||||
from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule
|
||||
from lms.lib.xblock.runtime import quote_slashes
|
||||
from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule, PREVIEW_VIEWS, STUDIO_VIEW
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -60,10 +59,10 @@ def wrap_xblock(runtime_class, block, view, frag, context, usage_id_serializer,
|
||||
css_classes = ['xblock', 'xblock-' + view]
|
||||
|
||||
if isinstance(block, (XModule, XModuleDescriptor)):
|
||||
if view == 'student_view':
|
||||
if view in PREVIEW_VIEWS:
|
||||
# The block is acting as an XModule
|
||||
css_classes.append('xmodule_display')
|
||||
elif view == 'studio_view':
|
||||
elif view == STUDIO_VIEW:
|
||||
# The block is acting as an XModuleDescriptor
|
||||
css_classes.append('xmodule_edit')
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from lazy import lazy
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, STUDENT_VIEW
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xblock.fields import Scope, ReferenceList
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -160,7 +160,7 @@ class ConditionalModule(ConditionalFields, XModule):
|
||||
context)
|
||||
return json.dumps({'html': [html], 'message': bool(message)})
|
||||
|
||||
html = [child.render('student_view').content for child in self.get_display_items()]
|
||||
html = [child.render(STUDENT_VIEW).content for child in self.get_display_items()]
|
||||
|
||||
return json.dumps({'html': html})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from pkg_resources import resource_string
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, STUDENT_VIEW
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.fields import Scope, String, Integer, Boolean, Dict, List
|
||||
|
||||
@@ -113,7 +113,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule):
|
||||
|
||||
try:
|
||||
child = self.get_display_items()[0]
|
||||
out = child.render('student_view').content
|
||||
out = child.render(STUDENT_VIEW).content
|
||||
# The event listener uses the ajax url to find the child.
|
||||
child_id = child.id
|
||||
except IndexError:
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.setting-message {
|
||||
margin: ($baseline/2) $baseline;
|
||||
border-top: 3px solid $gray-l2;
|
||||
background-color: $gray-l5;
|
||||
padding: $baseline;
|
||||
}
|
||||
|
||||
.setting-help {
|
||||
@include font-size(12);
|
||||
font-color: $gray-l6;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* JavaScript for editing operations that can be done on the split test author view. */
|
||||
window.SplitTestAuthorView = function (runtime, element) {
|
||||
var $element = $(element);
|
||||
|
||||
$element.find('.add-missing-groups-button').click(function () {
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
element: element,
|
||||
message: gettext('Creating missing groups…')
|
||||
});
|
||||
$.post(runtime.handlerUrl(element, 'add_missing_groups')).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to delete events so that the view can refresh when the last inactive group is removed.
|
||||
runtime.listenTo('deleted-child', function(parentLocator) {
|
||||
var splitTestLocator = $element.closest('.studio-xblock-wrapper').data('locator'),
|
||||
inactiveGroups = $element.find('.is-inactive .studio-xblock-wrapper');
|
||||
if (splitTestLocator === parentLocator && inactiveGroups.length === 0) {
|
||||
runtime.refreshXBlock($element);
|
||||
}
|
||||
});
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import random
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, STUDENT_VIEW
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
|
||||
from lxml import etree
|
||||
@@ -83,7 +83,7 @@ class RandomizeModule(RandomizeFields, XModule):
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return Fragment(content=u"<div>Nothing to randomize between</div>")
|
||||
|
||||
return self.child.render('student_view', context)
|
||||
return self.child.render(STUDENT_VIEW, context)
|
||||
|
||||
def get_icon_class(self):
|
||||
return self.child.get_icon_class() if self.child else 'other'
|
||||
|
||||
@@ -11,7 +11,7 @@ from .exceptions import NotFoundError
|
||||
from .fields import Date
|
||||
from .mako_module import MakoModuleDescriptor
|
||||
from .progress import Progress
|
||||
from .x_module import XModule
|
||||
from .x_module import XModule, STUDENT_VIEW
|
||||
from .xml_module import XmlDescriptor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -85,7 +85,7 @@ class SequenceModule(SequenceFields, XModule):
|
||||
|
||||
for child in self.get_display_items():
|
||||
progress = child.get_progress()
|
||||
rendered_child = child.render('student_view', context)
|
||||
rendered_child = child.render(STUDENT_VIEW, context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
titles = child.get_content_titles()
|
||||
|
||||
@@ -6,12 +6,11 @@ import logging
|
||||
import json
|
||||
from webob import Response
|
||||
from uuid import uuid4
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.studio_editable import StudioEditableModule
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
|
||||
from xmodule.x_module import XModule, module_attr, STUDENT_VIEW
|
||||
from xmodule.modulestore.inheritance import UserPartitionList
|
||||
|
||||
from lxml import etree
|
||||
@@ -51,6 +50,23 @@ class ValidationMessageType(object):
|
||||
return None
|
||||
|
||||
|
||||
# TODO: move this into the xblock repo once it has a formal validation contract
|
||||
class ValidationMessage(object):
|
||||
"""
|
||||
Represents a single validation message for an xblock.
|
||||
"""
|
||||
def __init__(self, xblock, message_text, message_type, action_class=None, action_label=None):
|
||||
assert isinstance(message_text, unicode)
|
||||
self.xblock = xblock
|
||||
self.message_text = message_text
|
||||
self.message_type = message_type
|
||||
self.action_class = action_class
|
||||
self.action_label = action_label
|
||||
|
||||
def __unicode__(self):
|
||||
return self.message_text
|
||||
|
||||
|
||||
class SplitTestFields(object):
|
||||
"""Fields needed for split test module"""
|
||||
has_children = True
|
||||
@@ -62,12 +78,15 @@ class SplitTestFields(object):
|
||||
no_partition_selected = {'display_name': _("Not Selected"), 'value': -1}
|
||||
|
||||
@staticmethod
|
||||
def build_partition_values(all_user_partitions):
|
||||
def build_partition_values(all_user_partitions, selected_user_partition):
|
||||
"""
|
||||
This helper method builds up the user_partition values that will
|
||||
be passed to the Studio editor
|
||||
"""
|
||||
SplitTestFields.user_partition_values = [SplitTestFields.no_partition_selected]
|
||||
SplitTestFields.user_partition_values = []
|
||||
# Add "No selection" value if there is not a valid selected user partition.
|
||||
if not selected_user_partition:
|
||||
SplitTestFields.user_partition_values.append(SplitTestFields.no_partition_selected)
|
||||
for user_partition in all_user_partitions:
|
||||
SplitTestFields.user_partition_values.append({"display_name": user_partition.name, "value": user_partition.id})
|
||||
return SplitTestFields.user_partition_values
|
||||
@@ -87,7 +106,7 @@ class SplitTestFields(object):
|
||||
)
|
||||
|
||||
user_partition_id = Integer(
|
||||
help=_("The configuration for how users are grouped for this content experiment. After you select the group configuration and save the content experiment, you cannot change this setting."),
|
||||
help=_("The configuration defines how users are grouped for this content experiment. Caution: Changing the group configuration of a student-visible experiment will impact the experiment data."),
|
||||
scope=Scope.content,
|
||||
display_name=_("Group Configuration"),
|
||||
default=no_partition_selected["value"],
|
||||
@@ -108,7 +127,6 @@ class SplitTestFields(object):
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
|
||||
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
||||
@XBlock.wants('partitions')
|
||||
class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
@@ -220,7 +238,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
child_location = self.group_id_to_child[group_id]
|
||||
child_descriptor = self.get_child_descriptor_by_location(child_location)
|
||||
child = self.system.get_module(child_descriptor)
|
||||
rendered_child = child.render('student_view', context)
|
||||
rendered_child = child.render(STUDENT_VIEW, context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
@@ -238,35 +256,55 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
fragment.initialize_js('ABTestSelector')
|
||||
return fragment
|
||||
|
||||
def studio_preview_view(self, context):
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview by rendering each child so that they can all be seen and edited.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_configured = not self.user_partition_id == SplitTestFields.no_partition_selected['value']
|
||||
is_root = root_xblock and root_xblock.location == self.location
|
||||
active_groups_preview = None
|
||||
inactive_groups_preview = None
|
||||
if is_root:
|
||||
[active_children, inactive_children] = self.descriptor.active_and_inactive_children()
|
||||
active_groups_preview = self.studio_render_children(
|
||||
fragment, active_children, context
|
||||
)
|
||||
inactive_groups_preview = self.studio_render_children(
|
||||
fragment, inactive_children, context
|
||||
)
|
||||
|
||||
# First render a header at the top of the split test module...
|
||||
fragment.add_content(self.system.render_template('split_test_studio_header.html', {
|
||||
fragment.add_content(self.system.render_template('split_test_author_view.html', {
|
||||
'split_test': self,
|
||||
'is_root': is_root,
|
||||
'is_configured': is_configured,
|
||||
'active_groups_preview': active_groups_preview,
|
||||
'inactive_groups_preview': inactive_groups_preview,
|
||||
}))
|
||||
|
||||
# ... then render the children only when this block is being shown as the container
|
||||
if is_root:
|
||||
self.render_children(context, fragment, can_reorder=False)
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_author_view.js'))
|
||||
fragment.initialize_js('SplitTestAuthorView')
|
||||
|
||||
return fragment
|
||||
|
||||
def studio_render_children(self, fragment, children, context):
|
||||
"""
|
||||
Renders the specified children and returns it as an HTML string. In addition, any
|
||||
dependencies are added to the specified fragment.
|
||||
"""
|
||||
html = ""
|
||||
for active_child_descriptor in children:
|
||||
active_child = self.system.get_module(active_child_descriptor)
|
||||
rendered_child = active_child.render(StudioEditableModule.get_preview_view_name(active_child), context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
html = html + rendered_child.content
|
||||
return html
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Render the contents of the chosen condition for students, and all the
|
||||
Renders the contents of the chosen condition for students, and all the
|
||||
conditions for staff.
|
||||
"""
|
||||
# When rendering a Studio preview, render all of the block's children
|
||||
if context and context.get('runtime_type', None) == 'studio':
|
||||
return self.studio_preview_view(context)
|
||||
|
||||
if self.child is None:
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return Fragment(content=u"<div>Nothing here. Move along.</div>")
|
||||
@@ -274,7 +312,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
if self.system.user_is_staff:
|
||||
return self._staff_view(context)
|
||||
else:
|
||||
child_fragment = self.child.render('student_view', context)
|
||||
child_fragment = self.child.render(STUDENT_VIEW, context)
|
||||
fragment = Fragment(self.system.render_template('split_test_student_view.html', {
|
||||
'child_content': child_fragment.content,
|
||||
'child_id': self.child.scope_ids.usage_id,
|
||||
@@ -305,14 +343,13 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
|
||||
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
||||
@XBlock.wants('partitions')
|
||||
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
module_class = SplitTestModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
mako_template = "widgets/split-edit.html"
|
||||
css = {'scss': [resource_string(__name__, 'css/split_test/edit.scss')]}
|
||||
mako_template = "widgets/metadata-only-edit.html"
|
||||
|
||||
child_descriptor = module_attr('child_descriptor')
|
||||
log_child_render = module_attr('log_child_render')
|
||||
@@ -359,8 +396,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
def get_context(self):
|
||||
_context = super(SplitTestDescriptor, self).get_context()
|
||||
_context.update({
|
||||
'disable_user_partition_editing': self._disable_user_partition_editing(),
|
||||
'selected_partition': self._get_selected_partition()
|
||||
'selected_partition': self.get_selected_partition()
|
||||
})
|
||||
return _context
|
||||
|
||||
@@ -380,41 +416,31 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
# Any existing value of user_partition_id will be in "old_content" instead of "old_metadata"
|
||||
# because it is Scope.content.
|
||||
if 'user_partition_id' not in old_content or old_content['user_partition_id'] != self.user_partition_id:
|
||||
selected_partition = self._get_selected_partition()
|
||||
selected_partition = self.get_selected_partition()
|
||||
if selected_partition is not None:
|
||||
assert hasattr(self.system, 'modulestore') and hasattr(self.system.modulestore, 'create_and_save_xmodule'), \
|
||||
"editor_saved should only be called when a mutable modulestore is available"
|
||||
modulestore = self.system.modulestore
|
||||
group_id_mapping = {}
|
||||
self.group_id_mapping = {} # pylint: disable=attribute-defined-outside-init
|
||||
for group in selected_partition.groups:
|
||||
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex)
|
||||
metadata = {'display_name': group.name}
|
||||
modulestore.create_and_save_xmodule(
|
||||
dest_usage_key,
|
||||
definition_data=None,
|
||||
metadata=metadata,
|
||||
system=self.system,
|
||||
)
|
||||
self.children.append(dest_usage_key) # pylint: disable=no-member
|
||||
group_id_mapping[unicode(group.id)] = dest_usage_key
|
||||
|
||||
self.group_id_to_child = group_id_mapping
|
||||
self._create_vertical_for_group(group)
|
||||
# Don't need to call update_item in the modulestore because the caller of this method will do it.
|
||||
else:
|
||||
# If children referenced in group_id_to_child have been deleted, remove them from the map.
|
||||
for str_group_id, usage_key in self.group_id_to_child.items():
|
||||
if usage_key not in self.children: # pylint: disable=no-member
|
||||
del self.group_id_to_child[str_group_id]
|
||||
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
# Update the list of partitions based on the currently available user_partitions.
|
||||
SplitTestFields.build_partition_values(self.user_partitions)
|
||||
SplitTestFields.build_partition_values(self.user_partitions, self.get_selected_partition())
|
||||
|
||||
editable_fields = super(SplitTestDescriptor, self).editable_metadata_fields
|
||||
|
||||
if not self._disable_user_partition_editing():
|
||||
# Explicitly add user_partition_id, which does not automatically get picked up because it is Scope.content.
|
||||
# Note that this means it will be saved by the Studio editor as "metadata", but the field will
|
||||
# still update correctly.
|
||||
editable_fields[SplitTestFields.user_partition_id.name] = self._create_metadata_editor_info(
|
||||
SplitTestFields.user_partition_id
|
||||
)
|
||||
# Explicitly add user_partition_id, which does not automatically get picked up because it is Scope.content.
|
||||
# Note that this means it will be saved by the Studio editor as "metadata", but the field will
|
||||
# still update correctly.
|
||||
editable_fields[SplitTestFields.user_partition_id.name] = self._create_metadata_editor_info(
|
||||
SplitTestFields.user_partition_id
|
||||
)
|
||||
|
||||
return editable_fields
|
||||
|
||||
@@ -427,13 +453,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
def _disable_user_partition_editing(self):
|
||||
"""
|
||||
If user_partition_id has been set to anything besides the default value, disable editing.
|
||||
"""
|
||||
return self.user_partition_id != SplitTestFields.user_partition_id.default
|
||||
|
||||
def _get_selected_partition(self):
|
||||
def get_selected_partition(self):
|
||||
"""
|
||||
Returns the partition that this split module is currently using, or None
|
||||
if the currently selected partition ID does not match any of the defined partitions.
|
||||
@@ -444,23 +464,121 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
|
||||
return None
|
||||
|
||||
def validation_message(self):
|
||||
def active_and_inactive_children(self):
|
||||
"""
|
||||
Returns a validation message describing the current state of the block, as well as a message type
|
||||
indicating whether the message represents information, a warning or an error.
|
||||
Returns two values:
|
||||
1. The active children of this split test, in the order of the groups.
|
||||
2. The remaining (inactive) children, in the order they were added to the split test.
|
||||
"""
|
||||
children = self.get_children()
|
||||
|
||||
user_partition = self.get_selected_partition()
|
||||
if not user_partition:
|
||||
return [], children
|
||||
|
||||
def get_child_descriptor(location):
|
||||
"""
|
||||
Returns the child descriptor which matches the specified location, or None if one is not found.
|
||||
"""
|
||||
for child in children:
|
||||
if child.location == location:
|
||||
return child
|
||||
return None
|
||||
|
||||
# Compute the active children in the order specified by the user partition
|
||||
active_children = []
|
||||
for group in user_partition.groups:
|
||||
group_id = unicode(group.id)
|
||||
child_location = self.group_id_to_child.get(group_id, None)
|
||||
child = get_child_descriptor(child_location)
|
||||
if child:
|
||||
active_children.append(child)
|
||||
|
||||
# Compute the inactive children in the order they were added to the split test
|
||||
inactive_children = [child for child in children if child not in active_children]
|
||||
|
||||
return active_children, inactive_children
|
||||
|
||||
def validation_messages(self):
|
||||
"""
|
||||
Returns a list of validation messages describing the current state of the block. Each message
|
||||
includes a message type indicating whether the message represents information, a warning or an error.
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
|
||||
messages = []
|
||||
if self.user_partition_id < 0:
|
||||
return _(u"You must select a group configuration for this content experiment."), ValidationMessageType.warning
|
||||
user_partition = self._get_selected_partition()
|
||||
if not user_partition:
|
||||
return \
|
||||
_(u"This content experiment will not be shown to students because it refers to a group configuration that has been deleted. You can delete this experiment or reinstate the group configuration to repair it."), \
|
||||
ValidationMessageType.error
|
||||
groups = user_partition.groups
|
||||
if not len(groups) == len(self.get_children()):
|
||||
return _(u"This content experiment is in an invalid state and cannot be repaired. Please delete and recreate."), ValidationMessageType.error
|
||||
messages.append(ValidationMessage(
|
||||
self,
|
||||
_(u"The experiment is not associated with a group configuration."),
|
||||
ValidationMessageType.warning,
|
||||
'edit-button',
|
||||
_(u"Select a Group Configuration")
|
||||
))
|
||||
else:
|
||||
user_partition = self.get_selected_partition()
|
||||
if not user_partition:
|
||||
messages.append(ValidationMessage(
|
||||
self,
|
||||
_(u"The experiment uses a deleted group configuration. Select a valid group configuration or delete this experiment."),
|
||||
ValidationMessageType.error
|
||||
))
|
||||
else:
|
||||
[active_children, inactive_children] = self.active_and_inactive_children()
|
||||
if len(active_children) < len(user_partition.groups):
|
||||
messages.append(ValidationMessage(
|
||||
self,
|
||||
_(u"The experiment does not contain all of the groups in the configuration."),
|
||||
ValidationMessageType.error,
|
||||
'add-missing-groups-button',
|
||||
_(u"Add Missing Groups")
|
||||
))
|
||||
if len(inactive_children) > 0:
|
||||
messages.append(ValidationMessage(
|
||||
self,
|
||||
_(u"The experiment has an inactive group. Move content into active groups, then delete the inactive group."),
|
||||
ValidationMessageType.warning
|
||||
))
|
||||
return messages
|
||||
|
||||
return _(u"This content experiment uses group configuration '{experiment_name}'.").format(
|
||||
experiment_name=user_partition.name
|
||||
), ValidationMessageType.information
|
||||
@XBlock.handler
|
||||
def add_missing_groups(self, request, suffix=''): # pylint: disable=unused-argument
|
||||
"""
|
||||
Create verticals for any missing groups in the split test instance.
|
||||
|
||||
Called from Studio view.
|
||||
"""
|
||||
user_partition = self.get_selected_partition()
|
||||
|
||||
changed = False
|
||||
for group in user_partition.groups:
|
||||
str_group_id = unicode(group.id)
|
||||
if str_group_id not in self.group_id_to_child:
|
||||
self._create_vertical_for_group(group)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
# request does not have a user attribute, so pass None for user.
|
||||
self.system.modulestore.update_item(self, None)
|
||||
return Response()
|
||||
|
||||
def _create_vertical_for_group(self, group):
|
||||
"""
|
||||
Creates a vertical to associate with the group.
|
||||
|
||||
This appends the new vertical to the end of children, and updates group_id_to_child.
|
||||
A mutable modulestore is needed to call this method (will need to update after mixed
|
||||
modulestore work, currently relies on mongo's create_and_save_xmodule method).
|
||||
"""
|
||||
assert hasattr(self.system, 'modulestore') and hasattr(self.system.modulestore, 'create_and_save_xmodule'), \
|
||||
"editor_saved should only be called when a mutable modulestore is available"
|
||||
modulestore = self.system.modulestore
|
||||
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex)
|
||||
metadata = {'display_name': group.name}
|
||||
modulestore.create_and_save_xmodule(
|
||||
dest_usage_key,
|
||||
definition_data=None,
|
||||
metadata=metadata,
|
||||
system=self.system,
|
||||
)
|
||||
self.children.append(dest_usage_key) # pylint: disable=no-member
|
||||
self.group_id_to_child[unicode(group.id)] = dest_usage_key
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""
|
||||
Mixin to support editing in Studio.
|
||||
"""
|
||||
from xmodule.x_module import module_attr, STUDENT_VIEW, AUTHOR_VIEW
|
||||
|
||||
|
||||
class StudioEditableModule(object):
|
||||
"""
|
||||
Helper methods for supporting Studio editing of xblocks/xmodules.
|
||||
Helper methods for supporting Studio editing of xmodules.
|
||||
|
||||
This class is only intended to be used with an XModule, as it assumes the existence of
|
||||
self.descriptor and self.system.
|
||||
"""
|
||||
|
||||
def render_children(self, context, fragment, can_reorder=False, can_add=False, view_name='student_view'):
|
||||
def render_children(self, context, fragment, can_reorder=False, can_add=False):
|
||||
"""
|
||||
Renders the children of the module with HTML appropriate for Studio. If can_reorder is True,
|
||||
then the children will be rendered to support drag and drop.
|
||||
@@ -22,7 +23,7 @@ class StudioEditableModule(object):
|
||||
if can_reorder:
|
||||
context['reorderable_items'].add(child.location)
|
||||
child_module = self.system.get_module(child) # pylint: disable=E1101
|
||||
rendered_child = child_module.render(view_name, context)
|
||||
rendered_child = child_module.render(StudioEditableModule.get_preview_view_name(child_module), context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
@@ -36,3 +37,21 @@ class StudioEditableModule(object):
|
||||
'can_add': can_add,
|
||||
'can_reorder': can_reorder,
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def get_preview_view_name(block):
|
||||
"""
|
||||
Helper method for getting preview view name (student_view or author_view) for a given module.
|
||||
"""
|
||||
return AUTHOR_VIEW if hasattr(block, AUTHOR_VIEW) else STUDENT_VIEW
|
||||
|
||||
|
||||
class StudioEditableDescriptor(object):
|
||||
"""
|
||||
Helper mixin for supporting Studio editing of xmodules.
|
||||
|
||||
This class is only intended to be used with an XModule Descriptor. This class assumes that the associated
|
||||
XModule will have an "author_view" method for returning an editable preview view of the module.
|
||||
"""
|
||||
author_view = module_attr(AUTHOR_VIEW)
|
||||
has_author_view = True
|
||||
|
||||
@@ -25,6 +25,7 @@ from xmodule.combined_open_ended_module import CombinedOpenEndedModule
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xmodule.tests import get_test_system, test_util_open_ended
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from xmodule.tests.test_util_open_ended import (
|
||||
DummyModulestore, TEST_STATE_SA_IN,
|
||||
MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID,
|
||||
@@ -1041,7 +1042,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
self._handle_ajax("next_problem", {})
|
||||
self.assertEqual(self._module().current_task_number, 0)
|
||||
|
||||
html = self._module().render('student_view').content
|
||||
html = self._module().render(STUDENT_VIEW).content
|
||||
self.assertIsInstance(html, basestring)
|
||||
|
||||
rubric = self._handle_ajax("get_combined_rubric", {})
|
||||
@@ -1098,7 +1099,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
# Move to the next step in the problem
|
||||
self._handle_ajax("next_problem", {})
|
||||
self.assertEqual(self._module().current_task_number, 1)
|
||||
self._module().render('student_view')
|
||||
self._module().render(STUDENT_VIEW)
|
||||
|
||||
# Try to get the rubric from the module
|
||||
self._handle_ajax("get_combined_rubric", {})
|
||||
@@ -1131,7 +1132,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
self.assertEqual(module.current_task_number, 1)
|
||||
|
||||
# Get html and other data client will request
|
||||
module.render('student_view')
|
||||
module.render(STUDENT_VIEW)
|
||||
|
||||
self._handle_ajax("skip_post_assessment", {})
|
||||
|
||||
@@ -1167,7 +1168,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
# Move to the next step in the problem
|
||||
self._handle_ajax("next_problem", {})
|
||||
self.assertEqual(self._module().current_task_number, 1)
|
||||
self._module().render('student_view')
|
||||
self._module().render(STUDENT_VIEW)
|
||||
|
||||
# Try to get the rubric from the module
|
||||
self._handle_ajax("get_combined_rubric", {})
|
||||
@@ -1198,7 +1199,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore):
|
||||
self.assertEqual(module.current_task_number, 1)
|
||||
|
||||
# Get html and other data client will request
|
||||
module.render('student_view')
|
||||
module.render(STUDENT_VIEW)
|
||||
|
||||
self._handle_ajax("skip_post_assessment", {})
|
||||
|
||||
@@ -1268,7 +1269,7 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore):
|
||||
self._handle_ajax("next_problem", {})
|
||||
self.assertEqual(self._module().current_task_number, 0)
|
||||
|
||||
html = self._module().render('student_view').content
|
||||
html = self._module().render(STUDENT_VIEW).content
|
||||
self.assertIsInstance(html, basestring)
|
||||
|
||||
# Module should now be done
|
||||
|
||||
@@ -11,6 +11,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, CourseLocationGenerator
|
||||
from xmodule.conditional_module import ConditionalDescriptor
|
||||
from xmodule.tests import DATA_DIR, get_test_system, get_test_descriptor_system
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
|
||||
|
||||
ORG = 'test_org'
|
||||
@@ -129,7 +130,7 @@ class ConditionalModuleBasicTest(unittest.TestCase):
|
||||
modules = ConditionalFactory.create(self.test_system)
|
||||
# because get_test_system returns the repr of the context dict passed to render_template,
|
||||
# we reverse it here
|
||||
html = modules['cond_module'].render('student_view').content
|
||||
html = modules['cond_module'].render(STUDENT_VIEW).content
|
||||
expected = modules['cond_module'].xmodule_runtime.render_template('conditional_ajax.html', {
|
||||
'ajax_url': modules['cond_module'].xmodule_runtime.ajax_url,
|
||||
'element_id': u'i4x-edX-conditional_test-conditional-SampleConditional',
|
||||
@@ -219,7 +220,7 @@ class ConditionalModuleXmlTest(unittest.TestCase):
|
||||
print "module children: ", module.get_children()
|
||||
print "module display items (children): ", module.get_display_items()
|
||||
|
||||
html = module.render('student_view').content
|
||||
html = module.render(STUDENT_VIEW).content
|
||||
print "html type: ", type(html)
|
||||
print "html: ", html
|
||||
html_expect = module.xmodule_runtime.render_template(
|
||||
|
||||
@@ -8,6 +8,7 @@ import copy
|
||||
|
||||
from xmodule.crowdsource_hinter import CrowdsourceHinterModule
|
||||
from xmodule.vertical_module import VerticalModule, VerticalDescriptor
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
@@ -245,7 +246,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
"""
|
||||
return [FakeChild()]
|
||||
mock_module.get_display_items = fake_get_display_items
|
||||
out_html = mock_module.render('student_view').content
|
||||
out_html = mock_module.render(STUDENT_VIEW).content
|
||||
self.assertTrue('This is supposed to be test html.' in out_html)
|
||||
self.assertTrue('i4x://this/is/a/fake/id' in out_html)
|
||||
|
||||
@@ -262,7 +263,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
"""
|
||||
return []
|
||||
mock_module.get_display_items = fake_get_display_items
|
||||
out_html = mock_module.render('student_view').content
|
||||
out_html = mock_module.render(STUDENT_VIEW).content
|
||||
self.assertTrue('Error in loading crowdsourced hinter' in out_html)
|
||||
|
||||
@unittest.skip("Needs to be finished.")
|
||||
@@ -273,7 +274,7 @@ class CrowdsourceHinterTest(unittest.TestCase):
|
||||
NOT WORKING RIGHT NOW
|
||||
"""
|
||||
mock_module = VerticalWithModulesFactory.create()
|
||||
out_html = mock_module.render('student_view').content
|
||||
out_html = mock_module.render(STUDENT_VIEW).content
|
||||
self.assertTrue('Test numerical problem.' in out_html)
|
||||
self.assertTrue('Another test numerical problem.' in out_html)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from xmodule.tests import get_test_system
|
||||
from xmodule.error_module import ErrorDescriptor, ErrorModule, NonStaffErrorDescriptor
|
||||
from xmodule.modulestore.xml import CourseLocationGenerator
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
|
||||
from xmodule.x_module import XModuleDescriptor, XModule
|
||||
from xmodule.x_module import XModuleDescriptor, XModule, STUDENT_VIEW
|
||||
from mock import MagicMock, Mock, patch
|
||||
from xblock.runtime import Runtime, IdReader
|
||||
from xblock.field_data import FieldData
|
||||
@@ -39,7 +39,7 @@ class TestErrorModule(unittest.TestCase, SetupTestErrorModules):
|
||||
)
|
||||
self.assertIsInstance(descriptor, ErrorDescriptor)
|
||||
descriptor.xmodule_runtime = self.system
|
||||
context_repr = self.system.render(descriptor, 'student_view').content
|
||||
context_repr = self.system.render(descriptor, STUDENT_VIEW).content
|
||||
self.assertIn(self.error_msg, context_repr)
|
||||
self.assertIn(repr(self.valid_xml), context_repr)
|
||||
|
||||
@@ -53,7 +53,7 @@ class TestErrorModule(unittest.TestCase, SetupTestErrorModules):
|
||||
descriptor, self.error_msg)
|
||||
self.assertIsInstance(error_descriptor, ErrorDescriptor)
|
||||
error_descriptor.xmodule_runtime = self.system
|
||||
context_repr = self.system.render(error_descriptor, 'student_view').content
|
||||
context_repr = self.system.render(error_descriptor, STUDENT_VIEW).content
|
||||
self.assertIn(self.error_msg, context_repr)
|
||||
self.assertIn(repr(descriptor), context_repr)
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestNonStaffErrorModule(unittest.TestCase, SetupTestErrorModules):
|
||||
CourseLocationGenerator(self.course_id)
|
||||
)
|
||||
descriptor.xmodule_runtime = self.system
|
||||
context_repr = self.system.render(descriptor, 'student_view').content
|
||||
context_repr = self.system.render(descriptor, STUDENT_VIEW).content
|
||||
self.assertNotIn(self.error_msg, context_repr)
|
||||
self.assertNotIn(repr(self.valid_xml), context_repr)
|
||||
|
||||
@@ -94,7 +94,7 @@ class TestNonStaffErrorModule(unittest.TestCase, SetupTestErrorModules):
|
||||
descriptor, self.error_msg)
|
||||
self.assertIsInstance(error_descriptor, ErrorDescriptor)
|
||||
error_descriptor.xmodule_runtime = self.system
|
||||
context_repr = self.system.render(error_descriptor, 'student_view').content
|
||||
context_repr = self.system.render(error_descriptor, STUDENT_VIEW).content
|
||||
self.assertNotIn(self.error_msg, context_repr)
|
||||
self.assertNotIn(str(descriptor), context_repr)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from fs.memoryfs import MemoryFS
|
||||
from xmodule.tests.xml import factories as xml
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
||||
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields, ValidationMessageType
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
|
||||
@@ -113,7 +114,7 @@ class SplitTestModuleLMSTest(SplitTestModuleTest):
|
||||
|
||||
self.assertIn(
|
||||
child_content,
|
||||
self.module_system.render(self.split_test_module, 'student_view').content
|
||||
self.module_system.render(self.split_test_module, STUDENT_VIEW).content
|
||||
)
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@@ -159,35 +160,43 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
Unit tests for how split test interacts with Studio.
|
||||
"""
|
||||
|
||||
def test_render_studio_view(self):
|
||||
def test_render_author_view(self):
|
||||
"""
|
||||
Test the rendering of the Studio view.
|
||||
Test the rendering of the Studio author view.
|
||||
"""
|
||||
|
||||
def create_studio_context(root_xblock):
|
||||
"""
|
||||
Context for rendering the studio "author_view".
|
||||
"""
|
||||
return {
|
||||
'container_view': True,
|
||||
'reorderable_items': set(),
|
||||
'root_xblock': root_xblock,
|
||||
}
|
||||
|
||||
# The split_test module should render both its groups when it is the root
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': True,
|
||||
'reorderable_items': reorderable_items,
|
||||
'root_xblock': self.split_test_module,
|
||||
}
|
||||
html = self.module_system.render(self.split_test_module, 'student_view', context).content
|
||||
context = create_studio_context(self.split_test_module)
|
||||
html = self.module_system.render(self.split_test_module, AUTHOR_VIEW, context).content
|
||||
self.assertIn('HTML FOR GROUP 0', html)
|
||||
self.assertIn('HTML FOR GROUP 1', html)
|
||||
|
||||
# When rendering as a child, it shouldn't render either of its groups
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': True,
|
||||
'reorderable_items': reorderable_items,
|
||||
'root_xblock': self.course_sequence,
|
||||
}
|
||||
html = self.module_system.render(self.split_test_module, 'student_view', context).content
|
||||
context = create_studio_context(self.course_sequence)
|
||||
html = self.module_system.render(self.split_test_module, AUTHOR_VIEW, context).content
|
||||
self.assertNotIn('HTML FOR GROUP 0', html)
|
||||
self.assertNotIn('HTML FOR GROUP 1', html)
|
||||
|
||||
# The "Create Missing Groups" button should be rendered when groups are missing
|
||||
context = create_studio_context(self.split_test_module)
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
|
||||
]
|
||||
html = self.module_system.render(self.split_test_module, AUTHOR_VIEW, context).content
|
||||
self.assertIn('HTML FOR GROUP 0', html)
|
||||
self.assertIn('HTML FOR GROUP 1', html)
|
||||
|
||||
def test_editable_settings(self):
|
||||
"""
|
||||
Test the setting information passed back from editable_metadata_fields.
|
||||
@@ -197,14 +206,8 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
self.assertNotIn(SplitTestDescriptor.due.name, editable_metadata_fields)
|
||||
self.assertNotIn(SplitTestDescriptor.user_partitions.name, editable_metadata_fields)
|
||||
|
||||
# user_partition_id will only appear in the editable settings if the value is the
|
||||
# default "unselected" value. This split instance has user_partition_id = 0, so
|
||||
# user_partition_id will not be editable.
|
||||
self.assertNotIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
|
||||
|
||||
# Explicitly set user_partition_id to the default value. Now user_partition_id will be editable.
|
||||
self.split_test_module.user_partition_id = SplitTestFields.no_partition_selected['value']
|
||||
editable_metadata_fields = self.split_test_module.editable_metadata_fields
|
||||
# user_partition_id will always appear in editable_metadata_settings, regardless
|
||||
# of the selected value.
|
||||
self.assertIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
|
||||
|
||||
def test_non_editable_settings(self):
|
||||
@@ -223,6 +226,7 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
self.assertEqual([], SplitTestDescriptor.user_partition_id.values)
|
||||
|
||||
# user_partitions is empty, only the "Not Selected" item will appear.
|
||||
self.split_test_module.user_partition_id = SplitTestFields.no_partition_selected['value']
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
self.assertEqual(1, len(partitions))
|
||||
@@ -239,6 +243,67 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
self.assertEqual(0, partitions[1]['value'])
|
||||
self.assertEqual("first_partition", partitions[1]['display_name'])
|
||||
|
||||
# Try again with a selected partition and verify that there is no option for "No Selection"
|
||||
self.split_test_module.user_partition_id = 0
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
self.assertEqual(1, len(partitions))
|
||||
self.assertEqual(0, partitions[0]['value'])
|
||||
self.assertEqual("first_partition", partitions[0]['display_name'])
|
||||
|
||||
# Finally try again with an invalid selected partition and verify that "No Selection" is an option
|
||||
self.split_test_module.user_partition_id = 999
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
self.assertEqual(2, len(partitions))
|
||||
self.assertEqual(SplitTestFields.no_partition_selected['value'], partitions[0]['value'])
|
||||
self.assertEqual(0, partitions[1]['value'])
|
||||
self.assertEqual("first_partition", partitions[1]['display_name'])
|
||||
|
||||
def test_active_and_inactive_children(self):
|
||||
"""
|
||||
Tests the active and inactive children returned for different split test configurations.
|
||||
"""
|
||||
split_test_module = self.split_test_module
|
||||
children = split_test_module.get_children()
|
||||
|
||||
# Verify that a split test has no active children if it has no specified user partition.
|
||||
split_test_module.user_partition_id = -1
|
||||
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
|
||||
self.assertEqual(active_children, [])
|
||||
self.assertEqual(inactive_children, children)
|
||||
|
||||
# Verify that all the children are returned as active for a correctly configured split_test
|
||||
split_test_module.user_partition_id = 0
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
|
||||
]
|
||||
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
|
||||
self.assertEqual(active_children, children)
|
||||
self.assertEqual(inactive_children, [])
|
||||
|
||||
# Verify that a split_test does not return inactive children in the active children
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha')])
|
||||
]
|
||||
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
|
||||
self.assertEqual(active_children, [children[0]])
|
||||
self.assertEqual(inactive_children, [children[1]])
|
||||
|
||||
# Verify that a split_test ignores misconfigured children
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("2", 'gamma')])
|
||||
]
|
||||
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
|
||||
self.assertEqual(active_children, [children[0]])
|
||||
self.assertEqual(inactive_children, [children[1]])
|
||||
|
||||
# Verify that a split_test referring to a non-existent user partition has no active children
|
||||
self.split_test_module.user_partition_id = 2
|
||||
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
|
||||
self.assertEqual(active_children, [])
|
||||
self.assertEqual(inactive_children, children)
|
||||
|
||||
def test_validation_message_types(self):
|
||||
"""
|
||||
Test the behavior of validation message types.
|
||||
@@ -249,46 +314,95 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
|
||||
def test_validation_messages(self):
|
||||
"""
|
||||
Test the validation messages produced for different split_test configurations.
|
||||
Test the validation messages produced for different split test configurations.
|
||||
"""
|
||||
split_test_module = self.split_test_module
|
||||
|
||||
def verify_validation_message(split_test_module, expected_message, expected_message_type):
|
||||
def verify_validation_message(message, expected_message, expected_message_type,
|
||||
expected_action_class=None, expected_action_label=None):
|
||||
"""
|
||||
Verify that the module has the expected validation message and type.
|
||||
Verify that the validation message has the expected validation message and type.
|
||||
"""
|
||||
(message, message_type) = split_test_module.validation_message()
|
||||
self.assertEqual(message, expected_message)
|
||||
self.assertEqual(message_type, expected_message_type)
|
||||
self.assertEqual(unicode(message), expected_message)
|
||||
self.assertEqual(message.message_type, expected_message_type)
|
||||
self.assertEqual(message.action_class, expected_action_class)
|
||||
self.assertEqual(message.action_label, expected_action_label)
|
||||
|
||||
# Verify the message for an unconfigured experiment
|
||||
self.split_test_module.user_partition_id = -1
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"You must select a group configuration for this content experiment.",
|
||||
ValidationMessageType.warning)
|
||||
# Verify the messages for an unconfigured user partition
|
||||
split_test_module.user_partition_id = -1
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 1)
|
||||
verify_validation_message(
|
||||
messages[0],
|
||||
u"The experiment is not associated with a group configuration.",
|
||||
ValidationMessageType.warning,
|
||||
'edit-button',
|
||||
u"Select a Group Configuration",
|
||||
)
|
||||
|
||||
# Verify the message for a correctly configured experiment
|
||||
self.split_test_module.user_partition_id = 0
|
||||
self.split_test_module.user_partitions = [
|
||||
# Verify the messages for a correctly configured split_test
|
||||
split_test_module.user_partition_id = 0
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
|
||||
]
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment uses group configuration 'first_partition'.",
|
||||
ValidationMessageType.information)
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 0)
|
||||
|
||||
# Verify the message for a block with the wrong number of groups
|
||||
self.split_test_module.user_partitions = [
|
||||
# Verify the messages for a split test with too few groups
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
|
||||
]
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment is in an invalid state and cannot be repaired. "
|
||||
u"Please delete and recreate.",
|
||||
ValidationMessageType.error)
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 1)
|
||||
verify_validation_message(
|
||||
messages[0],
|
||||
u"The experiment does not contain all of the groups in the configuration.",
|
||||
ValidationMessageType.error,
|
||||
'add-missing-groups-button',
|
||||
u"Add Missing Groups"
|
||||
)
|
||||
|
||||
# Verify the message for a block referring to a non-existent experiment
|
||||
self.split_test_module.user_partition_id = 2
|
||||
verify_validation_message(self.split_test_module,
|
||||
u"This content experiment will not be shown to students because it refers "
|
||||
u"to a group configuration that has been deleted. "
|
||||
u"You can delete this experiment or reinstate the group configuration to repair it.",
|
||||
ValidationMessageType.error)
|
||||
# Verify the messages for a split test with children that are not associated with any group
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha')])
|
||||
]
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 1)
|
||||
verify_validation_message(
|
||||
messages[0],
|
||||
u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
|
||||
ValidationMessageType.warning
|
||||
)
|
||||
|
||||
# Verify the messages for a split test with both missing and inactive children
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition',
|
||||
[Group("0", 'alpha'), Group("2", 'gamma')])
|
||||
]
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 2)
|
||||
verify_validation_message(
|
||||
messages[0],
|
||||
u"The experiment does not contain all of the groups in the configuration.",
|
||||
ValidationMessageType.error,
|
||||
'add-missing-groups-button',
|
||||
u"Add Missing Groups"
|
||||
)
|
||||
verify_validation_message(
|
||||
messages[1],
|
||||
u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.",
|
||||
ValidationMessageType.warning
|
||||
)
|
||||
|
||||
# Verify the messages for a split test referring to a non-existent user partition
|
||||
split_test_module.user_partition_id = 2
|
||||
messages = split_test_module.validation_messages()
|
||||
self.assertEqual(len(messages), 1)
|
||||
verify_validation_message(
|
||||
messages[0],
|
||||
u"The experiment uses a deleted group configuration. "
|
||||
u"Select a valid group configuration or delete this experiment.",
|
||||
ValidationMessageType.error
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for StudioEditableModule.
|
||||
"""
|
||||
|
||||
from xmodule.tests.test_vertical import BaseVerticalModuleTest
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
|
||||
|
||||
class StudioEditableModuleTestCase(BaseVerticalModuleTest):
|
||||
@@ -12,7 +13,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
|
||||
"""
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': True,
|
||||
'reorderable_items': reorderable_items,
|
||||
'read_only': False,
|
||||
@@ -20,6 +20,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
|
||||
}
|
||||
|
||||
# Both children of the vertical should be rendered as reorderable
|
||||
self.module_system.render(self.vertical, 'student_view', context).content
|
||||
self.module_system.render(self.vertical, AUTHOR_VIEW, context).content # pylint: disable=expression-not-assigned
|
||||
self.assertIn(self.vertical.get_children()[0].location, reorderable_items)
|
||||
self.assertIn(self.vertical.get_children()[1].location, reorderable_items)
|
||||
|
||||
@@ -6,6 +6,7 @@ from fs.memoryfs import MemoryFS
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests.xml import factories as xml
|
||||
from xmodule.x_module import STUDENT_VIEW, AUTHOR_VIEW
|
||||
|
||||
|
||||
class BaseVerticalModuleTest(XModuleXmlImportTest):
|
||||
@@ -46,30 +47,28 @@ class VerticalModuleTestCase(BaseVerticalModuleTest):
|
||||
"""
|
||||
Test the rendering of the student view.
|
||||
"""
|
||||
html = self.module_system.render(self.vertical, 'student_view', {}).content
|
||||
html = self.module_system.render(self.vertical, STUDENT_VIEW, {}).content
|
||||
self.assertIn(self.test_html_1, html)
|
||||
self.assertIn(self.test_html_2, html)
|
||||
|
||||
def test_render_studio_view(self):
|
||||
"""
|
||||
Test the rendering of the Studio view
|
||||
Test the rendering of the Studio author view
|
||||
"""
|
||||
# Vertical shouldn't render children on the unit page
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': False,
|
||||
}
|
||||
html = self.module_system.render(self.vertical, 'student_view', context).content
|
||||
html = self.module_system.render(self.vertical, AUTHOR_VIEW, context).content
|
||||
self.assertNotIn(self.test_html_1, html)
|
||||
self.assertNotIn(self.test_html_2, html)
|
||||
|
||||
# Vertical should render reorderable children on the container page
|
||||
reorderable_items = set()
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': True,
|
||||
'reorderable_items': reorderable_items,
|
||||
}
|
||||
html = self.module_system.render(self.vertical, 'student_view', context).content
|
||||
html = self.module_system.render(self.vertical, AUTHOR_VIEW, context).content
|
||||
self.assertIn(self.test_html_1, html)
|
||||
self.assertIn(self.test_html_2, html)
|
||||
|
||||
@@ -26,7 +26,7 @@ from xblock.fields import ScopeIds
|
||||
|
||||
from opaque_keys.edx.locations import Location
|
||||
|
||||
from xmodule.x_module import ModuleSystem, XModule, XModuleDescriptor, DescriptorSystem
|
||||
from xmodule.x_module import ModuleSystem, XModule, XModuleDescriptor, DescriptorSystem, STUDENT_VIEW, STUDIO_VIEW
|
||||
from xmodule.annotatable_module import AnnotatableDescriptor
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -324,7 +324,7 @@ class TestStudentView(XBlockWrapperTestMixin, TestCase):
|
||||
"""
|
||||
self.assertEqual(
|
||||
descriptor._xmodule.get_html(),
|
||||
descriptor.render('student_view').content
|
||||
descriptor.render(STUDENT_VIEW).content
|
||||
)
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ class TestStudioView(XBlockWrapperTestMixin, TestCase):
|
||||
"""
|
||||
Assert that studio_view and get_html render the same.
|
||||
"""
|
||||
self.assertEqual(descriptor.get_html(), descriptor.render('studio_view').content)
|
||||
self.assertEqual(descriptor.get_html(), descriptor.render(STUDIO_VIEW).content)
|
||||
|
||||
|
||||
class TestXModuleHandler(TestCase):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from xblock.fragment import Fragment
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.x_module import XModule, STUDENT_VIEW
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.studio_editable import StudioEditableModule
|
||||
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
|
||||
from pkg_resources import resource_string
|
||||
from copy import copy
|
||||
|
||||
@@ -19,13 +19,28 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
|
||||
''' Layout module for laying out submodules vertically.'''
|
||||
|
||||
def student_view(self, context):
|
||||
# When rendering a Studio preview, use a different template to support drag and drop.
|
||||
if context and context.get('runtime_type', None) == 'studio':
|
||||
return self.studio_preview_view(context)
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
|
||||
return self.render_view(context, 'vert_module.html')
|
||||
child_context = {} if not context else copy(context)
|
||||
child_context['child_of_vertical'] = True
|
||||
|
||||
def studio_preview_view(self, context):
|
||||
for child in self.get_display_items():
|
||||
rendered_child = child.render(STUDENT_VIEW, child_context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'id': child.location.to_deprecated_string(),
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
fragment.add_content(self.system.render_template('vert_module.html', {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
}))
|
||||
return fragment
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view, which supports drag and drop.
|
||||
"""
|
||||
@@ -36,31 +51,6 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
|
||||
self.render_children(context, fragment, can_reorder=True, can_add=True)
|
||||
return fragment
|
||||
|
||||
def render_view(self, context, template_name):
|
||||
"""
|
||||
Helper method for rendering student_view and the Studio version.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
|
||||
child_context = {} if not context else copy(context)
|
||||
child_context['child_of_vertical'] = True
|
||||
|
||||
for child in self.get_display_items():
|
||||
rendered_child = child.render('student_view', child_context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'id': child.location.to_deprecated_string(),
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
fragment.add_content(self.system.render_template(template_name, {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
}))
|
||||
return fragment
|
||||
|
||||
def get_progress(self):
|
||||
# TODO: Cache progress or children array?
|
||||
children = self.get_children()
|
||||
@@ -77,7 +67,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
|
||||
return new_class
|
||||
|
||||
|
||||
class VerticalDescriptor(VerticalFields, SequenceDescriptor):
|
||||
class VerticalDescriptor(VerticalFields, SequenceDescriptor, StudioEditableDescriptor):
|
||||
"""
|
||||
Descriptor class for editing verticals.
|
||||
"""
|
||||
module_class = VerticalModule
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
|
||||
|
||||
@@ -32,6 +32,25 @@ log = logging.getLogger(__name__)
|
||||
|
||||
XMODULE_METRIC_NAME = 'edxapp.xmodule'
|
||||
|
||||
# xblock view names
|
||||
|
||||
# This is the view that will be rendered to display the XBlock in the LMS.
|
||||
# It will also be used to render the block in "preview" mode in Studio, unless
|
||||
# the XBlock also implements author_view.
|
||||
STUDENT_VIEW = 'student_view'
|
||||
|
||||
# An optional view of the XBlock similar to student_view, but with possible inline
|
||||
# editing capabilities. This view differs from studio_view in that it should be as similar to student_view
|
||||
# as possible. When previewing XBlocks within Studio, Studio will prefer author_view to student_view.
|
||||
AUTHOR_VIEW = 'author_view'
|
||||
|
||||
# The view used to render an editor in Studio. The editor rendering can be completely different
|
||||
# from the LMS student_view, and it is only shown when the author selects "Edit".
|
||||
STUDIO_VIEW = 'studio_view'
|
||||
|
||||
# Views that present a "preview" view of an xblock (as opposed to an editing view).
|
||||
PREVIEW_VIEWS = [STUDENT_VIEW, AUTHOR_VIEW]
|
||||
|
||||
|
||||
class OpaqueKeyReader(IdReader):
|
||||
"""
|
||||
@@ -934,7 +953,7 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
get_score = module_attr('get_score')
|
||||
handle_ajax = module_attr('handle_ajax')
|
||||
max_score = module_attr('max_score')
|
||||
student_view = module_attr('student_view')
|
||||
student_view = module_attr(STUDENT_VIEW)
|
||||
get_child_descriptors = module_attr('get_child_descriptors')
|
||||
xmodule_handler = module_attr('xmodule_handler')
|
||||
|
||||
@@ -1138,7 +1157,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
|
||||
return result
|
||||
|
||||
def render(self, block, view_name, context=None):
|
||||
if view_name == 'student_view':
|
||||
if view_name in PREVIEW_VIEWS:
|
||||
assert block.xmodule_runtime is not None
|
||||
if isinstance(block, (XModule, XModuleDescriptor)):
|
||||
to_render = block._xmodule
|
||||
|
||||
@@ -200,6 +200,7 @@ class CourseFixture(StudioApiFixture):
|
||||
self._handouts = []
|
||||
self._children = []
|
||||
self._assets = []
|
||||
self._advanced_settings = {}
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
@@ -236,6 +237,12 @@ class CourseFixture(StudioApiFixture):
|
||||
"""
|
||||
self._assets.extend(asset_name)
|
||||
|
||||
def add_advanced_settings(self, settings):
|
||||
"""
|
||||
Adds advanced settings to be set on the course when the install method is called.
|
||||
"""
|
||||
self._advanced_settings.update(settings)
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Create the course and XBlocks within the course.
|
||||
@@ -248,6 +255,7 @@ class CourseFixture(StudioApiFixture):
|
||||
self._install_course_handouts()
|
||||
self._configure_course()
|
||||
self._upload_assets()
|
||||
self._add_advanced_settings()
|
||||
self._create_xblock_children(self._course_location, self._children)
|
||||
|
||||
return self
|
||||
@@ -415,6 +423,23 @@ class CourseFixture(StudioApiFixture):
|
||||
raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
|
||||
asset_name=asset_name, url=url, code=upload_response.status_code))
|
||||
|
||||
def _add_advanced_settings(self):
|
||||
"""
|
||||
Add advanced settings.
|
||||
"""
|
||||
url = STUDIO_BASE_URL + "/settings/advanced/" + self._course_key
|
||||
|
||||
# POST advanced settings to Studio
|
||||
response = self.session.post(
|
||||
url, data=self._encode_post_dict(self._advanced_settings),
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
"Could not update advanced details to '{0}' with {1}: Status was {2}.".format(
|
||||
self._advanced_settings, url, response.status_code))
|
||||
|
||||
def _create_xblock_children(self, parent_loc, xblock_descriptions):
|
||||
"""
|
||||
Recursively create XBlock children.
|
||||
@@ -489,6 +514,6 @@ class CourseFixture(StudioApiFixture):
|
||||
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
|
||||
"""
|
||||
return json.dumps({
|
||||
k: v.encode('utf-8') if v is not None else v
|
||||
k: v.encode('utf-8') if isinstance(v, basestring) else v
|
||||
for k, v in post_dict.items()
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ from bok_choy.page_object import PageObject
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from utils import click_css
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
|
||||
class ComponentEditorView(PageObject):
|
||||
@@ -40,7 +41,7 @@ class ComponentEditorView(PageObject):
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_setting_entry_index(self, label):
|
||||
def get_setting_element(self, label):
|
||||
"""
|
||||
Returns the index of the setting entry with given label (display name) within the Settings modal.
|
||||
"""
|
||||
@@ -48,15 +49,14 @@ class ComponentEditorView(PageObject):
|
||||
setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label'))
|
||||
for index, setting in enumerate(setting_labels):
|
||||
if setting.text == label:
|
||||
return index
|
||||
return self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting .setting-input'))[index]
|
||||
return None
|
||||
|
||||
def set_field_value_and_save(self, label, value):
|
||||
"""
|
||||
Set the field with given label (display name) to the specified value, and presses Save.
|
||||
Sets the text field with given label (display name) to the specified value, and presses Save.
|
||||
"""
|
||||
index = self.get_setting_entry_index(label)
|
||||
elem = self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting input.setting-input'))[index]
|
||||
elem = self.get_setting_element(label)
|
||||
# Click in the field, delete the value there.
|
||||
action = ActionChains(self.browser).click(elem)
|
||||
for _x in range(0, len(elem.get_attribute('value'))):
|
||||
@@ -64,3 +64,12 @@ class ComponentEditorView(PageObject):
|
||||
# Send the new text, then Tab to move to the next field (so change event is triggered).
|
||||
action.send_keys(value).send_keys(Keys.TAB).perform()
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def set_select_value_and_save(self, label, value):
|
||||
"""
|
||||
Sets the select with given label (display name) to the specified value, and presses Save.
|
||||
"""
|
||||
elem = self.get_setting_element(label)
|
||||
select = Select(elem)
|
||||
select.select_by_value(value)
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
@@ -54,7 +54,24 @@ class ContainerPage(PageObject):
|
||||
"""
|
||||
Return a list of xblocks loaded on the container page.
|
||||
"""
|
||||
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
|
||||
return self._get_xblocks()
|
||||
|
||||
@property
|
||||
def inactive_xblocks(self):
|
||||
"""
|
||||
Return a list of inactive xblocks loaded on the container page.
|
||||
"""
|
||||
return self._get_xblocks(".is-inactive ")
|
||||
|
||||
@property
|
||||
def active_xblocks(self):
|
||||
"""
|
||||
Return a list of active xblocks loaded on the container page.
|
||||
"""
|
||||
return self._get_xblocks(".is-active ")
|
||||
|
||||
def _get_xblocks(self, prefix=""):
|
||||
return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(
|
||||
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
|
||||
|
||||
def drag(self, source_index, target_index):
|
||||
@@ -77,15 +94,6 @@ class ContainerPage(PageObject):
|
||||
).release().perform()
|
||||
wait_for_notification(self)
|
||||
|
||||
def add_discussion(self, menu_index):
|
||||
"""
|
||||
Add a new instance of the discussion category.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
click_css(self, 'a>span.large-discussion-icon', menu_index)
|
||||
|
||||
def duplicate(self, source_index):
|
||||
"""
|
||||
Duplicate the item with index source_index (based on vertical placement in page).
|
||||
@@ -101,6 +109,11 @@ class ContainerPage(PageObject):
|
||||
click_css(self, 'a.button.action-primary', 0)
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Clicks the "edit" button for the first component on the page.
|
||||
|
||||
Same as the implementation in unit.py, unit and component pages will be merging.
|
||||
"""
|
||||
self.q(css='.edit-button').first.click()
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.xblock-studio_view').present,
|
||||
@@ -109,6 +122,18 @@ class ContainerPage(PageObject):
|
||||
|
||||
return self
|
||||
|
||||
def add_missing_groups(self):
|
||||
"""
|
||||
Click the "add missing groups" link.
|
||||
"""
|
||||
click_css(self, '.add-missing-groups-button')
|
||||
|
||||
def missing_groups_button_present(self):
|
||||
"""
|
||||
Returns True if the "add missing groups" button is present.
|
||||
"""
|
||||
return self.q(css='.add-missing-groups-button').present
|
||||
|
||||
|
||||
class XBlockWrapper(PageObject):
|
||||
"""
|
||||
@@ -161,4 +186,4 @@ class XBlockWrapper(PageObject):
|
||||
|
||||
@property
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view')
|
||||
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
|
||||
|
||||
@@ -27,7 +27,7 @@ class UnitPage(PageObject):
|
||||
|
||||
def _is_finished_loading():
|
||||
# Wait until all components have been loaded
|
||||
number_of_leaf_xblocks = len(self.q(css='{} .xblock-student_view'.format(Component.BODY_SELECTOR)).results)
|
||||
number_of_leaf_xblocks = len(self.q(css='{} .xblock-author_view,.xblock-student_view'.format(Component.BODY_SELECTOR)).results)
|
||||
is_done = len(self.q(css=Component.BODY_SELECTOR).results) == number_of_leaf_xblocks
|
||||
return (is_done, is_done)
|
||||
|
||||
@@ -99,9 +99,14 @@ class Component(PageObject):
|
||||
|
||||
@property
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view')
|
||||
return self._bounded_selector('.xblock-author_view,.xblock-student_view')
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Clicks the "edit" button for the first component on the page.
|
||||
|
||||
Same as the implementation in unit.py, unit and component pages will be merging.
|
||||
"""
|
||||
self.q(css=self._bounded_selector('.edit-button')).first.click()
|
||||
EmptyPromise(
|
||||
lambda: self.q(css='.xblock-studio_view').present,
|
||||
|
||||
@@ -9,10 +9,12 @@ def click_css(page, css, source_index=0, require_notification=True):
|
||||
"""
|
||||
Click the button/link with the given css and index on the specified page (subclass of PageObject).
|
||||
|
||||
Will only consider buttons with a non-zero size.
|
||||
|
||||
If require_notification is False (default value is True), the method will return immediately.
|
||||
Otherwise, it will wait for the "mini-notification" to appear and disappear.
|
||||
"""
|
||||
buttons = page.q(css=css)
|
||||
buttons = page.q(css=css).filter(lambda el: el.size['width'] > 0)
|
||||
target = buttons[source_index]
|
||||
ActionChains(page.browser).click(target).release().perform()
|
||||
if require_notification:
|
||||
@@ -31,5 +33,36 @@ def wait_for_notification(page):
|
||||
num_notifications = len(page.q(css='.wrapper-notification-mini.is-hiding'))
|
||||
return (num_notifications == 1, num_notifications)
|
||||
|
||||
Promise(_is_saving, 'Notification showing.').fulfill()
|
||||
Promise(_is_saving_done, 'Notification hidden.').fulfill()
|
||||
Promise(_is_saving, 'Notification should have been shown.').fulfill()
|
||||
Promise(_is_saving_done, 'Notification should have been hidden.').fulfill()
|
||||
|
||||
|
||||
def add_discussion(page, menu_index):
|
||||
"""
|
||||
Add a new instance of the discussion category.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
click_css(page, 'a>span.large-discussion-icon', menu_index)
|
||||
|
||||
|
||||
def add_advanced_component(page, menu_index, name):
|
||||
"""
|
||||
Adds an instance of the advanced component with the specified name.
|
||||
|
||||
menu_index specifies which instance of the menus should be used (based on vertical
|
||||
placement within the page).
|
||||
"""
|
||||
click_css(page, 'a>span.large-advanced-icon', menu_index, require_notification=False)
|
||||
|
||||
# Sporadically, the advanced component was not getting created after the click_css call on the category (below).
|
||||
# Try making sure that the menu of advanced components is visible before clicking (the HTML is always on the
|
||||
# page, but will have display none until the large-advanced-icon is clicked).
|
||||
def is_advanced_components_showing():
|
||||
advanced_buttons = page.q(css=".new-component-advanced").filter(lambda el: el.size['width'] > 0)
|
||||
return (len(advanced_buttons) == 1, len(advanced_buttons))
|
||||
|
||||
Promise(is_advanced_components_showing, "Advanced component menu not showing").fulfill()
|
||||
|
||||
click_css(page, 'a[data-category={}]'.format(name))
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
|
||||
from .helpers import UniqueCourseTest
|
||||
from ..pages.studio.component_editor import ComponentEditorView
|
||||
from ..pages.studio.utils import add_discussion
|
||||
|
||||
from unittest import skip
|
||||
|
||||
@@ -32,6 +33,87 @@ class ContainerBase(UniqueCourseTest):
|
||||
self.course_info['run']
|
||||
)
|
||||
|
||||
self.setup_fixtures()
|
||||
|
||||
self.auth_page = AutoAuthPage(
|
||||
self.browser,
|
||||
staff=False,
|
||||
username=self.user.get('username'),
|
||||
email=self.user.get('email'),
|
||||
password=self.user.get('password')
|
||||
)
|
||||
|
||||
self.auth_page.visit()
|
||||
|
||||
def setup_fixtures(self):
|
||||
pass
|
||||
|
||||
def go_to_container_page(self, make_draft=False):
|
||||
"""
|
||||
Go to the test container page.
|
||||
|
||||
If make_draft is true, the unit page (accessed on way to container page) will be put into draft mode.
|
||||
"""
|
||||
unit = self.go_to_unit_page(make_draft)
|
||||
container = unit.components[0].go_to_container()
|
||||
return container
|
||||
|
||||
def go_to_unit_page(self, make_draft=False):
|
||||
"""
|
||||
Go to the test unit page.
|
||||
|
||||
If make_draft is true, the unit page will be put into draft mode.
|
||||
"""
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
if make_draft:
|
||||
unit.edit_draft()
|
||||
return unit
|
||||
|
||||
def verify_ordering(self, container, expected_orderings):
|
||||
"""
|
||||
Verifies the expected ordering of xblocks on the page.
|
||||
"""
|
||||
xblocks = container.xblocks
|
||||
blocks_checked = set()
|
||||
for expected_ordering in expected_orderings:
|
||||
for xblock in xblocks:
|
||||
parent = expected_ordering.keys()[0]
|
||||
if xblock.name == parent:
|
||||
blocks_checked.add(parent)
|
||||
children = xblock.children
|
||||
expected_length = len(expected_ordering.get(parent))
|
||||
self.assertEqual(
|
||||
expected_length, len(children),
|
||||
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
|
||||
for idx, expected in enumerate(expected_ordering.get(parent)):
|
||||
self.assertEqual(expected, children[idx].name)
|
||||
blocks_checked.add(expected)
|
||||
break
|
||||
self.assertEqual(len(blocks_checked), len(xblocks))
|
||||
|
||||
def do_action_and_verify(self, action, expected_ordering):
|
||||
"""
|
||||
Perform the supplied action and then verify the resulting ordering.
|
||||
"""
|
||||
container = self.go_to_container_page(make_draft=True)
|
||||
action(container)
|
||||
|
||||
self.verify_ordering(container, expected_ordering)
|
||||
|
||||
# Reload the page to see that the change was persisted.
|
||||
container = self.go_to_container_page()
|
||||
self.verify_ordering(container, expected_ordering)
|
||||
|
||||
|
||||
class NestedVerticalTest(ContainerBase):
|
||||
__test__ = False
|
||||
|
||||
"""
|
||||
Sets up a course structure with nested verticals.
|
||||
"""
|
||||
def setup_fixtures(self):
|
||||
self.container_title = ""
|
||||
self.group_a = "Expand or Collapse\nGroup A"
|
||||
self.group_b = "Expand or Collapse\nGroup B"
|
||||
@@ -55,18 +137,6 @@ class ContainerBase(UniqueCourseTest):
|
||||
self.duplicate_label = "Duplicate of '{0}'"
|
||||
self.discussion_label = "Discussion"
|
||||
|
||||
self.setup_fixtures()
|
||||
|
||||
self.auth_page = AutoAuthPage(
|
||||
self.browser,
|
||||
staff=False,
|
||||
username=self.user.get('username'),
|
||||
email=self.user.get('email'),
|
||||
password=self.user.get('password')
|
||||
)
|
||||
self.auth_page.visit()
|
||||
|
||||
def setup_fixtures(self):
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
@@ -96,46 +166,8 @@ class ContainerBase(UniqueCourseTest):
|
||||
|
||||
self.user = course_fix.user
|
||||
|
||||
def go_to_container_page(self, make_draft=False):
|
||||
unit = self.go_to_unit_page(make_draft)
|
||||
container = unit.components[0].go_to_container()
|
||||
return container
|
||||
|
||||
def go_to_unit_page(self, make_draft=False):
|
||||
self.outline.visit()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
if make_draft:
|
||||
unit.edit_draft()
|
||||
return unit
|
||||
|
||||
def verify_ordering(self, container, expected_orderings):
|
||||
xblocks = container.xblocks
|
||||
for expected_ordering in expected_orderings:
|
||||
for xblock in xblocks:
|
||||
parent = expected_ordering.keys()[0]
|
||||
if xblock.name == parent:
|
||||
children = xblock.children
|
||||
expected_length = len(expected_ordering.get(parent))
|
||||
self.assertEqual(
|
||||
expected_length, len(children),
|
||||
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
|
||||
for idx, expected in enumerate(expected_ordering.get(parent)):
|
||||
self.assertEqual(expected, children[idx].name)
|
||||
break
|
||||
|
||||
def do_action_and_verify(self, action, expected_ordering):
|
||||
container = self.go_to_container_page(make_draft=True)
|
||||
action(container)
|
||||
|
||||
self.verify_ordering(container, expected_ordering)
|
||||
|
||||
# Reload the page to see that the change was persisted.
|
||||
container = self.go_to_container_page()
|
||||
self.verify_ordering(container, expected_ordering)
|
||||
|
||||
|
||||
class DragAndDropTest(ContainerBase):
|
||||
class DragAndDropTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of reordering within the container page.
|
||||
"""
|
||||
@@ -196,7 +228,7 @@ class DragAndDropTest(ContainerBase):
|
||||
|
||||
def add_new_components_and_rearrange(container):
|
||||
# Add a video component to Group 1
|
||||
container.add_discussion(group_a_menu)
|
||||
add_discussion(container, group_a_menu)
|
||||
# Duplicate the first item in Group A
|
||||
container.duplicate(self.group_a_item_1_action_index)
|
||||
|
||||
@@ -216,7 +248,7 @@ class DragAndDropTest(ContainerBase):
|
||||
self.do_action_and_verify(add_new_components_and_rearrange, expected_ordering)
|
||||
|
||||
|
||||
class AddComponentTest(ContainerBase):
|
||||
class AddComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of adding a component to the container page.
|
||||
"""
|
||||
@@ -224,7 +256,7 @@ class AddComponentTest(ContainerBase):
|
||||
|
||||
def add_and_verify(self, menu_index, expected_ordering):
|
||||
self.do_action_and_verify(
|
||||
lambda (container): container.add_discussion(menu_index),
|
||||
lambda (container): add_discussion(container, menu_index),
|
||||
expected_ordering
|
||||
)
|
||||
|
||||
@@ -256,7 +288,7 @@ class AddComponentTest(ContainerBase):
|
||||
self.add_and_verify(container_menu, expected_ordering)
|
||||
|
||||
|
||||
class DuplicateComponentTest(ContainerBase):
|
||||
class DuplicateComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of duplicating a component on the container page.
|
||||
"""
|
||||
@@ -302,7 +334,7 @@ class DuplicateComponentTest(ContainerBase):
|
||||
self.do_action_and_verify(duplicate_twice, expected_ordering)
|
||||
|
||||
|
||||
class DeleteComponentTest(ContainerBase):
|
||||
class DeleteComponentTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of deleting a component from the container page.
|
||||
"""
|
||||
@@ -319,10 +351,13 @@ class DeleteComponentTest(ContainerBase):
|
||||
{self.group_a: [self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{self.group_empty: []}]
|
||||
self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering)
|
||||
|
||||
# Group A itself has a delete icon now, so item_1 is index 1 instead of 0.
|
||||
group_a_item_1_delete_index = 1
|
||||
self.delete_and_verify(group_a_item_1_delete_index, expected_ordering)
|
||||
|
||||
|
||||
class EditContainerTest(ContainerBase):
|
||||
class EditContainerTest(NestedVerticalTest):
|
||||
"""
|
||||
Tests of editing a container.
|
||||
"""
|
||||
|
||||
156
common/test/acceptance/tests/test_studio_split_test.py
Normal file
156
common/test/acceptance/tests/test_studio_split_test.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Acceptance tests for Studio related to the split_test module.
|
||||
"""
|
||||
|
||||
from unittest import skip
|
||||
|
||||
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
|
||||
|
||||
from ..pages.studio.component_editor import ComponentEditorView
|
||||
from test_studio_container import ContainerBase
|
||||
from ..pages.studio.utils import add_advanced_component
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from bok_choy.promise import Promise
|
||||
|
||||
|
||||
class SplitTest(ContainerBase):
|
||||
"""
|
||||
Tests for creating and editing split test instances in Studio.
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def setup_fixtures(self):
|
||||
course_fix = CourseFixture(
|
||||
self.course_info['org'],
|
||||
self.course_info['number'],
|
||||
self.course_info['run'],
|
||||
self.course_info['display_name']
|
||||
)
|
||||
|
||||
course_fix.add_advanced_settings(
|
||||
{
|
||||
u"advanced_modules": ["split_test"],
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Configuration alpha,beta', 'first', [Group("0", 'alpha'), Group("1", 'beta')]).to_json(),
|
||||
UserPartition(1, 'Configuration 0,1,2', 'second', [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]).to_json()
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
course_fix.add_children(
|
||||
XBlockFixtureDesc('chapter', 'Test Section').add_children(
|
||||
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
|
||||
XBlockFixtureDesc('vertical', 'Test Unit')
|
||||
)
|
||||
)
|
||||
).install()
|
||||
|
||||
self.course_fix = course_fix
|
||||
|
||||
self.user = course_fix.user
|
||||
|
||||
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
|
||||
"""
|
||||
Check that the groups appear and are correctly categorized as to active and inactive.
|
||||
|
||||
Also checks that the "add missing groups" button/link is not present unless a value of False is passed
|
||||
for verify_missing_groups_not_present.
|
||||
"""
|
||||
def wait_for_xblocks_to_render():
|
||||
# First xblock is the container for the page, subtract 1.
|
||||
return (len(active_groups) + len(inactive_groups) == len(container.xblocks) - 1, len(active_groups))
|
||||
|
||||
Promise(wait_for_xblocks_to_render, "Number of xblocks on the page are incorrect").fulfill()
|
||||
|
||||
def check_xblock_names(expected_groups, actual_blocks):
|
||||
self.assertEqual(len(expected_groups), len(actual_blocks))
|
||||
for idx, expected in enumerate(expected_groups):
|
||||
self.assertEqual('Expand or Collapse\n{}'.format(expected), actual_blocks[idx].name)
|
||||
|
||||
check_xblock_names(active_groups, container.active_xblocks)
|
||||
check_xblock_names(inactive_groups, container.inactive_xblocks)
|
||||
|
||||
# Verify inactive xblocks appear after active xblocks
|
||||
check_xblock_names(active_groups + inactive_groups, container.xblocks[1:])
|
||||
|
||||
if verify_missing_groups_not_present:
|
||||
self.verify_add_missing_groups_button_not_present(container)
|
||||
|
||||
def verify_add_missing_groups_button_not_present(self, container):
|
||||
"""
|
||||
Checks that the "add missing gorups" button/link is not present.
|
||||
"""
|
||||
def missing_groups_button_not_present():
|
||||
button_present = container.missing_groups_button_present()
|
||||
return (not button_present, not button_present)
|
||||
|
||||
Promise(missing_groups_button_not_present, "Add missing groups button should not be showing.").fulfill()
|
||||
|
||||
def create_poorly_configured_split_instance(self):
|
||||
"""
|
||||
Creates a split test instance with a missing group and an inactive group.
|
||||
|
||||
Returns the container page.
|
||||
"""
|
||||
unit = self.go_to_unit_page(make_draft=True)
|
||||
add_advanced_component(unit, 0, 'split_test')
|
||||
container = self.go_to_container_page()
|
||||
container.edit()
|
||||
component_editor = ComponentEditorView(self.browser, container.locator)
|
||||
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
|
||||
self.course_fix.add_advanced_settings(
|
||||
{
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Configuration alpha,beta', 'first',
|
||||
[Group("0", 'alpha'), Group("2", 'gamma')]).to_json()
|
||||
]
|
||||
}
|
||||
)
|
||||
self.course_fix._add_advanced_settings()
|
||||
return self.go_to_container_page()
|
||||
|
||||
def test_create_and_select_group_configuration(self):
|
||||
"""
|
||||
Tests creating a split test instance on the unit page, and then
|
||||
assigning the group configuration.
|
||||
"""
|
||||
unit = self.go_to_unit_page(make_draft=True)
|
||||
add_advanced_component(unit, 0, 'split_test')
|
||||
container = self.go_to_container_page()
|
||||
container.edit()
|
||||
component_editor = ComponentEditorView(self.browser, container.locator)
|
||||
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
|
||||
self.verify_groups(container, ['alpha', 'beta'], [])
|
||||
|
||||
# Switch to the other group configuration. Must navigate again to the container page so
|
||||
# that there is only a single "editor" on the page.
|
||||
container = self.go_to_container_page()
|
||||
container.edit()
|
||||
component_editor = ComponentEditorView(self.browser, container.locator)
|
||||
component_editor.set_select_value_and_save('Group Configuration', 'Configuration 0,1,2')
|
||||
self.verify_groups(container, ['Group 0', 'Group 1', 'Group 2'], ['alpha', 'beta'])
|
||||
|
||||
# Reload the page to make sure the groups were persisted.
|
||||
container = self.go_to_container_page()
|
||||
self.verify_groups(container, ['Group 0', 'Group 1', 'Group 2'], ['alpha', 'beta'])
|
||||
|
||||
@skip("This fails periodically where it fails to trigger the add missing groups action.Dis")
|
||||
def test_missing_group(self):
|
||||
"""
|
||||
The case of a split test with invalid configuration (missing group).
|
||||
"""
|
||||
container = self.create_poorly_configured_split_instance()
|
||||
container.add_missing_groups()
|
||||
self.verify_groups(container, ['alpha', 'gamma'], ['beta'])
|
||||
|
||||
# Reload the page to make sure the groups were persisted.
|
||||
container = self.go_to_container_page()
|
||||
self.verify_groups(container, ['alpha', 'gamma'], ['beta'])
|
||||
|
||||
def test_delete_inactive_group(self):
|
||||
"""
|
||||
Test deleting an inactive group.
|
||||
"""
|
||||
container = self.create_poorly_configured_split_instance()
|
||||
container.delete(0)
|
||||
self.verify_groups(container, ['alpha'], [], verify_missing_groups_not_present=False)
|
||||
@@ -40,8 +40,9 @@ Class Features
|
||||
These are class attributes or functions that can be provided by an XBlock to customize behaviour
|
||||
in the LMS.
|
||||
|
||||
* student_view (XBlock view): This is the view that will be rendered to display
|
||||
the XBlock in the LMS.
|
||||
* student_view (XBlock view): This is the view that will be rendered to display the XBlock
|
||||
in the LMS. It will also be used to render the block in "preview" mode in Studio, unless
|
||||
the XBlock also implements author_view.
|
||||
* has_score (class property): True if this block should appear in the LMS progress page.
|
||||
* get_progress (method): See documentation in x_module.py:XModuleMixin.get_progress.
|
||||
* icon_class (class property): This can be one of (``other``, ``video``, or ``problem``), and
|
||||
@@ -77,7 +78,11 @@ Studio
|
||||
Class Features
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
* studio_view (XBlock.view): The view used to render an editor in Studio.
|
||||
* studio_view (XBlock.view): The view used to render an editor in Studio. The editor rendering can
|
||||
be completely different from the LMS student_view, and it is only shown when the author selects "Edit".
|
||||
* author_view (XBlock.view): An optional view of the XBlock similar to student_view, but with possible inline
|
||||
editing capabilities. This view differs from studio_view in that it should be as similar to student_view
|
||||
as possible. When previewing XBlocks within Studio, Studio will prefer author_view to student_view.
|
||||
|
||||
* non_editable_metadata_fields (property): A list of :class:`~xblock.fields.Field` objects that
|
||||
shouldn't be displayed in the default editing view for Studio.
|
||||
|
||||
@@ -15,6 +15,7 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
|
||||
from courseware.access import has_access
|
||||
from courseware.model_data import FieldDataCache
|
||||
@@ -196,7 +197,7 @@ def get_course_about_section(course, section_key):
|
||||
|
||||
if about_module is not None:
|
||||
try:
|
||||
html = about_module.render('student_view').content
|
||||
html = about_module.render(STUDENT_VIEW).content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
html = render_to_string('courseware/error-message.html', None)
|
||||
log.exception(
|
||||
@@ -250,7 +251,7 @@ def get_course_info_section(request, course, section_key):
|
||||
|
||||
if info_module is not None:
|
||||
try:
|
||||
html = info_module.render('student_view').content
|
||||
html = info_module.render(STUDENT_VIEW).content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
html = render_to_string('courseware/error-message.html', None)
|
||||
log.exception(
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
|
||||
from courseware.tests import BaseTestXmodule
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
@@ -108,7 +109,7 @@ class TestLTI(BaseTestXmodule):
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_lti_constructor(self):
|
||||
generated_content = self.item_descriptor.render('student_view').content
|
||||
generated_content = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
expected_content = self.runtime.render_template('lti.html', self.expected_context)
|
||||
self.assertEqual(generated_content, expected_content)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from xmodule.lti_module import LTIDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.x_module import XModuleDescriptor, STUDENT_VIEW
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
from courseware import module_render as render
|
||||
@@ -94,7 +94,7 @@ class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
)
|
||||
|
||||
# get the rendered HTML output which should have the rewritten link
|
||||
html = module.render('student_view').content
|
||||
html = module.render(STUDENT_VIEW).content
|
||||
|
||||
# See if the url got rewritten to the target link
|
||||
# note if the URL mapping changes then this assertion will break
|
||||
@@ -416,7 +416,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.course.id,
|
||||
wrap_xmodule_display=True,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
|
||||
self.assertIn('div class="xblock xblock-student_view xmodule_display xmodule_HtmlModule"', result_fragment.content)
|
||||
|
||||
@@ -429,7 +429,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.course.id,
|
||||
wrap_xmodule_display=False,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
|
||||
self.assertNotIn('div class="xblock xblock-student_view xmodule_display xmodule_HtmlModule"', result_fragment.content)
|
||||
|
||||
@@ -441,7 +441,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
|
||||
self.assertIn(
|
||||
'/c4x/{org}/{course}/asset/foo_content'.format(
|
||||
@@ -459,7 +459,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
|
||||
self.assertIn(
|
||||
'/c4x/{org}/{course}/asset/_file.jpg'.format(
|
||||
@@ -483,7 +483,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.course.id,
|
||||
static_asset_path="toy_course_dir",
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
self.assertIn('href="/static/toy_course_dir', result_fragment.content)
|
||||
|
||||
def test_course_image(self):
|
||||
@@ -509,7 +509,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
|
||||
self.assertIn(
|
||||
'/courses/{course_id}/bar/content'.format(
|
||||
@@ -590,14 +590,14 @@ class MongoViewInStudioTest(ViewInStudioTest):
|
||||
def test_view_in_studio_link_studio_course(self):
|
||||
"""Regular Studio courses should see 'View in Studio' links."""
|
||||
self.setup_mongo_course()
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
def test_view_in_studio_link_only_in_top_level_vertical(self):
|
||||
"""Regular Studio courses should not see 'View in Studio' for child verticals of verticals."""
|
||||
self.setup_mongo_course()
|
||||
# Render the parent vertical, then check that there is only a single "View Unit in Studio" link.
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
# The single "View Unit in Studio" link should appear before the first xmodule vertical definition.
|
||||
parts = result_fragment.content.split('xmodule_VerticalModule')
|
||||
self.assertEqual(3, len(parts), "Did not find two vertical modules")
|
||||
@@ -608,7 +608,7 @@ class MongoViewInStudioTest(ViewInStudioTest):
|
||||
def test_view_in_studio_link_xml_authored(self):
|
||||
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
|
||||
self.setup_mongo_course(course_edit_method='XML')
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
|
||||
@@ -622,19 +622,19 @@ class MixedViewInStudioTest(ViewInStudioTest):
|
||||
def test_view_in_studio_link_mongo_backed(self):
|
||||
"""Mixed mongo courses that are mongo backed should see 'View in Studio' links."""
|
||||
self.setup_mongo_course()
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
def test_view_in_studio_link_xml_authored(self):
|
||||
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
|
||||
self.setup_mongo_course(course_edit_method='XML')
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
def test_view_in_studio_link_xml_backed(self):
|
||||
"""Course in XML only modulestore should not see 'View in Studio' links."""
|
||||
self.setup_xml_course()
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ class XmlViewInStudioTest(ViewInStudioTest):
|
||||
def test_view_in_studio_link_xml_backed(self):
|
||||
"""Course in XML only modulestore should not see 'View in Studio' links."""
|
||||
self.setup_xml_course()
|
||||
result_fragment = self.module.render('student_view')
|
||||
result_fragment = self.module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('View Unit in Studio', result_fragment.content)
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ class TestStaffDebugInfo(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('Staff Debug', result_fragment.content)
|
||||
|
||||
def test_staff_debug_info_enabled(self):
|
||||
@@ -705,7 +705,7 @@ class TestStaffDebugInfo(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
self.assertIn('Staff Debug', result_fragment.content)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_HISTOGRAMS_TO_STAFF': False})
|
||||
@@ -717,7 +717,7 @@ class TestStaffDebugInfo(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
result_fragment = module.render('student_view')
|
||||
result_fragment = module.render(STUDENT_VIEW)
|
||||
self.assertNotIn('histrogram', result_fragment.content)
|
||||
|
||||
def test_histogram_enabled_for_unscored_xmodules(self):
|
||||
@@ -741,7 +741,7 @@ class TestStaffDebugInfo(ModuleStoreTestCase):
|
||||
field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
module.render('student_view')
|
||||
module.render(STUDENT_VIEW)
|
||||
self.assertFalse(mock_grade_histogram.called)
|
||||
|
||||
def test_histogram_enabled_for_scored_xmodules(self):
|
||||
@@ -764,7 +764,7 @@ class TestStaffDebugInfo(ModuleStoreTestCase):
|
||||
self.field_data_cache,
|
||||
self.course.id,
|
||||
)
|
||||
module.render('student_view')
|
||||
module.render(STUDENT_VIEW)
|
||||
self.assertTrue(mock_grade_histogram.called)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from webob import Request
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import editable_modulestore
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from . import BaseTestXmodule
|
||||
from .test_video_xml import SOURCE_XML
|
||||
from cache_toolbox.core import del_cached_content
|
||||
@@ -175,7 +176,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTranscriptAvailableTranslationsDispatch, self).setUp()
|
||||
self.item_descriptor.render('student_view')
|
||||
self.item_descriptor.render(STUDENT_VIEW)
|
||||
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
self.subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
|
||||
|
||||
@@ -234,7 +235,7 @@ class TestTranscriptDownloadDispatch(TestVideo):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTranscriptDownloadDispatch, self).setUp()
|
||||
self.item_descriptor.render('student_view')
|
||||
self.item_descriptor.render(STUDENT_VIEW)
|
||||
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
def test_download_transcript_not_exist(self):
|
||||
@@ -299,7 +300,7 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
|
||||
|
||||
def setUp(self):
|
||||
super(TestTranscriptTranslationGetDispatch, self).setUp()
|
||||
self.item_descriptor.render('student_view')
|
||||
self.item_descriptor.render(STUDENT_VIEW)
|
||||
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
def test_translation_fails(self):
|
||||
@@ -609,7 +610,7 @@ class TestGetTranscript(TestVideo):
|
||||
|
||||
def setUp(self):
|
||||
super(TestGetTranscript, self).setUp()
|
||||
self.item_descriptor.render('student_view')
|
||||
self.item_descriptor.render(STUDENT_VIEW)
|
||||
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
|
||||
|
||||
def test_good_transcript(self):
|
||||
|
||||
@@ -13,6 +13,7 @@ from xblock.field_data import DictFieldData
|
||||
from xmodule.video_module import create_youtube_string
|
||||
from xmodule.tests import get_test_descriptor_system
|
||||
from xmodule.video_module import VideoDescriptor
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
from . import BaseTestXmodule
|
||||
@@ -25,7 +26,7 @@ class TestVideoYouTube(TestVideo):
|
||||
|
||||
def test_video_constructor(self):
|
||||
"""Make sure that all parameters extracted correctly from xml"""
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
sources = json.dumps([u'example.mp4', u'example.webm'])
|
||||
|
||||
expected_context = {
|
||||
@@ -89,7 +90,7 @@ class TestVideoNonYouTube(TestVideo):
|
||||
"""Make sure that if the 'youtube' attribute is omitted in XML, then
|
||||
the template generates an empty string for the YouTube streams.
|
||||
"""
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
sources = json.dumps([u'example.mp4', u'example.webm'])
|
||||
|
||||
expected_context = {
|
||||
@@ -231,7 +232,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
self.item_descriptor, 'transcript', 'download'
|
||||
).rstrip('/?')
|
||||
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
|
||||
expected_context.update({
|
||||
'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt',
|
||||
@@ -344,7 +345,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
sources=data['sources']
|
||||
)
|
||||
self.initialize_module(data=DATA)
|
||||
context = self.item_descriptor.render('student_view').content
|
||||
context = self.item_descriptor.render(STUDENT_VIEW).content
|
||||
|
||||
expected_context = dict(initial_context)
|
||||
expected_context.update({
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from operator import itemgetter
|
||||
|
||||
from . import BaseTestXmodule
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
|
||||
|
||||
class TestWordCloud(BaseTestXmodule):
|
||||
@@ -242,7 +243,7 @@ class TestWordCloud(BaseTestXmodule):
|
||||
|
||||
def test_word_cloud_constructor(self):
|
||||
"""Make sure that all parameters extracted correclty from xml"""
|
||||
fragment = self.runtime.render(self.item_descriptor, 'student_view')
|
||||
fragment = self.runtime.render(self.item_descriptor, STUDENT_VIEW)
|
||||
|
||||
expected_context = {
|
||||
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url,
|
||||
|
||||
@@ -42,6 +42,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
from xmodule.tabs import CourseTabList, StaffGradingTab, PeerGradingTab, OpenEndedGradingTab
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
import shoppingcart
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
@@ -377,7 +378,7 @@ def index(request, course_id, chapter=None, section=None,
|
||||
|
||||
# Save where we are in the chapter
|
||||
save_child_position(chapter_module, section)
|
||||
context['fragment'] = section_module.render('student_view')
|
||||
context['fragment'] = section_module.render(STUDENT_VIEW)
|
||||
context['section_title'] = section_descriptor.display_name_with_default
|
||||
else:
|
||||
# section is none, so display a message
|
||||
@@ -865,7 +866,7 @@ def get_static_tab_contents(request, course, tab):
|
||||
html = ''
|
||||
if tab_module is not None:
|
||||
try:
|
||||
html = tab_module.render('student_view').content
|
||||
html = tab_module.render(STUDENT_VIEW).content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
html = render_to_string('courseware/error-message.html', None)
|
||||
log.exception(
|
||||
|
||||
89
lms/templates/split_test_author_view.html
Normal file
89
lms/templates/split_test_author_view.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from xmodule.split_test_module import ValidationMessageType %>
|
||||
|
||||
<%
|
||||
split_test = context.get('split_test')
|
||||
user_partition = split_test.descriptor.get_selected_partition()
|
||||
messages = split_test.descriptor.validation_messages()
|
||||
%>
|
||||
|
||||
% if is_root and not is_configured:
|
||||
<div class="no-container-content">
|
||||
% else:
|
||||
<div class="wrapper-xblock-message">
|
||||
% endif
|
||||
|
||||
% if user_partition:
|
||||
<div class="xblock-message information">
|
||||
<p>
|
||||
<span class="message-text">
|
||||
${_("This content experiment uses group configuration '{experiment_name}'.").format(experiment_name=user_partition.name)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
% endif
|
||||
% if len(messages) > 0:
|
||||
<%
|
||||
def get_validation_icon(validation_type):
|
||||
if validation_type == 'error':
|
||||
return 'icon-exclamation-sign'
|
||||
elif validation_type == 'warning':
|
||||
return 'icon-warning-sign'
|
||||
return None
|
||||
|
||||
error_messages = (message for message in messages if message.message_type==ValidationMessageType.error)
|
||||
has_errors = next(error_messages, False)
|
||||
aggregate_validation_class = 'has-errors' if has_errors else 'has-warnings'
|
||||
aggregate_validation_type = 'error' if has_errors else 'warning'
|
||||
%>
|
||||
<div class="xblock-message validation ${aggregate_validation_class}">
|
||||
% if is_configured:
|
||||
<p class="${aggregate_validation_type}"><i class="${get_validation_icon(aggregate_validation_type)}"></i>
|
||||
${_("This content experiment has issues that affect content visibility.")}
|
||||
</p>
|
||||
% endif
|
||||
% if is_root or not is_configured:
|
||||
<ul class="xblock-message-list">
|
||||
% for message in messages:
|
||||
<%
|
||||
message_type = message.message_type
|
||||
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
|
||||
%>
|
||||
<li class="xblock-message-item ${message_type}">
|
||||
% if not is_configured:
|
||||
<i class="${get_validation_icon(message_type)}"></i>
|
||||
% endif
|
||||
<span class="message-text">
|
||||
% if message_type_display_name:
|
||||
<span class="sr">${message_type_display_name}:</span>
|
||||
% endif
|
||||
${unicode(message)}
|
||||
|
||||
% if message.action_class:
|
||||
<a href="#" class="button action-button ${message.action_class}">
|
||||
<span class="action-button-text">${message.action_label}</span>
|
||||
</a>
|
||||
% endif
|
||||
</span>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
|
||||
</div>
|
||||
|
||||
% if is_root:
|
||||
<div class="wrapper-groups is-active">
|
||||
<h3 class="sr">${_("Active Groups")}</h3>
|
||||
${active_groups_preview}
|
||||
</div>
|
||||
|
||||
% if inactive_groups_preview:
|
||||
<div class="wrapper-groups is-inactive">
|
||||
<h3 class="title">${_("Inactive Groups")}</h3>
|
||||
${inactive_groups_preview}
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
@@ -1,47 +0,0 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from xmodule.split_test_module import ValidationMessageType %>
|
||||
|
||||
<%
|
||||
split_test = context.get('split_test')
|
||||
(message, message_type) = split_test.descriptor.validation_message()
|
||||
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
|
||||
is_configured = split_test.user_partition_id >= 0
|
||||
%>
|
||||
|
||||
% if message or not is_configured:
|
||||
% if is_root and not is_configured:
|
||||
<div class="no-container-content">
|
||||
% else:
|
||||
<div class="wrapper-xblock-message">
|
||||
<div class="xblock-message ${message_type}">
|
||||
% endif
|
||||
|
||||
% if not is_configured:
|
||||
<p><i class="icon-warning-sign"></i> ${_("You must select a group configuration for this content experiment.")}
|
||||
<a href="#" class="button edit-button action-button">
|
||||
<i class="icon-pencil"></i> <span class="action-button-text">${_("Select a Group Configuration")}</span>
|
||||
</a>
|
||||
</p>
|
||||
% else:
|
||||
<p>
|
||||
% if message_type == 'warning':
|
||||
<i class='icon-warning-sign'></i>
|
||||
% elif message_type == 'error':
|
||||
<i class='icon-exclamation-sign'></i>
|
||||
% endif
|
||||
<span class='message-text'>
|
||||
% if message_type_display_name:
|
||||
<span class='sr'>${message_type_display_name}:</span>
|
||||
% endif
|
||||
${message}
|
||||
</span>
|
||||
</p>
|
||||
% endif
|
||||
|
||||
% if is_root and not is_configured:
|
||||
</div>
|
||||
% else:
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
Reference in New Issue
Block a user