Merge pull request #6459 from edx/content-libraries
Content libraries MVP
This commit is contained in:
@@ -171,7 +171,7 @@ def log_into_studio(
|
||||
world.log_in(username=uname, password=password, email=email, name=name)
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
assert_in(uname, world.css_text('h2.title', timeout=10))
|
||||
assert_in(uname, world.css_text('span.account-username', timeout=10))
|
||||
|
||||
|
||||
def add_course_author(user, course):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
@@ -33,15 +34,19 @@ def i_create_a_course(step):
|
||||
create_a_course()
|
||||
|
||||
|
||||
@step('I click the course link in My Courses$')
|
||||
def i_click_the_course_link_in_my_courses(step):
|
||||
@step('I click the course link in Studio Home$')
|
||||
def i_click_the_course_link_in_studio_home(step): # pylint: disable=invalid-name
|
||||
course_css = 'a.course-link'
|
||||
world.css_click(course_css)
|
||||
|
||||
|
||||
@step('I see an error about the length of the org/course/run tuple')
|
||||
def i_see_error_about_length(step):
|
||||
assert world.css_has_text('#course_creation_error', 'The combined length of the organization, course number, and course run fields cannot be more than 65 characters.')
|
||||
assert world.css_has_text(
|
||||
'#course_creation_error',
|
||||
'The combined length of the organization, course number, '
|
||||
'and course run fields cannot be more than 65 characters.'
|
||||
)
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
@@ -52,8 +57,8 @@ def courseware_page_has_loaded_in_studio(step):
|
||||
assert world.is_css_present(course_title_css)
|
||||
|
||||
|
||||
@step('I see the course listed in My Courses$')
|
||||
def i_see_the_course_in_my_courses(step):
|
||||
@step('I see the course listed in Studio Home$')
|
||||
def i_see_the_course_in_studio_home(step):
|
||||
course_css = 'h3.class-title'
|
||||
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Feature: CMS.Help
|
||||
Scenario: Users can access online help within a course
|
||||
Given I have opened a new course in Studio
|
||||
|
||||
And I click the course link in My Courses
|
||||
And I click the course link in Studio Home
|
||||
Then I should see online help for "outline"
|
||||
|
||||
And I go to the course updates page
|
||||
|
||||
@@ -26,7 +26,7 @@ Feature: CMS.Sign in
|
||||
And I visit the url "/signin?next=http://www.google.com/"
|
||||
When I fill in and submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/course/"
|
||||
Then I should see that the path is "/home/"
|
||||
|
||||
Scenario: Login with mistyped credentials
|
||||
Given I have opened a new course in Studio
|
||||
@@ -41,4 +41,4 @@ Feature: CMS.Sign in
|
||||
Then I should not see a login error message
|
||||
And I submit the signin form
|
||||
And I wait for "2" seconds
|
||||
Then I should see that the path is "/course/"
|
||||
Then I should see that the path is "/home/"
|
||||
|
||||
@@ -25,7 +25,7 @@ def i_press_the_button_on_the_registration_form(step):
|
||||
|
||||
@step('I should see an email verification prompt')
|
||||
def i_should_see_an_email_verification_prompt(step):
|
||||
world.css_has_text('h1.page-header', u'My Courses')
|
||||
world.css_has_text('h1.page-header', u'Studio Home')
|
||||
world.css_has_text('div.msg h3.title', u'We need to verify your email address')
|
||||
|
||||
|
||||
|
||||
@@ -1166,11 +1166,10 @@ class ContentStoreTest(ContentStoreTestCase):
|
||||
|
||||
def test_course_index_view_with_no_courses(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
# Create a course so there is something to view
|
||||
resp = self.client.get_html('/course/')
|
||||
resp = self.client.get_html('/home/')
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
'<h1 class="page-header">Studio Home</h1>',
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
@@ -1189,7 +1188,7 @@ class ContentStoreTest(ContentStoreTestCase):
|
||||
def test_course_index_view_with_course(self):
|
||||
"""Test viewing the index page with an existing course"""
|
||||
CourseFactory.create(display_name='Robot Super Educational Course')
|
||||
resp = self.client.get_html('/course/')
|
||||
resp = self.client.get_html('/home/')
|
||||
self.assertContains(
|
||||
resp,
|
||||
'<h3 class="course-title">Robot Super Educational Course</h3>',
|
||||
@@ -1604,7 +1603,7 @@ class RerunCourseTest(ContentStoreTestCase):
|
||||
Asserts that the given course key is in the accessible course listing section of the html
|
||||
and NOT in the unsucceeded course action section of the html.
|
||||
"""
|
||||
course_listing = lxml.html.fromstring(self.client.get_html('/course/').content)
|
||||
course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
|
||||
self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 1)
|
||||
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0)
|
||||
|
||||
@@ -1613,7 +1612,7 @@ class RerunCourseTest(ContentStoreTestCase):
|
||||
Asserts that the given course key is in the unsucceeded course action section of the html
|
||||
and NOT in the accessible course listing section of the html.
|
||||
"""
|
||||
course_listing = lxml.html.fromstring(self.client.get_html('/course/').content)
|
||||
course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
|
||||
self.assertEqual(len(self.get_course_listing_elements(course_listing, course_key)), 0)
|
||||
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1)
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get_html('/course/')
|
||||
resp = self.client.get_html('/home/')
|
||||
self.assertContains(resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
'<h1 class="page-header">Studio Home</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
@@ -56,13 +56,13 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get_html(
|
||||
'/course/',
|
||||
'/home/',
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='en',
|
||||
)
|
||||
|
||||
self.assertContains(resp,
|
||||
'<h1 class="page-header">My Courses</h1>',
|
||||
'<h1 class="page-header">Studio Home</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
@@ -81,7 +81,7 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
self.client.login(username=self.uname, password=self.password)
|
||||
|
||||
resp = self.client.get_html(
|
||||
'/course/',
|
||||
'/home/',
|
||||
{},
|
||||
HTTP_ACCEPT_LANGUAGE='eo'
|
||||
)
|
||||
|
||||
849
cms/djangoapps/contentstore/tests/test_libraries.py
Normal file
849
cms/djangoapps/contentstore/tests/test_libraries.py
Normal file
@@ -0,0 +1,849 @@
|
||||
"""
|
||||
Content library unit tests that require the CMS runtime.
|
||||
"""
|
||||
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
|
||||
from contentstore.utils import reverse_url, reverse_usage_url, reverse_library_url
|
||||
from contentstore.views.preview import _load_preview_module
|
||||
from contentstore.views.tests.test_library import LIBRARY_REST_URL
|
||||
import ddt
|
||||
from mock import patch
|
||||
from student.auth import has_studio_read_access, has_studio_write_access
|
||||
from student.roles import (
|
||||
CourseInstructorRole, CourseStaffRole, CourseCreatorRole, LibraryUserRole,
|
||||
OrgStaffRole, OrgInstructorRole, OrgLibraryUserRole,
|
||||
)
|
||||
from xmodule.library_content_module import LibraryVersionReference
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from mock import Mock
|
||||
from opaque_keys.edx.locator import CourseKey, LibraryLocator
|
||||
|
||||
|
||||
class LibraryTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Common functionality for content libraries tests
|
||||
"""
|
||||
def setUp(self):
|
||||
user_password = super(LibraryTestCase, self).setUp()
|
||||
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username=self.user.username, password=user_password)
|
||||
|
||||
self.lib_key = self._create_library()
|
||||
self.library = modulestore().get_library(self.lib_key)
|
||||
|
||||
self.session_data = {} # Used by _bind_module
|
||||
|
||||
def _create_library(self, org="org", library="lib", display_name="Test Library"):
|
||||
"""
|
||||
Helper method used to create a library. Uses the REST API.
|
||||
"""
|
||||
response = self.client.ajax_post(LIBRARY_REST_URL, {
|
||||
'org': org,
|
||||
'library': library,
|
||||
'display_name': display_name,
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
lib_info = parse_json(response)
|
||||
lib_key = CourseKey.from_string(lib_info['library_key'])
|
||||
self.assertIsInstance(lib_key, LibraryLocator)
|
||||
return lib_key
|
||||
|
||||
def _add_library_content_block(self, course, library_key, other_settings=None):
|
||||
"""
|
||||
Helper method to add a LibraryContent block to a course.
|
||||
The block will be configured to select content from the library
|
||||
specified by library_key.
|
||||
other_settings can be a dict of Scope.settings fields to set on the block.
|
||||
"""
|
||||
return ItemFactory.create(
|
||||
category='library_content',
|
||||
parent_location=course.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
source_libraries=[LibraryVersionReference(library_key)],
|
||||
**(other_settings or {})
|
||||
)
|
||||
|
||||
def _add_simple_content_block(self):
|
||||
""" Adds simple HTML block to library """
|
||||
return ItemFactory.create(
|
||||
category="html", parent_location=self.library.location,
|
||||
user_id=self.user.id, publish_item=False
|
||||
)
|
||||
|
||||
def _refresh_children(self, lib_content_block, status_code_expected=200):
|
||||
"""
|
||||
Helper method: Uses the REST API to call the 'refresh_children' handler
|
||||
of a LibraryContent block
|
||||
"""
|
||||
if 'user' not in lib_content_block.runtime._services: # pylint: disable=protected-access
|
||||
lib_content_block.runtime._services['user'] = Mock(user_id=self.user.id) # pylint: disable=protected-access
|
||||
handler_url = reverse_usage_url(
|
||||
'component_handler',
|
||||
lib_content_block.location,
|
||||
kwargs={'handler': 'refresh_children'}
|
||||
)
|
||||
response = self.client.ajax_post(handler_url)
|
||||
self.assertEqual(response.status_code, status_code_expected)
|
||||
return modulestore().get_item(lib_content_block.location)
|
||||
|
||||
def _bind_module(self, descriptor, user=None):
|
||||
"""
|
||||
Helper to use the CMS's module system so we can access student-specific fields.
|
||||
"""
|
||||
if user is None:
|
||||
user = self.user
|
||||
if user not in self.session_data:
|
||||
self.session_data[user] = {}
|
||||
request = Mock(user=user, session=self.session_data[user])
|
||||
_load_preview_module(request, descriptor) # pylint: disable=protected-access
|
||||
|
||||
def _update_item(self, usage_key, metadata):
|
||||
"""
|
||||
Helper method: Uses the REST API to update the fields of an XBlock.
|
||||
This will result in the XBlock's editor_saved() method being called.
|
||||
"""
|
||||
update_url = reverse_usage_url("xblock_handler", usage_key)
|
||||
return self.client.ajax_post(
|
||||
update_url,
|
||||
data={
|
||||
'metadata': metadata,
|
||||
}
|
||||
)
|
||||
|
||||
def _list_libraries(self):
|
||||
"""
|
||||
Use the REST API to get a list of libraries visible to the current user.
|
||||
"""
|
||||
response = self.client.get_json(LIBRARY_REST_URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return parse_json(response)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestLibraries(LibraryTestCase):
|
||||
"""
|
||||
High-level tests for libraries
|
||||
"""
|
||||
@ddt.data(
|
||||
(2, 1, 1),
|
||||
(2, 2, 2),
|
||||
(2, 20, 2),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_max_items(self, num_to_create, num_to_select, num_expected):
|
||||
"""
|
||||
Test the 'max_count' property of LibraryContent blocks.
|
||||
"""
|
||||
for _ in range(0, num_to_create):
|
||||
self._add_simple_content_block()
|
||||
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': num_to_select})
|
||||
self.assertEqual(len(lc_block.children), 0)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
|
||||
# Now, we want to make sure that .children has the total # of potential
|
||||
# children, and that get_child_descriptors() returns the actual children
|
||||
# chosen for a given student.
|
||||
# In order to be able to call get_child_descriptors(), we must first
|
||||
# call bind_for_student:
|
||||
self._bind_module(lc_block)
|
||||
self.assertEqual(len(lc_block.children), num_to_create)
|
||||
self.assertEqual(len(lc_block.get_child_descriptors()), num_expected)
|
||||
|
||||
def test_consistent_children(self):
|
||||
"""
|
||||
Test that the same student will always see the same selected child block
|
||||
"""
|
||||
# Create many blocks in the library and add them to a course:
|
||||
for num in range(0, 8):
|
||||
ItemFactory.create(
|
||||
data="This is #{}".format(num + 1),
|
||||
category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False
|
||||
)
|
||||
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': 1})
|
||||
lc_block_key = lc_block.location
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
|
||||
def get_child_of_lc_block(block):
|
||||
"""
|
||||
Fetch the child shown to the current user.
|
||||
"""
|
||||
children = block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 1)
|
||||
return children[0]
|
||||
|
||||
# Check which child a student will see:
|
||||
self._bind_module(lc_block)
|
||||
chosen_child = get_child_of_lc_block(lc_block)
|
||||
chosen_child_defn_id = chosen_child.definition_locator.definition_id
|
||||
lc_block.save()
|
||||
|
||||
modulestore().update_item(lc_block, self.user.id)
|
||||
|
||||
# Now re-load the block and try again:
|
||||
def check():
|
||||
"""
|
||||
Confirm that chosen_child is still the child seen by the test student
|
||||
"""
|
||||
for _ in range(0, 6): # Repeat many times b/c blocks are randomized
|
||||
lc_block = modulestore().get_item(lc_block_key) # Reload block from the database
|
||||
self._bind_module(lc_block)
|
||||
current_child = get_child_of_lc_block(lc_block)
|
||||
self.assertEqual(current_child.location, chosen_child.location)
|
||||
self.assertEqual(current_child.data, chosen_child.data)
|
||||
self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id)
|
||||
|
||||
check()
|
||||
# Refresh the children:
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
# Now re-load the block and try yet again, in case refreshing the children changed anything:
|
||||
check()
|
||||
|
||||
def test_definition_shared_with_library(self):
|
||||
"""
|
||||
Test that the same block definition is used for the library and course[s]
|
||||
"""
|
||||
block1 = self._add_simple_content_block()
|
||||
def_id1 = block1.definition_locator.definition_id
|
||||
block2 = self._add_simple_content_block()
|
||||
def_id2 = block2.definition_locator.definition_id
|
||||
self.assertNotEqual(def_id1, def_id2)
|
||||
|
||||
# Next, create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
for child_key in lc_block.children:
|
||||
child = modulestore().get_item(child_key)
|
||||
def_id = child.definition_locator.definition_id
|
||||
self.assertIn(def_id, (def_id1, def_id2))
|
||||
|
||||
def test_fields(self):
|
||||
"""
|
||||
Test that blocks used from a library have the same field values as
|
||||
defined by the library author.
|
||||
"""
|
||||
data_value = "A Scope.content value"
|
||||
name_value = "A Scope.settings value"
|
||||
lib_block = ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name=name_value,
|
||||
data=data_value,
|
||||
)
|
||||
self.assertEqual(lib_block.data, data_value)
|
||||
self.assertEqual(lib_block.display_name, name_value)
|
||||
|
||||
# Next, create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
course_block = modulestore().get_item(lc_block.children[0])
|
||||
|
||||
self.assertEqual(course_block.data, data_value)
|
||||
self.assertEqual(course_block.display_name, name_value)
|
||||
|
||||
def test_block_with_children(self):
|
||||
"""
|
||||
Test that blocks used from a library can have children.
|
||||
"""
|
||||
data_value = "A Scope.content value"
|
||||
name_value = "A Scope.settings value"
|
||||
# In the library, create a vertical block with a child:
|
||||
vert_block = ItemFactory.create(
|
||||
category="vertical",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
)
|
||||
child_block = ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=vert_block.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name=name_value,
|
||||
data=data_value,
|
||||
)
|
||||
self.assertEqual(child_block.data, data_value)
|
||||
self.assertEqual(child_block.display_name, name_value)
|
||||
|
||||
# Next, create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
course_vert_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(len(course_vert_block.children), 1)
|
||||
course_child_block = modulestore().get_item(course_vert_block.children[0])
|
||||
|
||||
self.assertEqual(course_child_block.data, data_value)
|
||||
self.assertEqual(course_child_block.display_name, name_value)
|
||||
|
||||
def test_change_after_first_sync(self):
|
||||
"""
|
||||
Check that nothing goes wrong if we (A) Set up a LibraryContent block
|
||||
and use it successfully, then (B) Give it an invalid configuration.
|
||||
No children should be deleted until the configuration is fixed.
|
||||
"""
|
||||
# Add a block to the library:
|
||||
data_value = "Hello world!"
|
||||
ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name="HTML BLock",
|
||||
data=data_value,
|
||||
)
|
||||
# Create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
|
||||
# Now, change the block settings to have an invalid library key:
|
||||
resp = self._update_item(
|
||||
lc_block.location,
|
||||
{"source_libraries": [["library-v1:NOT+FOUND", None]]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
lc_block = modulestore().get_item(lc_block.location)
|
||||
self.assertEqual(len(lc_block.children), 1) # Children should not be deleted due to a bad setting.
|
||||
html_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(html_block.data, data_value)
|
||||
|
||||
def test_refreshes_children_if_libraries_change(self):
|
||||
""" Tests that children are automatically refreshed if libraries list changes """
|
||||
library2key = self._create_library("org2", "lib2", "Library2")
|
||||
library2 = modulestore().get_library(library2key)
|
||||
data1, data2 = "Hello world!", "Hello other world!"
|
||||
ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name="Lib1: HTML BLock",
|
||||
data=data1,
|
||||
)
|
||||
|
||||
ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=library2.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name="Lib 2: HTML BLock",
|
||||
data=data2,
|
||||
)
|
||||
|
||||
# Create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
|
||||
# Now, change the block settings to have an invalid library key:
|
||||
resp = self._update_item(
|
||||
lc_block.location,
|
||||
{"source_libraries": [[str(library2key)]]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
lc_block = modulestore().get_item(lc_block.location)
|
||||
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
html_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(html_block.data, data2)
|
||||
|
||||
def test_refreshes_children_if_capa_type_change(self):
|
||||
""" Tests that children are automatically refreshed if capa type field changes """
|
||||
name1, name2 = "Option Problem", "Multiple Choice Problem"
|
||||
ItemFactory.create(
|
||||
category="problem",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name=name1,
|
||||
data="<problem><optionresponse></optionresponse></problem>",
|
||||
)
|
||||
ItemFactory.create(
|
||||
category="problem",
|
||||
parent_location=self.library.location,
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
display_name=name2,
|
||||
data="<problem><multiplechoiceresponse></multiplechoiceresponse></problem>",
|
||||
)
|
||||
|
||||
# Create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
self.assertEqual(len(lc_block.children), 2)
|
||||
|
||||
resp = self._update_item(
|
||||
lc_block.location,
|
||||
{"capa_type": 'optionresponse'},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
lc_block = modulestore().get_item(lc_block.location)
|
||||
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
html_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(html_block.display_name, name1)
|
||||
|
||||
resp = self._update_item(
|
||||
lc_block.location,
|
||||
{"capa_type": 'multiplechoiceresponse'},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
lc_block = modulestore().get_item(lc_block.location)
|
||||
|
||||
self.assertEqual(len(lc_block.children), 1)
|
||||
html_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(html_block.display_name, name2)
|
||||
|
||||
def test_refresh_fails_for_unknown_library(self):
|
||||
""" Tests that refresh children fails if unknown library is configured """
|
||||
# Create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
lc_block = self._refresh_children(lc_block)
|
||||
self.assertEqual(len(lc_block.children), 0)
|
||||
|
||||
# Now, change the block settings to have an invalid library key:
|
||||
resp = self._update_item(
|
||||
lc_block.location,
|
||||
{"source_libraries": [["library-v1:NOT+FOUND", None]]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
with self.assertRaises(ValueError):
|
||||
self._refresh_children(lc_block, status_code_expected=400)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestLibraryAccess(LibraryTestCase):
|
||||
"""
|
||||
Test Roles and Permissions related to Content Libraries
|
||||
"""
|
||||
def setUp(self):
|
||||
""" Create a library, staff user, and non-staff user """
|
||||
super(TestLibraryAccess, self).setUp()
|
||||
self.non_staff_user, self.non_staff_user_password = self.create_non_staff_user()
|
||||
|
||||
def _login_as_non_staff_user(self, logout_first=True):
|
||||
""" Login as a user that starts out with no roles/permissions granted. """
|
||||
if logout_first:
|
||||
self.client.logout() # We start logged in as a staff user
|
||||
self.client.login(username=self.non_staff_user.username, password=self.non_staff_user_password)
|
||||
|
||||
def _assert_cannot_create_library(self, org="org", library="libfail", expected_code=403):
|
||||
""" Ensure the current user is not able to create a library. """
|
||||
self.assertTrue(expected_code >= 300)
|
||||
response = self.client.ajax_post(
|
||||
LIBRARY_REST_URL,
|
||||
{'org': org, 'library': library, 'display_name': "Irrelevant"}
|
||||
)
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
key = LibraryLocator(org=org, library=library)
|
||||
self.assertEqual(modulestore().get_library(key), None)
|
||||
|
||||
def _can_access_library(self, library):
|
||||
"""
|
||||
Use the normal studio library URL to check if we have access
|
||||
|
||||
`library` can be a LibraryLocator or the library's root XBlock
|
||||
"""
|
||||
if isinstance(library, (basestring, LibraryLocator)):
|
||||
lib_key = library
|
||||
else:
|
||||
lib_key = library.location.library_key
|
||||
response = self.client.get(reverse_library_url('library_handler', unicode(lib_key)))
|
||||
self.assertIn(response.status_code, (200, 302, 403))
|
||||
return response.status_code == 200
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Log out when done each test
|
||||
"""
|
||||
self.client.logout()
|
||||
super(TestLibraryAccess, self).tearDown()
|
||||
|
||||
def test_creation(self):
|
||||
"""
|
||||
The user that creates a library should have instructor (admin) and staff permissions
|
||||
"""
|
||||
# self.library has been auto-created by the staff user.
|
||||
self.assertTrue(has_studio_write_access(self.user, self.lib_key))
|
||||
self.assertTrue(has_studio_read_access(self.user, self.lib_key))
|
||||
# Make sure the user was actually assigned the instructor role and not just using is_staff superpowers:
|
||||
self.assertTrue(CourseInstructorRole(self.lib_key).has_user(self.user))
|
||||
|
||||
# Now log out and ensure we are forbidden from creating a library:
|
||||
self.client.logout()
|
||||
self._assert_cannot_create_library(expected_code=302) # 302 redirect to login expected
|
||||
|
||||
# Now create a non-staff user with no permissions:
|
||||
self._login_as_non_staff_user(logout_first=False)
|
||||
self.assertFalse(CourseCreatorRole().has_user(self.non_staff_user))
|
||||
|
||||
# Now check that logged-in users without any permissions cannot create libraries
|
||||
with patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}):
|
||||
self._assert_cannot_create_library()
|
||||
|
||||
@ddt.data(
|
||||
CourseInstructorRole,
|
||||
CourseStaffRole,
|
||||
LibraryUserRole,
|
||||
)
|
||||
def test_acccess(self, access_role):
|
||||
"""
|
||||
Test the various roles that allow viewing libraries are working correctly.
|
||||
"""
|
||||
# At this point, one library exists, created by the currently-logged-in staff user.
|
||||
# Create another library as staff:
|
||||
library2_key = self._create_library(library="lib2")
|
||||
# Login as non_staff_user:
|
||||
self._login_as_non_staff_user()
|
||||
|
||||
# non_staff_user shouldn't be able to access any libraries:
|
||||
lib_list = self._list_libraries()
|
||||
self.assertEqual(len(lib_list), 0)
|
||||
self.assertFalse(self._can_access_library(self.library))
|
||||
self.assertFalse(self._can_access_library(library2_key))
|
||||
|
||||
# Now manually intervene to give non_staff_user access to library2_key:
|
||||
access_role(library2_key).add_users(self.non_staff_user)
|
||||
|
||||
# Now non_staff_user should be able to access library2_key only:
|
||||
lib_list = self._list_libraries()
|
||||
self.assertEqual(len(lib_list), 1)
|
||||
self.assertEqual(lib_list[0]["library_key"], unicode(library2_key))
|
||||
self.assertTrue(self._can_access_library(library2_key))
|
||||
self.assertFalse(self._can_access_library(self.library))
|
||||
|
||||
@ddt.data(
|
||||
OrgStaffRole,
|
||||
OrgInstructorRole,
|
||||
OrgLibraryUserRole,
|
||||
)
|
||||
def test_org_based_access(self, org_access_role):
|
||||
"""
|
||||
Test the various roles that allow viewing all of an organization's
|
||||
libraries are working correctly.
|
||||
"""
|
||||
# Create some libraries as the staff user:
|
||||
lib_key_pacific = self._create_library(org="PacificX", library="libP")
|
||||
lib_key_atlantic = self._create_library(org="AtlanticX", library="libA")
|
||||
|
||||
# Login as a non-staff:
|
||||
self._login_as_non_staff_user()
|
||||
|
||||
# Now manually intervene to give non_staff_user access to all "PacificX" libraries:
|
||||
org_access_role(lib_key_pacific.org).add_users(self.non_staff_user)
|
||||
|
||||
# Now non_staff_user should be able to access lib_key_pacific only:
|
||||
lib_list = self._list_libraries()
|
||||
self.assertEqual(len(lib_list), 1)
|
||||
self.assertEqual(lib_list[0]["library_key"], unicode(lib_key_pacific))
|
||||
self.assertTrue(self._can_access_library(lib_key_pacific))
|
||||
self.assertFalse(self._can_access_library(lib_key_atlantic))
|
||||
self.assertFalse(self._can_access_library(self.lib_key))
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_read_only_role(self, use_org_level_role):
|
||||
"""
|
||||
Test the read-only role (LibraryUserRole and its org-level equivalent)
|
||||
"""
|
||||
# As staff user, add a block to self.library:
|
||||
block = self._add_simple_content_block()
|
||||
|
||||
# Login as a non_staff_user:
|
||||
self._login_as_non_staff_user()
|
||||
self.assertFalse(self._can_access_library(self.library))
|
||||
|
||||
block_url = reverse_usage_url('xblock_handler', block.location)
|
||||
|
||||
def can_read_block():
|
||||
""" Check if studio lets us view the XBlock in the library """
|
||||
response = self.client.get_json(block_url)
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
return response.status_code == 200
|
||||
|
||||
def can_edit_block():
|
||||
""" Check if studio lets us edit the XBlock in the library """
|
||||
response = self.client.ajax_post(block_url)
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
return response.status_code == 200
|
||||
|
||||
def can_delete_block():
|
||||
""" Check if studio lets us delete the XBlock in the library """
|
||||
response = self.client.delete(block_url)
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
return response.status_code == 200
|
||||
|
||||
def can_copy_block():
|
||||
""" Check if studio lets us duplicate the XBlock in the library """
|
||||
response = self.client.ajax_post(reverse_url('xblock_handler'), {
|
||||
'parent_locator': unicode(self.library.location),
|
||||
'duplicate_source_locator': unicode(block.location),
|
||||
})
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
return response.status_code == 200
|
||||
|
||||
def can_create_block():
|
||||
""" Check if studio lets us make a new XBlock in the library """
|
||||
response = self.client.ajax_post(reverse_url('xblock_handler'), {
|
||||
'parent_locator': unicode(self.library.location), 'category': 'html',
|
||||
})
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
return response.status_code == 200
|
||||
|
||||
# Check that we do not have read or write access to block:
|
||||
self.assertFalse(can_read_block())
|
||||
self.assertFalse(can_edit_block())
|
||||
self.assertFalse(can_delete_block())
|
||||
self.assertFalse(can_copy_block())
|
||||
self.assertFalse(can_create_block())
|
||||
|
||||
# Give non_staff_user read-only permission:
|
||||
if use_org_level_role:
|
||||
OrgLibraryUserRole(self.lib_key.org).add_users(self.non_staff_user)
|
||||
else:
|
||||
LibraryUserRole(self.lib_key).add_users(self.non_staff_user)
|
||||
|
||||
self.assertTrue(self._can_access_library(self.library))
|
||||
self.assertTrue(can_read_block())
|
||||
self.assertFalse(can_edit_block())
|
||||
self.assertFalse(can_delete_block())
|
||||
self.assertFalse(can_copy_block())
|
||||
self.assertFalse(can_create_block())
|
||||
|
||||
@ddt.data(
|
||||
(LibraryUserRole, CourseStaffRole, True),
|
||||
(CourseStaffRole, CourseStaffRole, True),
|
||||
(None, CourseStaffRole, False),
|
||||
(LibraryUserRole, None, False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_duplicate_across_courses(self, library_role, course_role, expected_result):
|
||||
"""
|
||||
Test that the REST API will correctly allow/refuse when copying
|
||||
from a library with (write, read, or no) access to a course with (write or no) access.
|
||||
"""
|
||||
# As staff user, add a block to self.library:
|
||||
block = self._add_simple_content_block()
|
||||
# And create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
self._login_as_non_staff_user()
|
||||
|
||||
# Assign roles:
|
||||
if library_role:
|
||||
library_role(self.lib_key).add_users(self.non_staff_user)
|
||||
if course_role:
|
||||
course_role(course.location.course_key).add_users(self.non_staff_user)
|
||||
|
||||
# Copy block to the course:
|
||||
response = self.client.ajax_post(reverse_url('xblock_handler'), {
|
||||
'parent_locator': unicode(course.location),
|
||||
'duplicate_source_locator': unicode(block.location),
|
||||
})
|
||||
self.assertIn(response.status_code, (200, 403)) # 400 would be ambiguous
|
||||
duplicate_action_allowed = (response.status_code == 200)
|
||||
self.assertEqual(duplicate_action_allowed, expected_result)
|
||||
|
||||
@ddt.data(
|
||||
(LibraryUserRole, CourseStaffRole, True),
|
||||
(CourseStaffRole, CourseStaffRole, True),
|
||||
(None, CourseStaffRole, False),
|
||||
(LibraryUserRole, None, False),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_refresh_library_content_permissions(self, library_role, course_role, expected_result):
|
||||
"""
|
||||
Test that the LibraryContent block's 'refresh_children' handler will correctly
|
||||
handle permissions and allow/refuse when updating its content with the latest
|
||||
version of a library. We try updating from a library with (write, read, or no)
|
||||
access to a course with (write or no) access.
|
||||
"""
|
||||
# As staff user, add a block to self.library:
|
||||
self._add_simple_content_block()
|
||||
# And create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
course = CourseFactory.create()
|
||||
|
||||
self._login_as_non_staff_user()
|
||||
|
||||
# Assign roles:
|
||||
if library_role:
|
||||
library_role(self.lib_key).add_users(self.non_staff_user)
|
||||
if course_role:
|
||||
course_role(course.location.course_key).add_users(self.non_staff_user)
|
||||
|
||||
# Try updating our library content block:
|
||||
lc_block = self._add_library_content_block(course, self.lib_key)
|
||||
# We must use the CMS's module system in order to get permissions checks.
|
||||
self._bind_module(lc_block, user=self.non_staff_user)
|
||||
lc_block = self._refresh_children(lc_block, status_code_expected=200 if expected_result else 403)
|
||||
self.assertEqual(len(lc_block.children), 1 if expected_result else 0)
|
||||
|
||||
|
||||
class TestOverrides(LibraryTestCase):
|
||||
"""
|
||||
Test that overriding block Scope.settings fields from a library in a specific course works
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestOverrides, self).setUp()
|
||||
self.original_display_name = "A Problem Block"
|
||||
self.original_weight = 1
|
||||
|
||||
# Create a problem block in the library:
|
||||
self.problem = ItemFactory.create(
|
||||
category="problem",
|
||||
parent_location=self.library.location,
|
||||
display_name=self.original_display_name, # display_name is a Scope.settings field
|
||||
weight=self.original_weight, # weight is also a Scope.settings field
|
||||
user_id=self.user.id,
|
||||
publish_item=False,
|
||||
)
|
||||
|
||||
# Also create a course:
|
||||
with modulestore().default_store(ModuleStoreEnum.Type.split):
|
||||
self.course = CourseFactory.create()
|
||||
|
||||
# Add a LibraryContent block to the course:
|
||||
self.lc_block = self._add_library_content_block(self.course, self.lib_key)
|
||||
self.lc_block = self._refresh_children(self.lc_block)
|
||||
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
|
||||
|
||||
def test_overrides(self):
|
||||
"""
|
||||
Test that we can override Scope.settings values in a course.
|
||||
"""
|
||||
new_display_name = "Modified Problem Title"
|
||||
new_weight = 10
|
||||
self.problem_in_course.display_name = new_display_name
|
||||
self.problem_in_course.weight = new_weight
|
||||
modulestore().update_item(self.problem_in_course, self.user.id)
|
||||
|
||||
# Add a second LibraryContent block to the course, with no override:
|
||||
lc_block2 = self._add_library_content_block(self.course, self.lib_key)
|
||||
lc_block2 = self._refresh_children(lc_block2)
|
||||
# Re-load the two problem blocks - one with and one without an override:
|
||||
self.problem_in_course = modulestore().get_item(self.lc_block.children[0])
|
||||
problem2_in_course = modulestore().get_item(lc_block2.children[0])
|
||||
|
||||
self.assertEqual(self.problem_in_course.display_name, new_display_name)
|
||||
self.assertEqual(self.problem_in_course.weight, new_weight)
|
||||
|
||||
self.assertEqual(problem2_in_course.display_name, self.original_display_name)
|
||||
self.assertEqual(problem2_in_course.weight, self.original_weight)
|
||||
|
||||
def test_reset_override(self):
|
||||
"""
|
||||
If we override a setting and then reset it, we should get the library value.
|
||||
"""
|
||||
new_display_name = "Modified Problem Title"
|
||||
new_weight = 10
|
||||
self.problem_in_course.display_name = new_display_name
|
||||
self.problem_in_course.weight = new_weight
|
||||
modulestore().update_item(self.problem_in_course, self.user.id)
|
||||
self.problem_in_course = modulestore().get_item(self.problem_in_course.location)
|
||||
|
||||
self.assertEqual(self.problem_in_course.display_name, new_display_name)
|
||||
self.assertEqual(self.problem_in_course.weight, new_weight)
|
||||
|
||||
# Reset:
|
||||
for field_name in ["display_name", "weight"]:
|
||||
self.problem_in_course.fields[field_name].delete_from(self.problem_in_course)
|
||||
|
||||
# Save, reload, and verify:
|
||||
modulestore().update_item(self.problem_in_course, self.user.id)
|
||||
self.problem_in_course = modulestore().get_item(self.problem_in_course.location)
|
||||
|
||||
self.assertEqual(self.problem_in_course.display_name, self.original_display_name)
|
||||
self.assertEqual(self.problem_in_course.weight, self.original_weight)
|
||||
|
||||
def test_consistent_definitions(self):
|
||||
"""
|
||||
Make sure that the new child of the LibraryContent block
|
||||
shares its definition with the original (self.problem).
|
||||
|
||||
This test is specific to split mongo.
|
||||
"""
|
||||
definition_id = self.problem.definition_locator.definition_id
|
||||
self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id)
|
||||
|
||||
# Now even if we change some Scope.settings fields and refresh, the definition should be unchanged
|
||||
self.problem.weight = 20
|
||||
self.problem.display_name = "NEW"
|
||||
modulestore().update_item(self.problem, self.user.id)
|
||||
self.lc_block = self._refresh_children(self.lc_block)
|
||||
self.problem_in_course = modulestore().get_item(self.problem_in_course.location)
|
||||
|
||||
self.assertEqual(self.problem.definition_locator.definition_id, definition_id)
|
||||
self.assertEqual(self.problem_in_course.definition_locator.definition_id, definition_id)
|
||||
|
||||
def test_persistent_overrides(self):
|
||||
"""
|
||||
Test that when we override Scope.settings values in a course,
|
||||
the override values persist even when the block is refreshed
|
||||
with updated blocks from the library.
|
||||
"""
|
||||
new_display_name = "Modified Problem Title"
|
||||
new_weight = 15
|
||||
self.problem_in_course.display_name = new_display_name
|
||||
self.problem_in_course.weight = new_weight
|
||||
|
||||
modulestore().update_item(self.problem_in_course, self.user.id)
|
||||
self.problem_in_course = modulestore().get_item(self.problem_in_course.location)
|
||||
self.assertEqual(self.problem_in_course.display_name, new_display_name)
|
||||
self.assertEqual(self.problem_in_course.weight, new_weight)
|
||||
|
||||
# Change the settings in the library version:
|
||||
self.problem.display_name = "X"
|
||||
self.problem.weight = 99
|
||||
new_data_value = "<problem><p>Changed data to check that non-overriden fields *do* get updated.</p></problem>"
|
||||
self.problem.data = new_data_value
|
||||
modulestore().update_item(self.problem, self.user.id)
|
||||
|
||||
self.lc_block = self._refresh_children(self.lc_block)
|
||||
self.problem_in_course = modulestore().get_item(self.problem_in_course.location)
|
||||
|
||||
self.assertEqual(self.problem_in_course.display_name, new_display_name)
|
||||
self.assertEqual(self.problem_in_course.weight, new_weight)
|
||||
self.assertEqual(self.problem_in_course.data, new_data_value)
|
||||
@@ -234,13 +234,13 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
def test_private_pages_auth(self):
|
||||
"""Make sure pages that do require login work."""
|
||||
auth_pages = (
|
||||
'/course/',
|
||||
'/home/',
|
||||
)
|
||||
|
||||
# These are pages that should just load when the user is logged in
|
||||
# (no data needed)
|
||||
simple_auth_pages = (
|
||||
'/course/',
|
||||
'/home/',
|
||||
)
|
||||
|
||||
# need an activated user
|
||||
@@ -266,7 +266,7 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
def test_index_auth(self):
|
||||
|
||||
# not logged in. Should return a redirect.
|
||||
resp = self.client.get_html('/course/')
|
||||
resp = self.client.get_html('/home/')
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
# Logged in should work.
|
||||
@@ -283,7 +283,7 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.login(self.email, self.pw)
|
||||
|
||||
# make sure we can access courseware immediately
|
||||
course_url = '/course/'
|
||||
course_url = '/home/'
|
||||
resp = self.client.get_html(course_url)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
|
||||
@@ -293,7 +293,7 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
resp = self.client.get_html(course_url)
|
||||
|
||||
# re-request, and we should get a redirect to login page
|
||||
self.assertRedirects(resp, settings.LOGIN_REDIRECT_URL + '?next=/course/')
|
||||
self.assertRedirects(resp, settings.LOGIN_REDIRECT_URL + '?next=/home/')
|
||||
|
||||
|
||||
class ForumTestCase(CourseTestCase):
|
||||
|
||||
@@ -296,6 +296,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None):
|
||||
return reverse_url(handler_name, 'course_key_string', course_key, kwargs)
|
||||
|
||||
|
||||
def reverse_library_url(handler_name, library_key, kwargs=None):
|
||||
"""
|
||||
Creates the URL for handlers that use library_keys as URL parameters.
|
||||
"""
|
||||
return reverse_url(handler_name, 'library_key_string', library_key, kwargs)
|
||||
|
||||
|
||||
def reverse_usage_url(handler_name, usage_key, kwargs=None):
|
||||
"""
|
||||
Creates the URL for handlers that use usage_keys as URL parameters.
|
||||
|
||||
@@ -12,6 +12,7 @@ from .error import *
|
||||
from .helpers import *
|
||||
from .item import *
|
||||
from .import_export import *
|
||||
from .library import *
|
||||
from .preview import *
|
||||
from .public import *
|
||||
from .export_git import *
|
||||
|
||||
@@ -56,6 +56,15 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES
|
||||
|
||||
|
||||
CONTAINER_TEMPATES = [
|
||||
"basic-modal", "modal-button", "edit-xblock-modal",
|
||||
"editor-mode-button", "upload-dialog", "image-modal",
|
||||
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
|
||||
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
|
||||
"unit-outline", "container-message"
|
||||
]
|
||||
|
||||
|
||||
def _advanced_component_types():
|
||||
"""
|
||||
Return advanced component types which can be created.
|
||||
@@ -202,14 +211,15 @@ def container_handler(request, usage_key_string):
|
||||
'xblock_info': xblock_info,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'templates': CONTAINER_TEMPATES
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports HTML requests")
|
||||
|
||||
|
||||
def get_component_templates(course):
|
||||
def get_component_templates(courselike, library=False):
|
||||
"""
|
||||
Returns the applicable component templates that can be used by the specified course.
|
||||
Returns the applicable component templates that can be used by the specified course or library.
|
||||
"""
|
||||
def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
|
||||
"""
|
||||
@@ -240,7 +250,13 @@ def get_component_templates(course):
|
||||
categories = set()
|
||||
# The component_templates array is in the order of "advanced" (if present), followed
|
||||
# by the components in the order listed in COMPONENT_TYPES.
|
||||
for category in COMPONENT_TYPES:
|
||||
component_types = COMPONENT_TYPES[:]
|
||||
|
||||
# Libraries do not support discussions
|
||||
if library:
|
||||
component_types = [component for component in component_types if component != 'discussion']
|
||||
|
||||
for category in component_types:
|
||||
templates_for_category = []
|
||||
component_class = _load_mixed_class(category)
|
||||
# add the default template with localized display name
|
||||
@@ -254,7 +270,7 @@ def get_component_templates(course):
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
filter_templates = getattr(component_class, 'filter_templates', None)
|
||||
if not filter_templates or filter_templates(template, course):
|
||||
if not filter_templates or filter_templates(template, courselike):
|
||||
templates_for_category.append(
|
||||
create_template_dict(
|
||||
_(template['metadata'].get('display_name')),
|
||||
@@ -279,11 +295,15 @@ def get_component_templates(course):
|
||||
"display_name": component_display_names[category]
|
||||
})
|
||||
|
||||
# Libraries do not support advanced components at this time.
|
||||
if library:
|
||||
return component_templates
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
|
||||
# enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
course_advanced_keys = courselike.advanced_modules
|
||||
advanced_component_templates = {"type": "advanced", "templates": [], "display_name": _("Advanced")}
|
||||
advanced_component_types = _advanced_component_types()
|
||||
# Set component types according to course policy file
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
from django.shortcuts import redirect
|
||||
import json
|
||||
import random
|
||||
import string # pylint: disable=deprecated-module
|
||||
@@ -38,6 +39,7 @@ from contentstore.utils import (
|
||||
add_extra_panel_tab,
|
||||
remove_extra_panel_tab,
|
||||
reverse_course_url,
|
||||
reverse_library_url,
|
||||
reverse_usage_url,
|
||||
reverse_url,
|
||||
remove_all_instructors,
|
||||
@@ -47,7 +49,7 @@ from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from util.json_request import expect_json
|
||||
from util.string_utils import _has_non_ascii_characters
|
||||
from student.auth import has_course_author_access
|
||||
from student.auth import has_studio_write_access, has_studio_read_access
|
||||
from .component import (
|
||||
OPEN_ENDED_COMPONENT_TYPES,
|
||||
NOTE_COMPONENT_TYPES,
|
||||
@@ -56,6 +58,7 @@ from .component import (
|
||||
ADVANCED_COMPONENT_TYPES,
|
||||
)
|
||||
from contentstore.tasks import rerun_course
|
||||
from .library import LIBRARIES_ENABLED
|
||||
from .item import create_xblock_info
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from contentstore import utils
|
||||
@@ -69,7 +72,8 @@ from microsite_configuration import microsite
|
||||
from xmodule.course_module import CourseFields
|
||||
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
||||
'course_info_update_handler',
|
||||
'course_rerun_handler',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
@@ -94,7 +98,7 @@ def get_course_and_check_access(course_key, user, depth=0):
|
||||
Internal method used to calculate and return the locator and course module
|
||||
for the view functions in this file.
|
||||
"""
|
||||
if not has_course_author_access(user, course_key):
|
||||
if not has_studio_read_access(user, course_key):
|
||||
raise PermissionDenied()
|
||||
course_module = modulestore().get_course(course_key, depth=depth)
|
||||
return course_module
|
||||
@@ -128,7 +132,7 @@ def course_notifications_handler(request, course_key_string=None, action_state_i
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
|
||||
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
if not has_course_author_access(request.user, course_key):
|
||||
if not has_studio_write_access(request.user, course_key):
|
||||
raise PermissionDenied()
|
||||
if request.method == 'GET':
|
||||
return _course_notifications_json_get(action_state_id)
|
||||
@@ -218,7 +222,7 @@ def course_handler(request, course_key_string=None):
|
||||
return JsonResponse(_course_outline_json(request, course_module))
|
||||
elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access
|
||||
return _create_or_rerun_course(request)
|
||||
elif not has_course_author_access(request.user, CourseKey.from_string(course_key_string)):
|
||||
elif not has_studio_write_access(request.user, CourseKey.from_string(course_key_string)):
|
||||
raise PermissionDenied()
|
||||
elif request.method == 'PUT':
|
||||
raise NotImplementedError()
|
||||
@@ -228,7 +232,7 @@ def course_handler(request, course_key_string=None):
|
||||
return HttpResponseBadRequest()
|
||||
elif request.method == 'GET': # assume html
|
||||
if course_key_string is None:
|
||||
return course_listing(request)
|
||||
return redirect(reverse("home"))
|
||||
else:
|
||||
return course_index(request, CourseKey.from_string(course_key_string))
|
||||
else:
|
||||
@@ -290,7 +294,7 @@ def _accessible_courses_list(request):
|
||||
if course.location.course == 'templates':
|
||||
return False
|
||||
|
||||
return has_course_author_access(request.user, course.id)
|
||||
return has_studio_read_access(request.user, course.id)
|
||||
|
||||
courses = filter(course_filter, modulestore().get_courses())
|
||||
in_process_course_actions = [
|
||||
@@ -298,7 +302,7 @@ def _accessible_courses_list(request):
|
||||
CourseRerunState.objects.find_all(
|
||||
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True
|
||||
)
|
||||
if has_course_author_access(request.user, course.course_key)
|
||||
if has_studio_read_access(request.user, course.course_key)
|
||||
]
|
||||
return courses, in_process_course_actions
|
||||
|
||||
@@ -341,6 +345,14 @@ def _accessible_courses_list_from_groups(request):
|
||||
return courses_list.values(), in_process_course_actions
|
||||
|
||||
|
||||
def _accessible_libraries_list(user):
|
||||
"""
|
||||
List all libraries available to the logged in user by iterating through all libraries
|
||||
"""
|
||||
# No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
|
||||
return [lib for lib in modulestore().get_libraries() if has_studio_read_access(user, lib.location.library_key)]
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_listing(request):
|
||||
@@ -360,6 +372,8 @@ def course_listing(request):
|
||||
# so fallback to iterating through all courses
|
||||
courses, in_process_course_actions = _accessible_courses_list(request)
|
||||
|
||||
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
|
||||
|
||||
def format_course_for_view(course):
|
||||
"""
|
||||
Return a dict of the data which the view requires for each course
|
||||
@@ -396,6 +410,19 @@ def course_listing(request):
|
||||
) if uca.state == CourseRerunUIStateManager.State.FAILED else ''
|
||||
}
|
||||
|
||||
def format_library_for_view(library):
|
||||
"""
|
||||
Return a dict of the data which the view requires for each library
|
||||
"""
|
||||
return {
|
||||
'display_name': library.display_name,
|
||||
'library_key': unicode(library.location.library_key),
|
||||
'url': reverse_library_url('library_handler', unicode(library.location.library_key)),
|
||||
'org': library.display_org_with_default,
|
||||
'number': library.display_number_with_default,
|
||||
'can_edit': has_studio_write_access(request.user, library.location.library_key),
|
||||
}
|
||||
|
||||
# remove any courses in courses that are also in the in_process_course_actions list
|
||||
in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
|
||||
courses = [
|
||||
@@ -409,6 +436,8 @@ def course_listing(request):
|
||||
return render_to_response('index.html', {
|
||||
'courses': courses,
|
||||
'in_process_course_actions': in_process_course_actions,
|
||||
'libraries_enabled': LIBRARIES_ENABLED,
|
||||
'libraries': [format_library_for_view(lib) for lib in libraries],
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -621,7 +650,7 @@ def _rerun_course(request, org, number, run, fields):
|
||||
source_course_key = CourseKey.from_string(request.json.get('source_course_key'))
|
||||
|
||||
# verify user has access to the original course
|
||||
if not has_course_author_access(request.user, source_course_key):
|
||||
if not has_studio_write_access(request.user, source_course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
# create destination course key
|
||||
@@ -702,7 +731,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None):
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item (GET shouldn't require this level?)
|
||||
if not has_course_author_access(request.user, usage_key.course_key):
|
||||
if not has_studio_write_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'GET':
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _
|
||||
from edxmako.shortcuts import render_to_string, render_to_response
|
||||
from xblock.core import XBlock
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import reverse_course_url, reverse_usage_url
|
||||
from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
@@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None):
|
||||
url=reverse_course_url('course_handler', xblock.location.course_key),
|
||||
usage_key=urllib.quote(unicode(xblock.location))
|
||||
)
|
||||
elif category == 'library':
|
||||
library_key = xblock.location.course_key
|
||||
return reverse_library_url('library_handler', library_key)
|
||||
else:
|
||||
return reverse_usage_url('container_handler', xblock.location)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ from util.date_utils import get_default_time_display
|
||||
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from student.auth import has_course_author_access
|
||||
from student.auth import has_studio_write_access, has_studio_read_access
|
||||
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \
|
||||
ancestor_has_staff_lock
|
||||
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
|
||||
@@ -47,6 +47,7 @@ from edxmako.shortcuts import render_to_string
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.xblock.runtime import handler_url, local_resource_url
|
||||
from opaque_keys.edx.keys import UsageKey, CourseKey
|
||||
from opaque_keys.edx.locator import LibraryUsageLocator
|
||||
|
||||
__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
|
||||
|
||||
@@ -129,7 +130,8 @@ def xblock_handler(request, usage_key_string):
|
||||
if usage_key_string:
|
||||
usage_key = usage_key_with_run(usage_key_string)
|
||||
|
||||
if not has_course_author_access(request.user, usage_key.course_key):
|
||||
access_check = has_studio_read_access if request.method == 'GET' else has_studio_write_access
|
||||
if not access_check(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
if request.method == 'GET':
|
||||
@@ -165,6 +167,14 @@ def xblock_handler(request, usage_key_string):
|
||||
parent_usage_key = usage_key_with_run(request.json['parent_locator'])
|
||||
duplicate_source_usage_key = usage_key_with_run(request.json['duplicate_source_locator'])
|
||||
|
||||
source_course = duplicate_source_usage_key.course_key
|
||||
dest_course = parent_usage_key.course_key
|
||||
if (
|
||||
not has_studio_write_access(request.user, dest_course) or
|
||||
not has_studio_read_access(request.user, source_course)
|
||||
):
|
||||
raise PermissionDenied()
|
||||
|
||||
dest_usage_key = _duplicate_item(
|
||||
parent_usage_key,
|
||||
duplicate_source_usage_key,
|
||||
@@ -196,7 +206,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
the second is the resource description
|
||||
"""
|
||||
usage_key = usage_key_with_run(usage_key_string)
|
||||
if not has_course_author_access(request.user, usage_key.course_key):
|
||||
if not has_studio_read_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
accept_header = request.META.get('HTTP_ACCEPT', 'application/json')
|
||||
@@ -204,7 +214,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
if 'application/json' in accept_header:
|
||||
store = modulestore()
|
||||
xblock = store.get_item(usage_key)
|
||||
container_views = ['container_preview', 'reorderable_container_child_preview']
|
||||
container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview']
|
||||
|
||||
# wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
@@ -227,6 +237,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
|
||||
elif view_name in (PREVIEW_VIEWS + container_views):
|
||||
is_pages_view = view_name == STUDENT_VIEW # Only the "Pages" view uses student view in Studio
|
||||
can_edit = has_studio_write_access(request.user, usage_key.course_key)
|
||||
|
||||
# Determine the items to be shown as reorderable. Note that the view
|
||||
# 'reorderable_container_child_preview' is only rendered for xblocks that
|
||||
@@ -236,12 +247,34 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
if view_name == 'reorderable_container_child_preview':
|
||||
reorderable_items.add(xblock.location)
|
||||
|
||||
paging = None
|
||||
try:
|
||||
if request.REQUEST.get('enable_paging', 'false') == 'true':
|
||||
paging = {
|
||||
'page_number': int(request.REQUEST.get('page_number', 0)),
|
||||
'page_size': int(request.REQUEST.get('page_size', 0)),
|
||||
}
|
||||
except ValueError:
|
||||
# pylint: disable=too-many-format-args
|
||||
return HttpResponse(
|
||||
content="Couldn't parse paging parameters: enable_paging: "
|
||||
"%s, page_number: %s, page_size: %s".format(
|
||||
request.REQUEST.get('enable_paging', 'false'),
|
||||
request.REQUEST.get('page_number', 0),
|
||||
request.REQUEST.get('page_size', 0)
|
||||
),
|
||||
status=400,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
# Set up the context to be passed to each XBlock's render method.
|
||||
context = {
|
||||
'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks
|
||||
'is_unit_page': is_unit(xblock),
|
||||
'can_edit': can_edit,
|
||||
'root_xblock': xblock if (view_name == 'container_preview') else None,
|
||||
'reorderable_items': reorderable_items
|
||||
'reorderable_items': reorderable_items,
|
||||
'paging': paging,
|
||||
}
|
||||
|
||||
fragment = get_preview_fragment(request, xblock, context)
|
||||
@@ -283,7 +316,7 @@ def xblock_outline_handler(request, usage_key_string):
|
||||
a course.
|
||||
"""
|
||||
usage_key = usage_key_with_run(usage_key_string)
|
||||
if not has_course_author_access(request.user, usage_key.course_key):
|
||||
if not has_studio_read_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
response_format = request.REQUEST.get('format', 'html')
|
||||
@@ -405,8 +438,11 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
|
||||
else:
|
||||
try:
|
||||
value = field.from_json(value)
|
||||
except ValueError:
|
||||
return JsonResponse({"error": "Invalid data"}, 400)
|
||||
except ValueError as verr:
|
||||
reason = _("Invalid data")
|
||||
if verr.message:
|
||||
reason = _("Invalid data ({details})").format(details=verr.message)
|
||||
return JsonResponse({"error": reason}, 400)
|
||||
field.write_to(xblock, value)
|
||||
|
||||
# update the xblock and call any xblock callbacks
|
||||
@@ -452,12 +488,18 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
|
||||
def _create_item(request):
|
||||
"""View for create items."""
|
||||
usage_key = usage_key_with_run(request.json['parent_locator'])
|
||||
category = request.json['category']
|
||||
if not has_studio_write_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
category = request.json['category']
|
||||
display_name = request.json.get('display_name')
|
||||
|
||||
if not has_course_author_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
if isinstance(usage_key, LibraryUsageLocator):
|
||||
# Only these categories are supported at this time.
|
||||
if category not in ['html', 'problem', 'video']:
|
||||
return HttpResponseBadRequest(
|
||||
"Category '%s' not supported for Libraries" % category, content_type='text/plain'
|
||||
)
|
||||
|
||||
store = modulestore()
|
||||
with store.bulk_operations(usage_key.course_key):
|
||||
@@ -508,7 +550,9 @@ def _create_item(request):
|
||||
)
|
||||
store.update_item(course, request.user.id)
|
||||
|
||||
return JsonResponse({"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)})
|
||||
return JsonResponse(
|
||||
{"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)}
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None):
|
||||
@@ -523,7 +567,12 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
|
||||
category = dest_usage_key.block_type
|
||||
|
||||
# Update the display name to indicate this is a duplicate (unless display name provided).
|
||||
duplicate_metadata = own_metadata(source_item)
|
||||
# Can't use own_metadata(), b/c it converts data for JSON serialization -
|
||||
# not suitable for setting metadata of the new block
|
||||
duplicate_metadata = {}
|
||||
for field in source_item.fields.values():
|
||||
if field.scope == Scope.settings and field.is_set_on(source_item):
|
||||
duplicate_metadata[field.name] = field.read_from(source_item)
|
||||
if display_name is not None:
|
||||
duplicate_metadata['display_name'] = display_name
|
||||
else:
|
||||
@@ -548,7 +597,8 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
|
||||
dest_module.children = []
|
||||
for child in source_item.children:
|
||||
dupe = _duplicate_item(dest_module.location, child, user=user)
|
||||
dest_module.children.append(dupe)
|
||||
if dupe not in dest_module.children: # _duplicate_item may add the child for us.
|
||||
dest_module.children.append(dupe)
|
||||
store.update_item(dest_module, user.id)
|
||||
|
||||
if 'detached' not in source_item.runtime.load_block_type(category)._class_tags:
|
||||
@@ -598,7 +648,7 @@ def orphan_handler(request, course_key_string):
|
||||
"""
|
||||
course_usage_key = CourseKey.from_string(course_key_string)
|
||||
if request.method == 'GET':
|
||||
if has_course_author_access(request.user, course_usage_key):
|
||||
if has_studio_read_access(request.user, course_usage_key):
|
||||
return JsonResponse([unicode(item) for item in modulestore().get_orphans(course_usage_key)])
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
@@ -660,7 +710,9 @@ def _get_module_info(xblock, rewrite_static_links=True):
|
||||
)
|
||||
|
||||
# Pre-cache has changes for the entire course because we'll need it for the ancestor info
|
||||
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
|
||||
# Except library blocks which don't [yet] use draft/publish
|
||||
if not isinstance(xblock.location, LibraryUsageLocator):
|
||||
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
|
||||
@@ -701,12 +753,18 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
|
||||
return None
|
||||
|
||||
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
|
||||
is_xblock_unit = is_unit(xblock, parent_xblock)
|
||||
# this should not be calculated for Sections and Subsections on Unit page
|
||||
has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None
|
||||
# this should not be calculated for Sections and Subsections on Unit page or for library blocks
|
||||
has_changes = None
|
||||
if (is_xblock_unit or course_outline) and not is_library_block:
|
||||
has_changes = modulestore().has_changes(xblock)
|
||||
|
||||
if graders is None:
|
||||
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
|
||||
if not is_library_block:
|
||||
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
|
||||
else:
|
||||
graders = []
|
||||
|
||||
# Compute the child info first so it can be included in aggregate information for the parent
|
||||
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
|
||||
@@ -726,7 +784,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
|
||||
else:
|
||||
visibility_state = None
|
||||
published = modulestore().has_published_version(xblock)
|
||||
published = modulestore().has_published_version(xblock) if not is_library_block else None
|
||||
|
||||
xblock_info = {
|
||||
"id": unicode(xblock.location),
|
||||
@@ -734,7 +792,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
"category": xblock.category,
|
||||
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
|
||||
"published": published,
|
||||
"published_on": get_default_time_display(xblock.published_on) if xblock.published_on else None,
|
||||
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
|
||||
"studio_url": xblock_studio_url(xblock, parent_xblock),
|
||||
"released_to_students": datetime.now(UTC) > xblock.start,
|
||||
"release_date": release_date,
|
||||
|
||||
235
cms/djangoapps/contentstore/views/library.py
Normal file
235
cms/djangoapps/contentstore/views/library.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Views related to content libraries.
|
||||
A content library is a structure containing XBlocks which can be re-used in the
|
||||
multiple courses.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from contentstore.views.item import create_xblock_info
|
||||
from contentstore.utils import reverse_library_url, add_instructor
|
||||
from django.http import HttpResponseNotAllowed, Http404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
|
||||
from xmodule.modulestore.exceptions import DuplicateCourseError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .component import get_component_templates, CONTAINER_TEMPATES
|
||||
from student.auth import (
|
||||
STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access
|
||||
)
|
||||
from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, LibraryUserRole
|
||||
from student import auth
|
||||
from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest
|
||||
|
||||
__all__ = ['library_handler', 'manage_library_users']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(('GET', 'POST'))
|
||||
def library_handler(request, library_key_string=None):
|
||||
"""
|
||||
RESTful interface to most content library related functionality.
|
||||
"""
|
||||
if not LIBRARIES_ENABLED:
|
||||
log.exception("Attempted to use the content library API when the libraries feature is disabled.")
|
||||
raise Http404 # Should never happen because we test the feature in urls.py also
|
||||
|
||||
if library_key_string is not None and request.method == 'POST':
|
||||
return HttpResponseNotAllowed(("POST",))
|
||||
|
||||
if request.method == 'POST':
|
||||
return _create_library(request)
|
||||
|
||||
# request method is get, since only GET and POST are allowed by @require_http_methods(('GET', 'POST'))
|
||||
if library_key_string:
|
||||
return _display_library(library_key_string, request)
|
||||
|
||||
return _list_libraries(request)
|
||||
|
||||
|
||||
def _display_library(library_key_string, request):
|
||||
"""
|
||||
Displays single library
|
||||
"""
|
||||
library_key = CourseKey.from_string(library_key_string)
|
||||
if not isinstance(library_key, LibraryLocator):
|
||||
log.exception("Non-library key passed to content libraries API.") # Should never happen due to url regex
|
||||
raise Http404 # This is not a library
|
||||
if not has_studio_read_access(request.user, library_key):
|
||||
log.exception(
|
||||
u"User %s tried to access library %s without permission",
|
||||
request.user.username, unicode(library_key)
|
||||
)
|
||||
raise PermissionDenied()
|
||||
|
||||
library = modulestore().get_library(library_key)
|
||||
if library is None:
|
||||
log.exception(u"Library %s not found", unicode(library_key))
|
||||
raise Http404
|
||||
|
||||
response_format = 'html'
|
||||
if (
|
||||
request.REQUEST.get('format', 'html') == 'json' or
|
||||
'application/json' in request.META.get('HTTP_ACCEPT', 'text/html')
|
||||
):
|
||||
response_format = 'json'
|
||||
|
||||
return library_blocks_view(library, request.user, response_format)
|
||||
|
||||
|
||||
def _list_libraries(request):
|
||||
"""
|
||||
List all accessible libraries
|
||||
"""
|
||||
lib_info = [
|
||||
{
|
||||
"display_name": lib.display_name,
|
||||
"library_key": unicode(lib.location.library_key),
|
||||
}
|
||||
for lib in modulestore().get_libraries()
|
||||
if has_studio_read_access(request.user, lib.location.library_key)
|
||||
]
|
||||
return JsonResponse(lib_info)
|
||||
|
||||
|
||||
@expect_json
|
||||
def _create_library(request):
|
||||
"""
|
||||
Helper method for creating a new library.
|
||||
"""
|
||||
if not auth.has_access(request.user, CourseCreatorRole()):
|
||||
log.exception(u"User %s tried to create a library without permission", request.user.username)
|
||||
raise PermissionDenied()
|
||||
display_name = None
|
||||
try:
|
||||
display_name = request.json['display_name']
|
||||
org = request.json['org']
|
||||
library = request.json.get('number', None)
|
||||
if library is None:
|
||||
library = request.json['library']
|
||||
store = modulestore()
|
||||
with store.default_store(ModuleStoreEnum.Type.split):
|
||||
new_lib = store.create_library(
|
||||
org=org,
|
||||
library=library,
|
||||
user_id=request.user.id,
|
||||
fields={"display_name": display_name},
|
||||
)
|
||||
# Give the user admin ("Instructor") role for this library:
|
||||
add_instructor(new_lib.location.library_key, request.user, request.user)
|
||||
except KeyError as error:
|
||||
log.exception("Unable to create library - missing required JSON key.")
|
||||
return JsonResponseBadRequest({
|
||||
"ErrMsg": _("Unable to create library - missing required field '{field}'".format(field=error.message))
|
||||
})
|
||||
except InvalidKeyError as error:
|
||||
log.exception("Unable to create library - invalid key.")
|
||||
return JsonResponseBadRequest({
|
||||
"ErrMsg": _("Unable to create library '{name}'.\n\n{err}").format(name=display_name, err=error.message)
|
||||
})
|
||||
except DuplicateCourseError:
|
||||
log.exception("Unable to create library - one already exists with the same key.")
|
||||
return JsonResponseBadRequest({
|
||||
'ErrMsg': _(
|
||||
'There is already a library defined with the same '
|
||||
'organization and library code. Please '
|
||||
'change your library code so that it is unique within your organization.'
|
||||
)
|
||||
})
|
||||
|
||||
lib_key_str = unicode(new_lib.location.library_key)
|
||||
return JsonResponse({
|
||||
'url': reverse_library_url('library_handler', lib_key_str),
|
||||
'library_key': lib_key_str,
|
||||
})
|
||||
|
||||
|
||||
def library_blocks_view(library, user, response_format):
|
||||
"""
|
||||
The main view of a course's content library.
|
||||
Shows all the XBlocks in the library, and allows adding/editing/deleting
|
||||
them.
|
||||
Can be called with response_format="json" to get a JSON-formatted list of
|
||||
the XBlocks in the library along with library metadata.
|
||||
|
||||
Assumes that read permissions have been checked before calling this.
|
||||
"""
|
||||
assert isinstance(library.location.library_key, LibraryLocator)
|
||||
assert isinstance(library.location, LibraryUsageLocator)
|
||||
|
||||
children = library.children
|
||||
if response_format == "json":
|
||||
# The JSON response for this request is short and sweet:
|
||||
prev_version = library.runtime.course_entry.structure['previous_version']
|
||||
return JsonResponse({
|
||||
"display_name": library.display_name,
|
||||
"library_id": unicode(library.location.library_key),
|
||||
"version": unicode(library.runtime.course_entry.course_key.version),
|
||||
"previous_version": unicode(prev_version) if prev_version else None,
|
||||
"blocks": [unicode(x) for x in children],
|
||||
})
|
||||
|
||||
can_edit = has_studio_write_access(user, library.location.library_key)
|
||||
|
||||
xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
|
||||
component_templates = get_component_templates(library, library=True) if can_edit else []
|
||||
|
||||
return render_to_response('library.html', {
|
||||
'can_edit': can_edit,
|
||||
'context_library': library,
|
||||
'component_templates': json.dumps(component_templates),
|
||||
'xblock_info': xblock_info,
|
||||
'templates': CONTAINER_TEMPATES,
|
||||
'lib_users_url': reverse_library_url('manage_library_users', unicode(library.location.library_key)),
|
||||
})
|
||||
|
||||
|
||||
def manage_library_users(request, library_key_string):
|
||||
"""
|
||||
Studio UI for editing the users within a library.
|
||||
|
||||
Uses the /course_team/:library_key/:user_email/ REST API to make changes.
|
||||
"""
|
||||
library_key = CourseKey.from_string(library_key_string)
|
||||
if not isinstance(library_key, LibraryLocator):
|
||||
raise Http404 # This is not a library
|
||||
user_perms = get_user_permissions(request.user, library_key)
|
||||
if not user_perms & STUDIO_VIEW_USERS:
|
||||
raise PermissionDenied()
|
||||
library = modulestore().get_library(library_key)
|
||||
if library is None:
|
||||
raise Http404
|
||||
|
||||
# Segment all the users explicitly associated with this library, ensuring each user only has one role listed:
|
||||
instructors = set(CourseInstructorRole(library_key).users_with_role())
|
||||
staff = set(CourseStaffRole(library_key).users_with_role()) - instructors
|
||||
users = set(LibraryUserRole(library_key).users_with_role()) - instructors - staff
|
||||
all_users = instructors | staff | users
|
||||
|
||||
return render_to_response('manage_users_lib.html', {
|
||||
'context_library': library,
|
||||
'staff': staff,
|
||||
'instructors': instructors,
|
||||
'users': users,
|
||||
'all_users': all_users,
|
||||
'allow_actions': bool(user_perms & STUDIO_EDIT_ROLES),
|
||||
'library_key': unicode(library_key),
|
||||
'lib_users_url': reverse_library_url('manage_library_users', library_key_string),
|
||||
})
|
||||
@@ -14,6 +14,7 @@ from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.django import modulestore, ModuleI18nService
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from xmodule.x_module import ModuleSystem
|
||||
@@ -21,6 +22,7 @@ from xblock.runtime import KvsFieldData
|
||||
from xblock.django.request import webob_to_django_response, django_to_webob_request
|
||||
from xblock.exceptions import NoSuchHandlerError
|
||||
from xblock.fragment import Fragment
|
||||
from student.auth import has_studio_read_access, has_studio_write_access
|
||||
|
||||
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
|
||||
from cms.lib.xblock.field_data import CmsFieldData
|
||||
@@ -123,6 +125,28 @@ class StudioUserService(object):
|
||||
return self._request.user.id
|
||||
|
||||
|
||||
class StudioPermissionsService(object):
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
Deprecated. To be replaced by a more general authorization service.
|
||||
|
||||
Only used by LibraryContentDescriptor (and library_tools.py).
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
super(StudioPermissionsService, self).__init__()
|
||||
self._request = request
|
||||
|
||||
def can_read(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_read_access(self._request.user, course_key)
|
||||
|
||||
def can_write(self, course_key):
|
||||
""" Does the user have read access to the given course/library? """
|
||||
return has_studio_write_access(self._request.user, course_key)
|
||||
|
||||
|
||||
def _preview_module_system(request, descriptor, field_data):
|
||||
"""
|
||||
Returns a ModuleSystem for the specified descriptor that is specialized for
|
||||
@@ -152,6 +176,7 @@ def _preview_module_system(request, descriptor, field_data):
|
||||
]
|
||||
|
||||
descriptor.runtime._services['user'] = StudioUserService(request) # pylint: disable=protected-access
|
||||
descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access
|
||||
|
||||
return PreviewModuleSystem(
|
||||
static_url=settings.STATIC_URL,
|
||||
@@ -177,6 +202,7 @@ def _preview_module_system(request, descriptor, field_data):
|
||||
services={
|
||||
"i18n": ModuleI18nService(),
|
||||
"field-data": field_data,
|
||||
"library_tools": LibraryToolsService(modulestore()),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -224,6 +250,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
'content': frag.content,
|
||||
'is_root': is_root,
|
||||
'is_reorderable': is_reorderable,
|
||||
'can_edit': context.get('can_edit', True),
|
||||
}
|
||||
html = render_to_string('studio_xblock_wrapper.html', template_context)
|
||||
frag = wrap_fragment(frag, html)
|
||||
|
||||
@@ -66,6 +66,6 @@ def login_page(request):
|
||||
def howitworks(request):
|
||||
"Proxy view"
|
||||
if request.user.is_authenticated():
|
||||
return redirect('/course/')
|
||||
return redirect('/home/')
|
||||
else:
|
||||
return render_to_response('howitworks.html', {})
|
||||
|
||||
@@ -6,7 +6,7 @@ import lxml
|
||||
import datetime
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.utils import reverse_course_url, add_instructor
|
||||
from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor
|
||||
from student.auth import has_course_author_access
|
||||
from contentstore.views.course import course_outline_initial_state
|
||||
from contentstore.views.item import create_xblock_info, VisibilityState
|
||||
@@ -14,7 +14,7 @@ from course_action_state.models import CourseRerunState
|
||||
from util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from student.tests.factories import UserFactory
|
||||
from course_action_state.managers import CourseRerunUIStateManager
|
||||
@@ -42,7 +42,7 @@ class TestCourseIndex(CourseTestCase):
|
||||
"""
|
||||
Test getting the list of courses and then pulling up their outlines
|
||||
"""
|
||||
index_url = '/course/'
|
||||
index_url = '/home/'
|
||||
index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html')
|
||||
parsed_html = lxml.html.fromstring(index_response.content)
|
||||
course_link_eles = parsed_html.find_class('course-link')
|
||||
@@ -61,6 +61,27 @@ class TestCourseIndex(CourseTestCase):
|
||||
course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0]
|
||||
self.assertEqual(course_menu_link.find("a").get("href"), link.get("href"))
|
||||
|
||||
def test_libraries_on_course_index(self):
|
||||
"""
|
||||
Test getting the list of libraries from the course listing page
|
||||
"""
|
||||
# Add a library:
|
||||
lib1 = LibraryFactory.create()
|
||||
|
||||
index_url = '/home/'
|
||||
index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
|
||||
parsed_html = lxml.html.fromstring(index_response.content)
|
||||
library_link_elements = parsed_html.find_class('library-link')
|
||||
self.assertEqual(len(library_link_elements), 1)
|
||||
link = library_link_elements[0]
|
||||
self.assertEqual(
|
||||
link.get("href"),
|
||||
reverse_library_url('library_handler', lib1.location.library_key),
|
||||
)
|
||||
# now test that url
|
||||
outline_response = self.client.get(link.get("href"), {}, HTTP_ACCEPT='text/html')
|
||||
self.assertEqual(outline_response.status_code, 200)
|
||||
|
||||
def test_is_staff_access(self):
|
||||
"""
|
||||
Test that people with is_staff see the courses and can navigate into them
|
||||
|
||||
@@ -4,7 +4,7 @@ Unit tests for helpers.py.
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory
|
||||
from django.utils import http
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ class HelpersTestCase(CourseTestCase):
|
||||
display_name="My Video")
|
||||
self.assertIsNone(xblock_studio_url(video))
|
||||
|
||||
# Verify library URL
|
||||
library = LibraryFactory.create()
|
||||
expected_url = u'/library/{}'.format(unicode(library.location.library_key))
|
||||
self.assertEqual(xblock_studio_url(library), expected_url)
|
||||
|
||||
def test_xblock_type_display_name(self):
|
||||
|
||||
# Verify chapter type display name
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from datetime import datetime, timedelta
|
||||
import ddt
|
||||
|
||||
from mock import patch
|
||||
from mock import patch, Mock, PropertyMock
|
||||
from pytz import UTC
|
||||
from webob import Response
|
||||
|
||||
@@ -18,13 +18,15 @@ from contentstore.views.component import (
|
||||
component_handler, get_component_templates
|
||||
)
|
||||
|
||||
|
||||
from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls
|
||||
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
|
||||
from xblock.exceptions import NoSuchHandlerError
|
||||
from opaque_keys.edx.keys import UsageKey, CourseKey
|
||||
@@ -85,12 +87,18 @@ class ItemTest(CourseTestCase):
|
||||
class GetItemTest(ItemTest):
|
||||
"""Tests for '/xblock' GET url."""
|
||||
|
||||
def _get_container_preview(self, usage_key):
|
||||
def _get_preview(self, usage_key, data=None):
|
||||
""" Makes a request to xblock preview handler """
|
||||
preview_url = reverse_usage_url("xblock_view_handler", usage_key, {'view_name': 'container_preview'})
|
||||
data = data if data else {}
|
||||
resp = self.client.get(preview_url, data, HTTP_ACCEPT='application/json')
|
||||
return resp
|
||||
|
||||
def _get_container_preview(self, usage_key, data=None):
|
||||
"""
|
||||
Returns the HTML and resources required for the xblock at the specified UsageKey
|
||||
"""
|
||||
preview_url = reverse_usage_url("xblock_view_handler", usage_key, {'view_name': 'container_preview'})
|
||||
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
|
||||
resp = self._get_preview(usage_key, data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp_content = json.loads(resp.content)
|
||||
html = resp_content['html']
|
||||
@@ -99,6 +107,14 @@ class GetItemTest(ItemTest):
|
||||
self.assertIsNotNone(resources)
|
||||
return html, resources
|
||||
|
||||
def _get_container_preview_with_error(self, usage_key, expected_code, data=None, content_contains=None):
|
||||
""" Make request and asserts on response code and response contents """
|
||||
resp = self._get_preview(usage_key, data)
|
||||
self.assertEqual(resp.status_code, expected_code)
|
||||
if content_contains:
|
||||
self.assertIn(content_contains, resp.content)
|
||||
return resp
|
||||
|
||||
@ddt.data(
|
||||
(1, 21, 23, 35, 37),
|
||||
(2, 22, 24, 38, 39),
|
||||
@@ -246,6 +262,40 @@ class GetItemTest(ItemTest):
|
||||
self.assertIn('New_NAME_A', html)
|
||||
self.assertIn('New_NAME_B', html)
|
||||
|
||||
def test_valid_paging(self):
|
||||
"""
|
||||
Tests that valid paging is passed along to underlying block
|
||||
"""
|
||||
with patch('contentstore.views.item.get_preview_fragment') as patched_get_preview_fragment:
|
||||
retval = Mock()
|
||||
type(retval).content = PropertyMock(return_value="Some content")
|
||||
type(retval).resources = PropertyMock(return_value=[])
|
||||
patched_get_preview_fragment.return_value = retval
|
||||
|
||||
root_usage_key = self._create_vertical()
|
||||
_, _ = self._get_container_preview(
|
||||
root_usage_key,
|
||||
{'enable_paging': 'true', 'page_number': 0, 'page_size': 2}
|
||||
)
|
||||
call_args = patched_get_preview_fragment.call_args[0]
|
||||
_, _, context = call_args
|
||||
self.assertIn('paging', context)
|
||||
self.assertEqual({'page_number': 0, 'page_size': 2}, context['paging'])
|
||||
|
||||
@ddt.data([1, 'invalid'], ['invalid', 2])
|
||||
@ddt.unpack
|
||||
def test_invalid_paging(self, page_number, page_size):
|
||||
"""
|
||||
Tests that valid paging is passed along to underlying block
|
||||
"""
|
||||
root_usage_key = self._create_vertical()
|
||||
self._get_container_preview_with_error(
|
||||
root_usage_key,
|
||||
400,
|
||||
data={'enable_paging': 'true', 'page_number': page_number, 'page_size': page_size},
|
||||
content_contains="Couldn't parse paging parameters"
|
||||
)
|
||||
|
||||
|
||||
class DeleteItem(ItemTest):
|
||||
"""Tests for '/xblock' DELETE url."""
|
||||
@@ -893,6 +943,29 @@ class TestEditItem(ItemTest):
|
||||
self._verify_published_with_draft(unit_usage_key)
|
||||
self._verify_published_with_draft(html_usage_key)
|
||||
|
||||
def test_field_value_errors(self):
|
||||
"""
|
||||
Test that if the user's input causes a ValueError on an XBlock field,
|
||||
we provide a friendly error message back to the user.
|
||||
"""
|
||||
response = self.create_xblock(parent_usage_key=self.seq_usage_key, category='video')
|
||||
video_usage_key = self.response_usage_key(response)
|
||||
update_url = reverse_usage_url('xblock_handler', video_usage_key)
|
||||
|
||||
response = self.client.ajax_post(
|
||||
update_url,
|
||||
data={
|
||||
'id': unicode(video_usage_key),
|
||||
'metadata': {
|
||||
'saved_video_position': "Not a valid relative time",
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
parsed = json.loads(response.content)
|
||||
self.assertIn("error", parsed)
|
||||
self.assertIn("Incorrect RelativeTime value", parsed["error"]) # See xmodule/fields.py
|
||||
|
||||
|
||||
class TestEditSplitModule(ItemTest):
|
||||
"""
|
||||
@@ -1420,6 +1493,90 @@ class TestXBlockInfo(ItemTest):
|
||||
self.assertIsNone(xblock_info.get('edited_by', None))
|
||||
|
||||
|
||||
class TestLibraryXBlockInfo(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for XBlock Info for XBlocks in a content library
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestLibraryXBlockInfo, self).setUp()
|
||||
user_id = self.user.id
|
||||
self.library = LibraryFactory.create()
|
||||
self.top_level_html = ItemFactory.create(
|
||||
parent_location=self.library.location, category='html', user_id=user_id, publish_item=False
|
||||
)
|
||||
self.vertical = ItemFactory.create(
|
||||
parent_location=self.library.location, category='vertical', user_id=user_id, publish_item=False
|
||||
)
|
||||
self.child_html = ItemFactory.create(
|
||||
parent_location=self.vertical.location, category='html', display_name='Test HTML Child Block',
|
||||
user_id=user_id, publish_item=False
|
||||
)
|
||||
|
||||
def test_lib_xblock_info(self):
|
||||
html_block = modulestore().get_item(self.top_level_html.location)
|
||||
xblock_info = create_xblock_info(html_block)
|
||||
self.validate_component_xblock_info(xblock_info, html_block)
|
||||
self.assertIsNone(xblock_info.get('child_info', None))
|
||||
|
||||
def test_lib_child_xblock_info(self):
|
||||
html_block = modulestore().get_item(self.child_html.location)
|
||||
xblock_info = create_xblock_info(html_block, include_ancestor_info=True, include_child_info=True)
|
||||
self.validate_component_xblock_info(xblock_info, html_block)
|
||||
self.assertIsNone(xblock_info.get('child_info', None))
|
||||
ancestors = xblock_info['ancestor_info']['ancestors']
|
||||
self.assertEqual(len(ancestors), 2)
|
||||
self.assertEqual(ancestors[0]['category'], 'vertical')
|
||||
self.assertEqual(ancestors[0]['id'], unicode(self.vertical.location))
|
||||
self.assertEqual(ancestors[1]['category'], 'library')
|
||||
|
||||
def validate_component_xblock_info(self, xblock_info, original_block):
|
||||
"""
|
||||
Validate that the xblock info is correct for the test component.
|
||||
"""
|
||||
self.assertEqual(xblock_info['category'], original_block.category)
|
||||
self.assertEqual(xblock_info['id'], unicode(original_block.location))
|
||||
self.assertEqual(xblock_info['display_name'], original_block.display_name)
|
||||
self.assertIsNone(xblock_info.get('has_changes', None))
|
||||
self.assertIsNone(xblock_info.get('published', None))
|
||||
self.assertIsNone(xblock_info.get('published_on', None))
|
||||
self.assertIsNone(xblock_info.get('graders', None))
|
||||
|
||||
|
||||
class TestLibraryXBlockCreation(ItemTest):
|
||||
"""
|
||||
Tests the adding of XBlocks to Library
|
||||
"""
|
||||
def test_add_xblock(self):
|
||||
"""
|
||||
Verify we can add an XBlock to a Library.
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
self.create_xblock(parent_usage_key=lib.location, display_name='Test', category="html")
|
||||
lib = self.store.get_library(lib.location.library_key)
|
||||
self.assertTrue(lib.children)
|
||||
xblock_locator = lib.children[0]
|
||||
self.assertEqual(self.store.get_item(xblock_locator).display_name, 'Test')
|
||||
|
||||
def test_no_add_discussion(self):
|
||||
"""
|
||||
Verify we cannot add a discussion module to a Library.
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='discussion')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
lib = self.store.get_library(lib.location.library_key)
|
||||
self.assertFalse(lib.children)
|
||||
|
||||
def test_no_add_advanced(self):
|
||||
lib = LibraryFactory.create()
|
||||
lib.advanced_modules = ['lti']
|
||||
lib.save()
|
||||
response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='lti')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
lib = self.store.get_library(lib.location.library_key)
|
||||
self.assertFalse(lib.children)
|
||||
|
||||
|
||||
class TestXBlockPublishingInfo(ItemTest):
|
||||
"""
|
||||
Unit tests for XBlock's outline handling.
|
||||
|
||||
228
cms/djangoapps/contentstore/views/tests/test_library.py
Normal file
228
cms/djangoapps/contentstore/views/tests/test_library.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Unit tests for contentstore.views.library
|
||||
|
||||
More important high-level tests are in contentstore/tests/test_libraries.py
|
||||
"""
|
||||
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
|
||||
from contentstore.utils import reverse_course_url, reverse_library_url
|
||||
from contentstore.views.component import get_component_templates
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory
|
||||
from mock import patch
|
||||
from opaque_keys.edx.locator import CourseKey, LibraryLocator
|
||||
import ddt
|
||||
from student.roles import LibraryUserRole
|
||||
|
||||
LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries
|
||||
|
||||
|
||||
def make_url_for_lib(key):
|
||||
""" Get the RESTful/studio URL for testing the given library """
|
||||
if isinstance(key, LibraryLocator):
|
||||
key = unicode(key)
|
||||
return LIBRARY_REST_URL + key
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class UnitTestLibraries(ModuleStoreTestCase):
|
||||
"""
|
||||
Unit tests for library views
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
user_password = super(UnitTestLibraries, self).setUp()
|
||||
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username=self.user.username, password=user_password)
|
||||
|
||||
######################################################
|
||||
# Tests for /library/ - list and create libraries:
|
||||
|
||||
@patch("contentstore.views.library.LIBRARIES_ENABLED", False)
|
||||
def test_with_libraries_disabled(self):
|
||||
"""
|
||||
The library URLs should return 404 if libraries are disabled.
|
||||
"""
|
||||
response = self.client.get_json(LIBRARY_REST_URL)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_list_libraries(self):
|
||||
"""
|
||||
Test that we can GET /library/ to list all libraries visible to the current user.
|
||||
"""
|
||||
# Create some more libraries
|
||||
libraries = [LibraryFactory.create() for _ in range(0, 3)]
|
||||
lib_dict = dict([(lib.location.library_key, lib) for lib in libraries])
|
||||
|
||||
response = self.client.get_json(LIBRARY_REST_URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
lib_list = parse_json(response)
|
||||
self.assertEqual(len(lib_list), len(libraries))
|
||||
for entry in lib_list:
|
||||
self.assertIn("library_key", entry)
|
||||
self.assertIn("display_name", entry)
|
||||
key = CourseKey.from_string(entry["library_key"])
|
||||
self.assertIn(key, lib_dict)
|
||||
self.assertEqual(entry["display_name"], lib_dict[key].display_name)
|
||||
del lib_dict[key] # To ensure no duplicates are matched
|
||||
|
||||
@ddt.data("delete", "put")
|
||||
def test_bad_http_verb(self, verb):
|
||||
"""
|
||||
We should get an error if we do weird requests to /library/
|
||||
"""
|
||||
response = getattr(self.client, verb)(LIBRARY_REST_URL)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_create_library(self):
|
||||
""" Create a library. """
|
||||
response = self.client.ajax_post(LIBRARY_REST_URL, {
|
||||
'org': 'org',
|
||||
'library': 'lib',
|
||||
'display_name': "New Library",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# That's all we check. More detailed tests are in contentstore.tests.test_libraries...
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True})
|
||||
def test_lib_create_permission(self):
|
||||
"""
|
||||
Users who aren't given course creator roles shouldn't be able to create
|
||||
libraries either.
|
||||
"""
|
||||
self.client.logout()
|
||||
ns_user, password = self.create_non_staff_user()
|
||||
self.client.login(username=ns_user.username, password=password)
|
||||
|
||||
response = self.client.ajax_post(LIBRARY_REST_URL, {
|
||||
'org': 'org', 'library': 'lib', 'display_name': "New Library",
|
||||
})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ddt.data(
|
||||
{},
|
||||
{'org': 'org'},
|
||||
{'library': 'lib'},
|
||||
{'org': 'C++', 'library': 'lib', 'display_name': 'Lib with invalid characters in key'},
|
||||
{'org': 'Org', 'library': 'Wh@t?', 'display_name': 'Lib with invalid characters in key'},
|
||||
)
|
||||
def test_create_library_invalid(self, data):
|
||||
"""
|
||||
Make sure we are prevented from creating libraries with invalid keys/data
|
||||
"""
|
||||
response = self.client.ajax_post(LIBRARY_REST_URL, data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_no_duplicate_libraries(self):
|
||||
"""
|
||||
We should not be able to create multiple libraries with the same key
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
lib_key = lib.location.library_key
|
||||
response = self.client.ajax_post(LIBRARY_REST_URL, {
|
||||
'org': lib_key.org,
|
||||
'library': lib_key.library,
|
||||
'display_name': "A Duplicate key, same as 'lib'",
|
||||
})
|
||||
self.assertIn('already a library defined', parse_json(response)['ErrMsg'])
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
######################################################
|
||||
# Tests for /library/:lib_key/ - get a specific library as JSON or HTML editing view
|
||||
|
||||
def test_get_lib_info(self):
|
||||
"""
|
||||
Test that we can get data about a library (in JSON format) using /library/:key/
|
||||
"""
|
||||
# Create a library
|
||||
lib_key = LibraryFactory.create().location.library_key
|
||||
# Re-load the library from the modulestore, explicitly including version information:
|
||||
lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False)
|
||||
version = lib.location.library_key.version_guid
|
||||
self.assertNotEqual(version, None)
|
||||
|
||||
response = self.client.get_json(make_url_for_lib(lib_key))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
info = parse_json(response)
|
||||
self.assertEqual(info['display_name'], lib.display_name)
|
||||
self.assertEqual(info['library_id'], unicode(lib_key))
|
||||
self.assertEqual(info['previous_version'], None)
|
||||
self.assertNotEqual(info['version'], None)
|
||||
self.assertNotEqual(info['version'], '')
|
||||
self.assertEqual(info['version'], unicode(version))
|
||||
|
||||
def test_get_lib_edit_html(self):
|
||||
"""
|
||||
Test that we can get the studio view for editing a library using /library/:key/
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
|
||||
response = self.client.get(make_url_for_lib(lib.location.library_key))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("<html", response.content)
|
||||
self.assertIn(lib.display_name, response.content)
|
||||
|
||||
@ddt.data('library-v1:Nonexistent+library', 'course-v1:Org+Course', 'course-v1:Org+Course+Run', 'invalid')
|
||||
def test_invalid_keys(self, key_str):
|
||||
"""
|
||||
Check that various Nonexistent/invalid keys give 404 errors
|
||||
"""
|
||||
response = self.client.get_json(make_url_for_lib(key_str))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_bad_http_verb_with_lib_key(self):
|
||||
"""
|
||||
We should get an error if we do weird requests to /library/
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
for verb in ("post", "delete", "put"):
|
||||
response = getattr(self.client, verb)(make_url_for_lib(lib.location.library_key))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_no_access(self):
|
||||
user, password = self.create_non_staff_user()
|
||||
self.client.login(username=user, password=password)
|
||||
|
||||
lib = LibraryFactory.create()
|
||||
response = self.client.get(make_url_for_lib(lib.location.library_key))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_get_component_templates(self):
|
||||
"""
|
||||
Verify that templates for adding discussion and advanced components to
|
||||
content libraries are not provided.
|
||||
"""
|
||||
lib = LibraryFactory.create()
|
||||
lib.advanced_modules = ['lti']
|
||||
lib.save()
|
||||
templates = [template['type'] for template in get_component_templates(lib, library=True)]
|
||||
self.assertIn('problem', templates)
|
||||
self.assertNotIn('discussion', templates)
|
||||
self.assertNotIn('advanced', templates)
|
||||
|
||||
def test_manage_library_users(self):
|
||||
"""
|
||||
Simple test that the Library "User Access" view works.
|
||||
Also tests that we can use the REST API to assign a user to a library.
|
||||
"""
|
||||
library = LibraryFactory.create()
|
||||
extra_user, _ = self.create_non_staff_user()
|
||||
manage_users_url = reverse_library_url('manage_library_users', unicode(library.location.library_key))
|
||||
|
||||
response = self.client.get(manage_users_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# extra_user has not been assigned to the library so should not show up in the list:
|
||||
self.assertNotIn(extra_user.username, response.content)
|
||||
|
||||
# Now add extra_user to the library:
|
||||
user_details_url = reverse_course_url(
|
||||
'course_team_handler',
|
||||
library.location.library_key, kwargs={'email': extra_user.email}
|
||||
)
|
||||
edit_response = self.client.ajax_post(user_details_url, {"role": LibraryUserRole.ROLE})
|
||||
self.assertIn(edit_response.status_code, (200, 204))
|
||||
|
||||
# Now extra_user should apear in the list:
|
||||
response = self.client.get(manage_users_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(extra_user.username, response.content)
|
||||
@@ -70,7 +70,7 @@ class UsersTestCase(CourseTestCase):
|
||||
def test_detail_post(self):
|
||||
resp = self.client.post(
|
||||
self.detail_url,
|
||||
data={"role": None},
|
||||
data={"role": ""},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
# reload user from DB
|
||||
@@ -218,7 +218,7 @@ class UsersTestCase(CourseTestCase):
|
||||
data={"role": "instructor"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
|
||||
@@ -232,7 +232,7 @@ class UsersTestCase(CourseTestCase):
|
||||
data={"role": "instructor"},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
|
||||
@@ -255,7 +255,7 @@ class UsersTestCase(CourseTestCase):
|
||||
self.user.save()
|
||||
|
||||
resp = self.client.delete(self.detail_url)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
result = json.loads(resp.content)
|
||||
self.assertIn("error", result)
|
||||
# reload user from DB
|
||||
|
||||
@@ -9,11 +9,12 @@ from edxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from util.json_request import JsonResponse, expect_json
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole
|
||||
from course_creators.views import user_requested_access
|
||||
|
||||
from student.auth import has_course_author_access
|
||||
from student.auth import STUDIO_EDIT_ROLES, STUDIO_VIEW_USERS, get_user_permissions
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from django.http import HttpResponseNotFound
|
||||
@@ -50,8 +51,7 @@ def course_team_handler(request, course_key_string=None, email=None):
|
||||
json: remove a particular course team member from the course team (email is required).
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_key_string) if course_key_string else None
|
||||
if not has_course_author_access(request.user, course_key):
|
||||
raise PermissionDenied()
|
||||
# No permissions check here - each helper method does its own check.
|
||||
|
||||
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
return _course_team_user(request, course_key, email)
|
||||
@@ -66,7 +66,8 @@ def _manage_users(request, course_key):
|
||||
This view will return all CMS users who are editors for the specified course
|
||||
"""
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_course_author_access(request.user, course_key):
|
||||
user_perms = get_user_permissions(request.user, course_key)
|
||||
if not user_perms & STUDIO_VIEW_USERS:
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_course(course_key)
|
||||
@@ -78,7 +79,7 @@ def _manage_users(request, course_key):
|
||||
'context_course': course_module,
|
||||
'staff': staff,
|
||||
'instructors': instructors,
|
||||
'allow_actions': has_course_author_access(request.user, course_key, role=CourseInstructorRole),
|
||||
'allow_actions': bool(user_perms & STUDIO_EDIT_ROLES),
|
||||
})
|
||||
|
||||
|
||||
@@ -88,17 +89,14 @@ def _course_team_user(request, course_key, email):
|
||||
Handle the add, remove, promote, demote requests ensuring the requester has authority
|
||||
"""
|
||||
# check that logged in user has permissions to this item
|
||||
if has_course_author_access(request.user, course_key, role=CourseInstructorRole):
|
||||
# instructors have full permissions
|
||||
pass
|
||||
elif has_course_author_access(request.user, course_key, role=CourseStaffRole) and email == request.user.email:
|
||||
# staff can only affect themselves
|
||||
requester_perms = get_user_permissions(request.user, course_key)
|
||||
permissions_error_response = JsonResponse({"error": _("Insufficient permissions")}, 403)
|
||||
if (requester_perms & STUDIO_VIEW_USERS) or (email == request.user.email):
|
||||
# This user has permissions to at least view the list of users or is editing themself
|
||||
pass
|
||||
else:
|
||||
msg = {
|
||||
"error": _("Insufficient permissions")
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
# This user is not even allowed to know who the authorized users are.
|
||||
return permissions_error_response
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
@@ -108,7 +106,13 @@ def _course_team_user(request, course_key, email):
|
||||
}
|
||||
return JsonResponse(msg, 404)
|
||||
|
||||
# role hierarchy: globalstaff > "instructor" > "staff" (in a course)
|
||||
is_library = isinstance(course_key, LibraryLocator)
|
||||
# Ordered list of roles: can always move self to the right, but need STUDIO_EDIT_ROLES to move any user left
|
||||
if is_library:
|
||||
role_hierarchy = (CourseInstructorRole, CourseStaffRole, LibraryUserRole)
|
||||
else:
|
||||
role_hierarchy = (CourseInstructorRole, CourseStaffRole)
|
||||
|
||||
if request.method == "GET":
|
||||
# just return info about the user
|
||||
msg = {
|
||||
@@ -117,12 +121,17 @@ def _course_team_user(request, course_key, email):
|
||||
"role": None,
|
||||
}
|
||||
# what's the highest role that this user has? (How should this report global staff?)
|
||||
for role in [CourseInstructorRole(course_key), CourseStaffRole(course_key)]:
|
||||
if role.has_user(user):
|
||||
for role in role_hierarchy:
|
||||
if role(course_key).has_user(user):
|
||||
msg["role"] = role.ROLE
|
||||
break
|
||||
return JsonResponse(msg)
|
||||
|
||||
# All of the following code is for editing/promoting/deleting users.
|
||||
# Check that the user has STUDIO_EDIT_ROLES permission or is editing themselves:
|
||||
if not ((requester_perms & STUDIO_EDIT_ROLES) or (user.id == request.user.id)):
|
||||
return permissions_error_response
|
||||
|
||||
# can't modify an inactive user
|
||||
if not user.is_active:
|
||||
msg = {
|
||||
@@ -131,60 +140,44 @@ def _course_team_user(request, course_key, email):
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
if request.method == "DELETE":
|
||||
try:
|
||||
try_remove_instructor(request, course_key, user)
|
||||
except CannotOrphanCourse as oops:
|
||||
return JsonResponse(oops.msg, 400)
|
||||
new_role = None
|
||||
else:
|
||||
# only other operation supported is to promote/demote a user by changing their role:
|
||||
# role may be None or "" (equivalent to a DELETE request) but must be set.
|
||||
# Check that the new role was specified:
|
||||
if "role" in request.json or "role" in request.POST:
|
||||
new_role = request.json.get("role", request.POST.get("role"))
|
||||
else:
|
||||
return JsonResponse({"error": _("No `role` specified.")}, 400)
|
||||
|
||||
auth.remove_users(request.user, CourseStaffRole(course_key), user)
|
||||
return JsonResponse()
|
||||
old_roles = set()
|
||||
role_added = False
|
||||
for role_type in role_hierarchy:
|
||||
role = role_type(course_key)
|
||||
if role_type.ROLE == new_role:
|
||||
if (requester_perms & STUDIO_EDIT_ROLES) or (user.id == request.user.id and old_roles):
|
||||
# User has STUDIO_EDIT_ROLES permission or
|
||||
# is currently a member of a higher role, and is thus demoting themself
|
||||
auth.add_users(request.user, role, user)
|
||||
role_added = True
|
||||
else:
|
||||
return permissions_error_response
|
||||
elif role.has_user(user):
|
||||
# Remove the user from this old role:
|
||||
old_roles.add(role)
|
||||
|
||||
# all other operations require the requesting user to specify a role
|
||||
role = request.json.get("role", request.POST.get("role"))
|
||||
if role is None:
|
||||
return JsonResponse({"error": _("`role` is required")}, 400)
|
||||
if new_role and not role_added:
|
||||
return JsonResponse({"error": _("Invalid `role` specified.")}, 400)
|
||||
|
||||
if role == "instructor":
|
||||
if not has_course_author_access(request.user, course_key, role=CourseInstructorRole):
|
||||
msg = {
|
||||
"error": _("Only instructors may create other instructors")
|
||||
}
|
||||
for role in old_roles:
|
||||
if isinstance(role, CourseInstructorRole) and role.users_with_role().count() == 1:
|
||||
msg = {"error": _("You may not remove the last Admin. Add another Admin first.")}
|
||||
return JsonResponse(msg, 400)
|
||||
auth.add_users(request.user, CourseInstructorRole(course_key), user)
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
CourseEnrollment.enroll(user, course_key)
|
||||
elif role == "staff":
|
||||
# add to staff regardless (can't do after removing from instructors as will no longer
|
||||
# be allowed)
|
||||
auth.add_users(request.user, CourseStaffRole(course_key), user)
|
||||
try:
|
||||
try_remove_instructor(request, course_key, user)
|
||||
except CannotOrphanCourse as oops:
|
||||
return JsonResponse(oops.msg, 400)
|
||||
auth.remove_users(request.user, role, user)
|
||||
|
||||
# auto-enroll the course creator in the course so that "View Live" will work.
|
||||
if new_role and not is_library:
|
||||
# The user may be newly added to this course.
|
||||
# auto-enroll the user in the course so that "View Live" will work.
|
||||
CourseEnrollment.enroll(user, course_key)
|
||||
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
class CannotOrphanCourse(Exception):
|
||||
"""
|
||||
Exception raised if an attempt is made to remove all responsible instructors from course.
|
||||
"""
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
Exception.__init__(self)
|
||||
|
||||
|
||||
def try_remove_instructor(request, course_key, user):
|
||||
|
||||
# remove all roles in this course from this user: but fail if the user
|
||||
# is the last instructor in the course team
|
||||
instructors = CourseInstructorRole(course_key)
|
||||
if instructors.has_user(user):
|
||||
if instructors.users_with_role().count() == 1:
|
||||
msg = {"error": _("You may not remove the last instructor from a course")}
|
||||
raise CannotOrphanCourse(msg)
|
||||
else:
|
||||
auth.remove_users(request.user, instructors, user)
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"SUBDOMAIN_BRANDING": false,
|
||||
"SUBDOMAIN_COURSE_LISTINGS": false,
|
||||
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
|
||||
"ALLOW_COURSE_RERUNS": true
|
||||
"ALLOW_COURSE_RERUNS": true,
|
||||
"ENABLE_CONTENT_LIBRARIES": true
|
||||
},
|
||||
"FEEDBACK_SUBMISSION_EMAIL": "",
|
||||
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
|
||||
|
||||
@@ -766,6 +766,7 @@ ADVANCED_COMPONENT_TYPES = [
|
||||
'word_cloud',
|
||||
'graphical_slider_tool',
|
||||
'lti',
|
||||
'library_content',
|
||||
# XBlocks from pmitros repos are prototypes. They should not be used
|
||||
# except for edX Learning Sciences experiments on edge.edx.org without
|
||||
# further work to make them robust, maintainable, finalize data formats,
|
||||
|
||||
@@ -226,3 +226,6 @@ FEATURES['USE_MICROSITES'] = True
|
||||
# For consistency in user-experience, keep the value of this setting in sync with
|
||||
# the one in lms/envs/test.py
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
|
||||
# Enable content libraries code for the tests
|
||||
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
|
||||
|
||||
@@ -239,6 +239,7 @@ define([
|
||||
"js/spec/views/assets_spec",
|
||||
"js/spec/views/baseview_spec",
|
||||
"js/spec/views/container_spec",
|
||||
"js/spec/views/paged_container_spec",
|
||||
"js/spec/views/group_configuration_spec",
|
||||
"js/spec/views/paging_spec",
|
||||
"js/spec/views/unit_outline_spec",
|
||||
@@ -255,6 +256,7 @@ define([
|
||||
"js/spec/views/pages/course_outline_spec",
|
||||
"js/spec/views/pages/course_rerun_spec",
|
||||
"js/spec/views/pages/index_spec",
|
||||
"js/spec/views/pages/library_users_spec",
|
||||
|
||||
"js/spec/views/modals/base_modal_spec",
|
||||
"js/spec/views/modals/edit_xblock_spec",
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
define([
|
||||
'jquery', 'js/models/xblock_info', 'js/views/pages/container',
|
||||
'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
|
||||
'js/collections/component_template', 'xmodule', 'coffee/src/main',
|
||||
'xblock/cms.runtime.v1'
|
||||
],
|
||||
function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
|
||||
function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
|
||||
'use strict';
|
||||
return function (componentTemplates, XBlockInfoJson, action, isUnitPage) {
|
||||
var templates = new ComponentTemplates(componentTemplates, {parse: true}),
|
||||
mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true});
|
||||
return function (componentTemplates, XBlockInfoJson, action, options) {
|
||||
var main_options = {
|
||||
el: $('#content'),
|
||||
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
|
||||
action: action,
|
||||
templates: new ComponentTemplates(componentTemplates, {parse: true})
|
||||
};
|
||||
|
||||
xmoduleLoader.done(function () {
|
||||
var view = new ContainerPage({
|
||||
el: $('#content'),
|
||||
model: mainXBlockInfo,
|
||||
action: action,
|
||||
templates: templates,
|
||||
isUnitPage: isUnitPage
|
||||
});
|
||||
var view = new ContainerPage(_.extend(main_options, options));
|
||||
view.render();
|
||||
});
|
||||
};
|
||||
|
||||
23
cms/static/js/factories/library.js
Normal file
23
cms/static/js/factories/library.js
Normal file
@@ -0,0 +1,23 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/paged_container',
|
||||
'js/views/library_container', 'js/collections/component_template', 'xmodule', 'coffee/src/main',
|
||||
'xblock/cms.runtime.v1'
|
||||
],
|
||||
function($, _, XBlockInfo, PagedContainerPage, LibraryContainerView, ComponentTemplates, xmoduleLoader) {
|
||||
'use strict';
|
||||
return function (componentTemplates, XBlockInfoJson, options) {
|
||||
var main_options = {
|
||||
el: $('#content'),
|
||||
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
|
||||
templates: new ComponentTemplates(componentTemplates, {parse: true}),
|
||||
action: 'view',
|
||||
viewClass: LibraryContainerView,
|
||||
canEdit: true
|
||||
};
|
||||
|
||||
xmoduleLoader.done(function () {
|
||||
var view = new PagedContainerPage(_.extend(main_options, options));
|
||||
view.render();
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -32,7 +32,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function
|
||||
msg = new PromptView.Warning({
|
||||
title: gettext('Already a course team member'),
|
||||
message: _.template(
|
||||
gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), {
|
||||
gettext("{email} is already on the {course} team. Recheck the email address if you want to add a new member."), {
|
||||
email: email,
|
||||
course: course.escape('name')
|
||||
}, {interpolate: /\{(.+?)\}/g}
|
||||
|
||||
159
cms/static/js/factories/manage_users_lib.js
Normal file
159
cms/static/js/factories/manage_users_lib.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
Code for editing users and assigning roles within a library context.
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt', 'js/views/utils/view_utils'],
|
||||
function($, _, gettext, PromptView, ViewUtils) {
|
||||
'use strict';
|
||||
return function (libraryName, allUserEmails, tplUserURL) {
|
||||
var unknownErrorMessage = gettext('Unknown'),
|
||||
$createUserForm = $('#create-user-form'),
|
||||
$createUserFormWrapper = $createUserForm.closest('.wrapper-create-user'),
|
||||
$cancelButton;
|
||||
|
||||
// Our helper method that calls the RESTful API to add/remove/change user roles:
|
||||
var changeRole = function(email, newRole, opts) {
|
||||
var url = tplUserURL.replace('@@EMAIL@@', email);
|
||||
var errMessage = opts.errMessage || gettext("There was an error changing the user's role");
|
||||
var onSuccess = opts.onSuccess || function(data){ ViewUtils.reload(); };
|
||||
var onError = opts.onError || function(){};
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: newRole ? 'POST' : 'DELETE',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json',
|
||||
notifyOnError: false,
|
||||
data: JSON.stringify({role: newRole}),
|
||||
success: onSuccess,
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
var message, prompt;
|
||||
try {
|
||||
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
|
||||
} catch (e) {
|
||||
message = unknownErrorMessage;
|
||||
}
|
||||
prompt = new PromptView.Error({
|
||||
title: errMessage,
|
||||
message: message,
|
||||
actions: {
|
||||
primary: { text: gettext('OK'), click: function(view) { view.hide(); onError(); } }
|
||||
}
|
||||
});
|
||||
prompt.show();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$createUserForm.bind('submit', function(event) {
|
||||
event.preventDefault();
|
||||
var email = $('#user-email-input').val().trim();
|
||||
var msg;
|
||||
|
||||
if(!email) {
|
||||
msg = new PromptView.Error({
|
||||
title: gettext('A valid email address is required'),
|
||||
message: gettext('You must enter a valid email address in order to add an instructor'),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Return and add email address'),
|
||||
click: function(view) { view.hide(); $('#user-email-input').focus(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
msg.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if(_.contains(allUserEmails, email)) {
|
||||
msg = new PromptView.Warning({
|
||||
title: gettext('Already a library team member'),
|
||||
message: _.template(
|
||||
gettext("{email} is already on the {course} team. Recheck the email address if you want to add a new member."), {
|
||||
email: email,
|
||||
course: libraryName
|
||||
}, {interpolate: /\{(.+?)\}/g}
|
||||
),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Return to team listing'),
|
||||
click: function(view) { view.hide(); $('#user-email-input').focus(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
msg.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the REST API to create the user, giving them a role of "library_user" for now:
|
||||
changeRole(
|
||||
$('#user-email-input').val().trim(),
|
||||
'library_user',
|
||||
{
|
||||
errMessage: gettext('Error adding user'),
|
||||
onError: function() { $('#user-email-input').focus(); }
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$cancelButton = $createUserForm.find('.action-cancel');
|
||||
$cancelButton.on('click', function(event) {
|
||||
event.preventDefault();
|
||||
$('.create-user-button').toggleClass('is-disabled');
|
||||
$createUserFormWrapper.toggleClass('is-shown');
|
||||
$('#user-email-input').val('');
|
||||
});
|
||||
|
||||
$('.create-user-button').on('click', function(event) {
|
||||
event.preventDefault();
|
||||
$('.create-user-button').toggleClass('is-disabled');
|
||||
$createUserFormWrapper.toggleClass('is-shown');
|
||||
$createUserForm.find('#user-email-input').focus();
|
||||
});
|
||||
|
||||
$('body').on('keyup', function(event) {
|
||||
if(event.which == jQuery.ui.keyCode.ESCAPE && $createUserFormWrapper.is('.is-shown')) {
|
||||
$cancelButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
$('.remove-user').click(function() {
|
||||
var email = $(this).closest('li[data-email]').data('email'),
|
||||
msg = new PromptView.Warning({
|
||||
title: gettext('Are you sure?'),
|
||||
message: _.template(gettext('Are you sure you want to delete {email} from the library “{library}”?'), {email: email, library: libraryName}, {interpolate: /\{(.+?)\}/g}),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Delete'),
|
||||
click: function(view) {
|
||||
// User the REST API to delete the user:
|
||||
changeRole(email, null, { errMessage: gettext('Error removing user') });
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext('Cancel'),
|
||||
click: function(view) { view.hide(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
msg.show();
|
||||
});
|
||||
|
||||
$('.user-actions .make-instructor').click(function(event) {
|
||||
event.preventDefault();
|
||||
var email = $(this).closest('li[data-email]').data('email');
|
||||
changeRole(email, 'instructor', {});
|
||||
});
|
||||
|
||||
$('.user-actions .make-staff').click(function(event) {
|
||||
event.preventDefault();
|
||||
var email = $(this).closest('li[data-email]').data('email');
|
||||
changeRole(email, 'staff', {});
|
||||
});
|
||||
|
||||
$('.user-actions .make-user').click(function(event) {
|
||||
event.preventDefault();
|
||||
var email = $(this).closest('li[data-email]').data('email');
|
||||
changeRole(email, 'library_user', {});
|
||||
});
|
||||
|
||||
};
|
||||
});
|
||||
@@ -1,16 +1,17 @@
|
||||
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
|
||||
"js/views/utils/view_utils"],
|
||||
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) {
|
||||
var CreateCourseUtils = CreateCourseUtilsFactory({
|
||||
"js/views/utils/create_library_utils", "js/views/utils/view_utils"],
|
||||
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) {
|
||||
"use strict";
|
||||
var CreateCourseUtils = new CreateCourseUtilsFactory({
|
||||
name: '.new-course-name',
|
||||
org: '.new-course-org',
|
||||
number: '.new-course-number',
|
||||
run: '.new-course-run',
|
||||
save: '.new-course-save',
|
||||
errorWrapper: '.wrap-error',
|
||||
errorWrapper: '.create-course .wrap-error',
|
||||
errorMessage: '#course_creation_error',
|
||||
tipError: 'span.tip-error',
|
||||
error: '.error',
|
||||
tipError: '.create-course span.tip-error',
|
||||
error: '.create-course .error',
|
||||
allowUnicode: '.allow-unicode-course-id'
|
||||
}, {
|
||||
shown: 'is-shown',
|
||||
@@ -20,6 +21,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
|
||||
error: 'error'
|
||||
});
|
||||
|
||||
var CreateLibraryUtils = new CreateLibraryUtilsFactory({
|
||||
name: '.new-library-name',
|
||||
org: '.new-library-org',
|
||||
number: '.new-library-number',
|
||||
save: '.new-library-save',
|
||||
errorWrapper: '.create-library .wrap-error',
|
||||
errorMessage: '#library_creation_error',
|
||||
tipError: '.create-library span.tip-error',
|
||||
error: '.create-library .error',
|
||||
allowUnicode: '.allow-unicode-library-id'
|
||||
}, {
|
||||
shown: 'is-shown',
|
||||
showing: 'is-showing',
|
||||
hiding: 'is-hiding',
|
||||
disabled: 'is-disabled',
|
||||
error: 'error'
|
||||
});
|
||||
|
||||
var saveNewCourse = function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -33,7 +52,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
|
||||
var number = $newCourseForm.find('.new-course-number').val();
|
||||
var run = $newCourseForm.find('.new-course-run').val();
|
||||
|
||||
course_info = {
|
||||
var course_info = {
|
||||
org: org,
|
||||
number: number,
|
||||
display_name: display_name,
|
||||
@@ -41,27 +60,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
|
||||
};
|
||||
|
||||
analytics.track('Created a Course', course_info);
|
||||
CreateCourseUtils.createCourse(course_info, function (errorMessage) {
|
||||
$('.wrap-error').addClass('is-shown');
|
||||
CreateCourseUtils.create(course_info, function (errorMessage) {
|
||||
$('.create-course .wrap-error').addClass('is-shown');
|
||||
$('#course_creation_error').html('<p>' + errorMessage + '</p>');
|
||||
$('.new-course-save').addClass('is-disabled').attr('aria-disabled', true);
|
||||
});
|
||||
};
|
||||
|
||||
var cancelNewCourse = function (e) {
|
||||
e.preventDefault();
|
||||
$('.new-course-button').removeClass('is-disabled').attr('aria-disabled', false);
|
||||
$('.wrapper-create-course').removeClass('is-shown');
|
||||
// Clear out existing fields and errors
|
||||
_.each(
|
||||
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
|
||||
function (field) {
|
||||
$(field).val('');
|
||||
}
|
||||
);
|
||||
$('#course_creation_error').html('');
|
||||
$('.wrap-error').removeClass('is-shown');
|
||||
$('.new-course-save').off('click');
|
||||
var makeCancelHandler = function (addType) {
|
||||
return function(e) {
|
||||
e.preventDefault();
|
||||
$('.new-'+addType+'-button').removeClass('is-disabled').attr('aria-disabled', false);
|
||||
$('.wrapper-create-'+addType).removeClass('is-shown');
|
||||
// Clear out existing fields and errors
|
||||
$('#create-'+addType+'-form input[type=text]').val('');
|
||||
$('#'+addType+'_creation_error').html('');
|
||||
$('.create-'+addType+' .wrap-error').removeClass('is-shown');
|
||||
$('.new-'+addType+'-save').off('click');
|
||||
};
|
||||
};
|
||||
|
||||
var addNewCourse = function (e) {
|
||||
@@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
|
||||
var $courseName = $('.new-course-name');
|
||||
$courseName.focus().select();
|
||||
$('.new-course-save').on('click', saveNewCourse);
|
||||
$cancelButton.bind('click', cancelNewCourse);
|
||||
$cancelButton.bind('click', makeCancelHandler('course'));
|
||||
CancelOnEscape($cancelButton);
|
||||
|
||||
CreateCourseUtils.configureHandlers();
|
||||
};
|
||||
|
||||
var saveNewLibrary = function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (CreateLibraryUtils.hasInvalidRequiredFields()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $newLibraryForm = $(this).closest('#create-library-form');
|
||||
var display_name = $newLibraryForm.find('.new-library-name').val();
|
||||
var org = $newLibraryForm.find('.new-library-org').val();
|
||||
var number = $newLibraryForm.find('.new-library-number').val();
|
||||
|
||||
var lib_info = {
|
||||
org: org,
|
||||
number: number,
|
||||
display_name: display_name,
|
||||
};
|
||||
|
||||
analytics.track('Created a Library', lib_info);
|
||||
CreateLibraryUtils.create(lib_info, function (errorMessage) {
|
||||
$('.create-library .wrap-error').addClass('is-shown');
|
||||
$('#library_creation_error').html('<p>' + errorMessage + '</p>');
|
||||
$('.new-library-save').addClass('is-disabled').attr('aria-disabled', true);
|
||||
});
|
||||
};
|
||||
|
||||
var addNewLibrary = function (e) {
|
||||
e.preventDefault();
|
||||
$('.new-library-button').addClass('is-disabled').attr('aria-disabled', true);
|
||||
$('.new-library-save').addClass('is-disabled').attr('aria-disabled', true);
|
||||
var $newLibrary = $('.wrapper-create-library').addClass('is-shown');
|
||||
var $cancelButton = $newLibrary.find('.new-library-cancel');
|
||||
var $libraryName = $('.new-library-name');
|
||||
$libraryName.focus().select();
|
||||
$('.new-library-save').on('click', saveNewLibrary);
|
||||
$cancelButton.bind('click', makeCancelHandler('library'));
|
||||
CancelOnEscape($cancelButton);
|
||||
|
||||
CreateLibraryUtils.configureHandlers();
|
||||
};
|
||||
|
||||
var showTab = function(tab) {
|
||||
return function(e) {
|
||||
e.preventDefault();
|
||||
$('.courses-tab').toggleClass('active', tab === 'courses');
|
||||
$('.libraries-tab').toggleClass('active', tab === 'libraries');
|
||||
};
|
||||
};
|
||||
|
||||
var onReady = function () {
|
||||
$('.new-course-button').bind('click', addNewCourse);
|
||||
$('.new-library-button').bind('click', addNewLibrary);
|
||||
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
|
||||
ViewUtils.reload();
|
||||
}));
|
||||
$('.action-reload').bind('click', ViewUtils.reload);
|
||||
$('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
|
||||
$('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
|
||||
};
|
||||
|
||||
domReady(onReady);
|
||||
|
||||
@@ -49,7 +49,7 @@ define(["jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_helpe
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
modal = showModal(requests, mockXBlockEditorHtml);
|
||||
expect(modal.$('.action-save')).not.toBeVisible();
|
||||
expect(modal.$('.action-cancel').text()).toBe('OK');
|
||||
expect(modal.$('.action-cancel').text()).toBe('Close');
|
||||
});
|
||||
|
||||
it('shows the correct title', function() {
|
||||
|
||||
489
cms/static/js/spec/views/paged_container_spec.js
Normal file
489
cms/static/js/spec/views/paged_container_spec.js
Normal file
@@ -0,0 +1,489 @@
|
||||
define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info",
|
||||
"js/views/paged_container", "js/views/paging_header", "js/views/paging_footer"],
|
||||
function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter) {
|
||||
|
||||
var htmlResponseTpl = _.template('' +
|
||||
'<div class="xblock-container-paging-parameters" data-start="<%= start %>" data-displayed="<%= displayed %>" data-total="<%= total %>"/>'
|
||||
);
|
||||
|
||||
function getResponseHtml(options){
|
||||
return '<div class="xblock" data-request-token="request_token">' +
|
||||
'<div class="container-paging-header"></div>' +
|
||||
htmlResponseTpl(options) +
|
||||
'<div class="container-paging-footer"></div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
var PAGE_SIZE = 3;
|
||||
|
||||
var mockFirstPage = {
|
||||
resources: [],
|
||||
html: getResponseHtml({
|
||||
start: 0,
|
||||
displayed: PAGE_SIZE,
|
||||
total: PAGE_SIZE + 1
|
||||
})
|
||||
};
|
||||
|
||||
var mockSecondPage = {
|
||||
resources: [],
|
||||
html: getResponseHtml({
|
||||
start: PAGE_SIZE,
|
||||
displayed: 1,
|
||||
total: PAGE_SIZE + 1
|
||||
})
|
||||
};
|
||||
|
||||
var mockEmptyPage = {
|
||||
resources: [],
|
||||
html: getResponseHtml({
|
||||
start: 0,
|
||||
displayed: 0,
|
||||
total: 0
|
||||
})
|
||||
};
|
||||
|
||||
var respondWithMockPage = function(requests) {
|
||||
var requestIndex = requests.length - 1;
|
||||
var request = requests[requestIndex];
|
||||
var url = new URI(request.url);
|
||||
var queryParameters = url.query(true); // Returns an object with each query parameter stored as a value
|
||||
var page = queryParameters.page_number;
|
||||
var response = page === "0" ? mockFirstPage : mockSecondPage;
|
||||
AjaxHelpers.respondWithJson(requests, response, requestIndex);
|
||||
};
|
||||
|
||||
var MockPagingView = PagedContainer.extend({
|
||||
view: 'container_preview',
|
||||
el: $("<div><div class='xblock' data-request-token='test_request_token'/></div>"),
|
||||
model: new XBlockInfo({}, {parse: true})
|
||||
});
|
||||
|
||||
describe("Paging Container", function() {
|
||||
var pagingContainer;
|
||||
|
||||
beforeEach(function () {
|
||||
var feedbackTpl = readFixtures('system-feedback.underscore');
|
||||
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTpl));
|
||||
pagingContainer = new MockPagingView({ page_size: PAGE_SIZE });
|
||||
});
|
||||
|
||||
describe("Container", function () {
|
||||
describe("setPage", function () {
|
||||
it('can set the current page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should not change page after a server error', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.setPage(1);
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPage", function () {
|
||||
it('does not move forward after a server error', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.nextPage();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('can move to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.nextPage();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('can not move forward from the final page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.nextPage();
|
||||
expect(requests.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousPage", function () {
|
||||
it('can move back a page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.previousPage();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('can not move back from the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.previousPage();
|
||||
expect(requests.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not move back after a server error', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.previousPage();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PagingHeader", function () {
|
||||
beforeEach(function () {
|
||||
var pagingFooterTpl = readFixtures('paging-header.underscore');
|
||||
appendSetFixtures($("<script>", { id: "paging-header-tpl", type: "text/template" }).text(pagingFooterTpl));
|
||||
});
|
||||
|
||||
describe("Next page button", function () {
|
||||
beforeEach(function () {
|
||||
pagingContainer.render();
|
||||
});
|
||||
|
||||
it('does not move forward if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingHeader.$('.next-page-link').click();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('can move to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingHeader.$('.next-page-link').click();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should be enabled when there is at least one more page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.next-page-link')).not.toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled on the final page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled on an empty page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Previous page button", function () {
|
||||
beforeEach(function () {
|
||||
pagingContainer.render();
|
||||
});
|
||||
|
||||
it('does not move back if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingHeader.$('.previous-page-link').click();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('can go back a page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingHeader.$('.previous-page-link').click();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('should be disabled on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be enabled on the second page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.previous-page-link')).not.toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled for an empty page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingHeader.$('.previous-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page metadata section", function() {
|
||||
it('shows the correct metadata for the current page', function () {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
message;
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
message = pagingContainer.pagingHeader.$('.meta').html().trim();
|
||||
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
|
||||
' out of <span class="count-total">4 total</span>, ' +
|
||||
'sorted by <span class="sort-order">Date added</span> descending</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Children count label", function () {
|
||||
it('should show correct count on first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.count-current-shown')).toHaveHtml('1-3');
|
||||
});
|
||||
|
||||
it('should show correct count on second page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.count-current-shown')).toHaveHtml('4-4');
|
||||
});
|
||||
|
||||
it('should show correct count for an empty collection', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingHeader.$('.count-current-shown')).toHaveHtml('0-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Children total label", function () {
|
||||
it('should show correct total on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.count-total')).toHaveText('4 total');
|
||||
});
|
||||
|
||||
it('should show correct total on the second page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingHeader.$('.count-total')).toHaveText('4 total');
|
||||
});
|
||||
|
||||
it('should show zero total for an empty collection', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingHeader.$('.count-total')).toHaveText('0 total');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PagingFooter", function () {
|
||||
var pagingFooter;
|
||||
|
||||
beforeEach(function () {
|
||||
var pagingFooterTpl = readFixtures('paging-footer.underscore');
|
||||
appendSetFixtures($("<script>", { id: "paging-footer-tpl", type: "text/template" }).text(pagingFooterTpl));
|
||||
});
|
||||
|
||||
describe("Next page button", function () {
|
||||
beforeEach(function () {
|
||||
// Render the page and header so that they can react to events
|
||||
pagingContainer.render();
|
||||
});
|
||||
|
||||
it('does not move forward if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.next-page-link').click();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('can move to the next page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.next-page-link').click();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should be enabled when there is at least one more page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.next-page-link')).not.toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled on the final page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled on an empty page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingFooter.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Previous page button", function () {
|
||||
beforeEach(function () {
|
||||
// Render the page and header so that they can react to events
|
||||
pagingContainer.render();
|
||||
});
|
||||
|
||||
it('does not move back if a server error occurs', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.previous-page-link').click();
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('can go back a page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.previous-page-link').click();
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
});
|
||||
|
||||
it('should be disabled on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be enabled on the second page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.previous-page-link')).not.toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('should be disabled for an empty page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingFooter.$('.previous-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Current page label", function () {
|
||||
it('should show 1 on the first page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.current-page')).toHaveText('1');
|
||||
});
|
||||
|
||||
it('should show 2 on the second page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(1);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.current-page')).toHaveText('2');
|
||||
});
|
||||
|
||||
it('should show 1 for an empty collection', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingFooter.$('.current-page')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page total label", function () {
|
||||
it('should show the correct value with more than one page', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.total-pages')).toHaveText('2');
|
||||
});
|
||||
|
||||
it('should show page 1 when there are no assets', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
AjaxHelpers.respondWithJson(requests, mockEmptyPage);
|
||||
expect(pagingContainer.pagingFooter.$('.total-pages')).toHaveText('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page input field", function () {
|
||||
var input;
|
||||
|
||||
it('should initially have a blank page input', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
expect(pagingContainer.pagingFooter.$('.page-number-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should handle invalid page requests', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.page-number-input').val('abc');
|
||||
pagingContainer.pagingFooter.$('.page-number-input').trigger('change');
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
expect(pagingContainer.pagingFooter.$('.page-number-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should switch pages via the input field', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.page-number-input').val('2');
|
||||
pagingContainer.pagingFooter.$('.page-number-input').trigger('change');
|
||||
AjaxHelpers.respondWithJson(requests, mockSecondPage);
|
||||
expect(pagingContainer.collection.currentPage).toBe(1);
|
||||
expect(pagingContainer.pagingFooter.$('.page-number-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should handle AJAX failures when switching pages via the input field', function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
pagingContainer.setPage(0);
|
||||
respondWithMockPage(requests);
|
||||
pagingContainer.pagingFooter.$('.page-number-input').val('2');
|
||||
pagingContainer.pagingFooter.$('.page-number-input').trigger('change');
|
||||
requests[1].respond(500);
|
||||
expect(pagingContainer.collection.currentPage).toBe(0);
|
||||
expect(pagingContainer.pagingFooter.$('.page-number-input')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,539 +1,590 @@
|
||||
define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_helpers",
|
||||
"js/common_helpers/template_helpers", "js/spec_helpers/edit_helpers",
|
||||
"js/views/pages/container", "js/models/xblock_info", "jquery.simulate"],
|
||||
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, XBlockInfo) {
|
||||
"js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info"],
|
||||
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
|
||||
|
||||
describe("ContainerPage", function() {
|
||||
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
|
||||
model, containerPage, requests, initialDisplayName,
|
||||
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
|
||||
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'),
|
||||
mockBadContainerXBlockHtml = readFixtures('mock/mock-bad-javascript-container-xblock.underscore'),
|
||||
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
|
||||
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
|
||||
|
||||
beforeEach(function () {
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
EditHelpers.installEditTemplates();
|
||||
TemplateHelpers.installTemplate('xblock-string-field-editor');
|
||||
TemplateHelpers.installTemplate('container-message');
|
||||
appendSetFixtures(mockContainerPage);
|
||||
|
||||
EditHelpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
|
||||
initialDisplayName = 'Test Container';
|
||||
|
||||
model = new XBlockInfo({
|
||||
id: 'locator-container',
|
||||
display_name: initialDisplayName,
|
||||
category: 'vertical'
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
EditHelpers.uninstallMockXBlock();
|
||||
});
|
||||
|
||||
lastRequest = function() { return requests[requests.length - 1]; };
|
||||
|
||||
respondWithHtml = function(html) {
|
||||
var requestIndex = requests.length - 1;
|
||||
AjaxHelpers.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
renderContainerPage = function(test, html, options) {
|
||||
requests = AjaxHelpers.requests(test);
|
||||
containerPage = new ContainerPage(_.extend(options || {}, {
|
||||
model: model,
|
||||
templates: EditHelpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
}));
|
||||
containerPage.render();
|
||||
respondWithHtml(html);
|
||||
};
|
||||
|
||||
expectComponents = function (container, locators) {
|
||||
// verify expected components (in expected order) by their locators
|
||||
var components = $(container).find('.studio-xblock-wrapper');
|
||||
expect(components.length).toBe(locators.length);
|
||||
_.each(locators, function(locator, locator_index) {
|
||||
expect($(components[locator_index]).data('locator')).toBe(locator);
|
||||
});
|
||||
};
|
||||
|
||||
describe("Initial display", function() {
|
||||
it('can render itself', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('shows a loading indicator', function() {
|
||||
requests = AjaxHelpers.requests(this);
|
||||
containerPage.render();
|
||||
expect(containerPage.$('.ui-loading')).not.toHaveClass('is-hidden');
|
||||
respondWithHtml(mockContainerXBlockHtml);
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('can show an xblock with broken JavaScript', function() {
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('can show an xblock with an invalid XBlock', function() {
|
||||
renderContainerPage(this, mockBadXBlockContainerXBlockHtml);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('inline edits the display name when performing a new action', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
action: 'new'
|
||||
});
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.xblock-field-input')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing the container", function() {
|
||||
var updatedDisplayName = 'Updated Test Container',
|
||||
getDisplayNameWrapper;
|
||||
|
||||
afterEach(function() {
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
getDisplayNameWrapper = function() {
|
||||
return containerPage.$('.wrapper-xblock-field');
|
||||
};
|
||||
|
||||
it('can edit itself', function() {
|
||||
var editButtons, displayNameElement;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
|
||||
// Click the root edit button
|
||||
editButtons = containerPage.$('.nav-actions .edit-button');
|
||||
editButtons.first().click();
|
||||
|
||||
// Expect a request to be made to show the studio view for the container
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/studio_view')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockContainerXBlockHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
|
||||
// Expect the correct title to be shown
|
||||
expect(EditHelpers.getModalTitle()).toBe('Editing: Test Container');
|
||||
|
||||
// Press the save button and respond with a success message to the save
|
||||
EditHelpers.pressModalButton('.action-save');
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
expect(EditHelpers.isShowingModal()).toBeFalsy();
|
||||
|
||||
// Expect the last request be to refresh the container page
|
||||
expect(str.startsWith(lastRequest().url,
|
||||
'/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockUpdatedContainerXBlockHtml,
|
||||
resources: []
|
||||
});
|
||||
|
||||
// Respond to the subsequent xblock info fetch request.
|
||||
AjaxHelpers.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
|
||||
// Expect the title to have been updated
|
||||
expect(displayNameElement.text().trim()).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('can inline edit the display name', function() {
|
||||
var displayNameInput, displayNameWrapper;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
displayNameWrapper = getDisplayNameWrapper();
|
||||
displayNameInput = EditHelpers.inlineEdit(displayNameWrapper, updatedDisplayName);
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation.
|
||||
AjaxHelpers.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
EditHelpers.verifyInlineEditChange(displayNameWrapper, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xblock", function() {
|
||||
afterEach(function() {
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
it('can show an edit modal for a child xblock', function() {
|
||||
var editButtons;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
editButtons[0].click();
|
||||
// Make sure that the correct xblock is requested to be edited
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/studio_view')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can show an edit modal for a child xblock with broken JavaScript', function() {
|
||||
var editButtons;
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
editButtons[0].click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xmodule", function() {
|
||||
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
|
||||
newDisplayName = 'New Display Name';
|
||||
function parameterized_suite(label, global_page_options, fixtures) {
|
||||
describe(label + " ContainerPage", function () {
|
||||
var lastRequest, getContainerPage, renderContainerPage, expectComponents, respondWithHtml,
|
||||
model, containerPage, requests, initialDisplayName,
|
||||
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
|
||||
mockContainerXBlockHtml = readFixtures(fixtures.initial),
|
||||
mockXBlockHtml = readFixtures(fixtures.add_response),
|
||||
mockBadContainerXBlockHtml = readFixtures('mock/mock-bad-javascript-container-xblock.underscore'),
|
||||
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
|
||||
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'),
|
||||
PageClass = fixtures.page;
|
||||
|
||||
beforeEach(function () {
|
||||
EditHelpers.installMockXModule({
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
EditHelpers.installEditTemplates();
|
||||
TemplateHelpers.installTemplate('xblock-string-field-editor');
|
||||
TemplateHelpers.installTemplate('container-message');
|
||||
appendSetFixtures(mockContainerPage);
|
||||
|
||||
EditHelpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
EditHelpers.uninstallMockXModule();
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
initialDisplayName = 'Test Container';
|
||||
|
||||
it('can save changes to settings', function() {
|
||||
var editButtons, modal, mockUpdatedXBlockHtml;
|
||||
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
editButtons[0].click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXModuleEditor,
|
||||
resources: []
|
||||
model = new XBlockInfo({
|
||||
id: 'locator-container',
|
||||
display_name: initialDisplayName,
|
||||
category: 'vertical'
|
||||
});
|
||||
|
||||
modal = $('.edit-xblock-modal');
|
||||
expect(modal.length).toBe(1);
|
||||
// Click on the settings tab
|
||||
modal.find('.settings-button').click();
|
||||
// Change the display name's text
|
||||
modal.find('.setting-input').text("Mock Update");
|
||||
// Press the save button
|
||||
modal.find('.action-save').click();
|
||||
// Respond to the save
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
id: model.id
|
||||
});
|
||||
|
||||
// Respond to the request to refresh
|
||||
respondWithHtml(mockUpdatedXBlockHtml);
|
||||
|
||||
// Verify that the xblock was updated
|
||||
expect(containerPage.$('.mock-updated-content').text()).toBe('Mock Update');
|
||||
});
|
||||
});
|
||||
|
||||
describe("xblock operations", function() {
|
||||
var getGroupElement,
|
||||
NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
allComponentsInGroup = _.map(
|
||||
_.range(NUM_COMPONENTS_PER_GROUP),
|
||||
function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); }
|
||||
);
|
||||
afterEach(function () {
|
||||
EditHelpers.uninstallMockXBlock();
|
||||
});
|
||||
|
||||
getGroupElement = function() {
|
||||
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
|
||||
lastRequest = function () {
|
||||
return requests[requests.length - 1];
|
||||
};
|
||||
|
||||
describe("Deleting an xblock", function() {
|
||||
var clickDelete, deleteComponent, deleteComponentWithSuccess,
|
||||
promptSpy;
|
||||
respondWithHtml = function (html) {
|
||||
var requestIndex = requests.length - 1;
|
||||
AjaxHelpers.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
promptSpy = EditHelpers.createPromptSpy();
|
||||
});
|
||||
|
||||
clickDelete = function(componentIndex, clickNo) {
|
||||
|
||||
// find all delete buttons for the given group
|
||||
var deleteButtons = getGroupElement().find(".delete-button");
|
||||
expect(deleteButtons.length).toBe(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// click the requested delete button
|
||||
deleteButtons[componentIndex].click();
|
||||
|
||||
// click the 'yes' or 'no' button in the prompt
|
||||
EditHelpers.confirmPrompt(promptSpy, clickNo);
|
||||
getContainerPage = function (options) {
|
||||
var default_options = {
|
||||
model: model,
|
||||
templates: EditHelpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
};
|
||||
return new PageClass(_.extend(options || {}, global_page_options, default_options));
|
||||
};
|
||||
|
||||
deleteComponent = function(componentIndex) {
|
||||
clickDelete(componentIndex);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
renderContainerPage = function (test, html, options) {
|
||||
requests = AjaxHelpers.requests(test);
|
||||
containerPage = getContainerPage(options);
|
||||
containerPage.render();
|
||||
respondWithHtml(html);
|
||||
};
|
||||
|
||||
// second to last request contains given component's id (to delete the component)
|
||||
AjaxHelpers.expectJsonRequest(requests, 'DELETE',
|
||||
'/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
|
||||
null, requests.length - 2);
|
||||
expectComponents = function (container, locators) {
|
||||
// verify expected components (in expected order) by their locators
|
||||
var components = $(container).find('.studio-xblock-wrapper');
|
||||
expect(components.length).toBe(locators.length);
|
||||
_.each(locators, function (locator, locator_index) {
|
||||
expect($(components[locator_index]).data('locator')).toBe(locator);
|
||||
});
|
||||
};
|
||||
|
||||
// final request to refresh the xblock info
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
};
|
||||
|
||||
deleteComponentWithSuccess = function(componentIndex) {
|
||||
deleteComponent(componentIndex);
|
||||
|
||||
// verify the new list of components within the group
|
||||
expectComponents(
|
||||
getGroupElement(),
|
||||
_.without(allComponentsInGroup, allComponentsInGroup[componentIndex])
|
||||
);
|
||||
};
|
||||
|
||||
it("can delete the first xblock", function() {
|
||||
describe("Initial display", function () {
|
||||
it('can render itself', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(0);
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it("can delete a middle xblock", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(1);
|
||||
it('shows a loading indicator', function () {
|
||||
requests = AjaxHelpers.requests(this);
|
||||
containerPage = getContainerPage();
|
||||
containerPage.render();
|
||||
expect(containerPage.$('.ui-loading')).not.toHaveClass('is-hidden');
|
||||
respondWithHtml(mockContainerXBlockHtml);
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it("can delete the last xblock", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it("can delete an xblock with broken JavaScript", function() {
|
||||
it('can show an xblock with broken JavaScript', function () {
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
containerPage.$('.delete-button').first().click();
|
||||
EditHelpers.confirmPrompt(promptSpy);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
// expect the second to last request to be a delete of the xblock
|
||||
AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript',
|
||||
null, requests.length - 2);
|
||||
// expect the last request to be a fetch of the xblock info for the parent container
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('does not delete when clicking No in prompt', function () {
|
||||
var numRequests;
|
||||
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
numRequests = requests.length;
|
||||
|
||||
// click delete on the first component but press no
|
||||
clickDelete(0, true);
|
||||
|
||||
// all components should still exist
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
|
||||
// no requests should have been sent to the server
|
||||
expect(requests.length).toBe(numRequests);
|
||||
it('can show an xblock with an invalid XBlock', function () {
|
||||
renderContainerPage(this, mockBadXBlockContainerXBlockHtml);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('shows a notification during the delete operation', function() {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not delete an xblock upon failure', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
it('inline edits the display name when performing a new action', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
action: 'new'
|
||||
});
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.xblock-field-input')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duplicating an xblock", function() {
|
||||
var clickDuplicate, duplicateComponentWithSuccess,
|
||||
refreshXBlockSpies;
|
||||
describe("Editing the container", function () {
|
||||
var updatedDisplayName = 'Updated Test Container',
|
||||
getDisplayNameWrapper;
|
||||
|
||||
clickDuplicate = function(componentIndex) {
|
||||
afterEach(function () {
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
// find all duplicate buttons for the given group
|
||||
var duplicateButtons = getGroupElement().find(".duplicate-button");
|
||||
expect(duplicateButtons.length).toBe(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// click the requested duplicate button
|
||||
duplicateButtons[componentIndex].click();
|
||||
getDisplayNameWrapper = function () {
|
||||
return containerPage.$('.wrapper-xblock-field');
|
||||
};
|
||||
|
||||
duplicateComponentWithSuccess = function(componentIndex) {
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
it('can edit itself', function () {
|
||||
var editButtons, displayNameElement;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
|
||||
clickDuplicate(componentIndex);
|
||||
// Click the root edit button
|
||||
editButtons = containerPage.$('.nav-actions .edit-button');
|
||||
editButtons.first().click();
|
||||
|
||||
// verify content of request
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'duplicate_source_locator': 'locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
|
||||
'parent_locator': 'locator-group-' + GROUP_TO_TEST
|
||||
});
|
||||
|
||||
// send the response
|
||||
// Expect a request to be made to show the studio view for the container
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/studio_view')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
'locator': 'locator-duplicated-component'
|
||||
html: mockContainerXBlockHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
|
||||
// Expect the correct title to be shown
|
||||
expect(EditHelpers.getModalTitle()).toBe('Editing: Test Container');
|
||||
|
||||
// Press the save button and respond with a success message to the save
|
||||
EditHelpers.pressModalButton('.action-save');
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
expect(EditHelpers.isShowingModal()).toBeFalsy();
|
||||
|
||||
// Expect the last request be to refresh the container page
|
||||
expect(str.startsWith(lastRequest().url,
|
||||
'/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockUpdatedContainerXBlockHtml,
|
||||
resources: []
|
||||
});
|
||||
|
||||
// expect parent container to be refreshed
|
||||
expect(refreshXBlockSpies).toHaveBeenCalled();
|
||||
};
|
||||
// Respond to the subsequent xblock info fetch request.
|
||||
AjaxHelpers.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
|
||||
it("can duplicate the first xblock", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(0);
|
||||
// Expect the title to have been updated
|
||||
expect(displayNameElement.text().trim()).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it("can duplicate a middle xblock", function() {
|
||||
it('can inline edit the display name', function () {
|
||||
var displayNameInput, displayNameWrapper;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("can duplicate the last xblock", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it("can duplicate an xblock with broken JavaScript", function() {
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
containerPage.$('.duplicate-button').first().click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'duplicate_source_locator': 'locator-broken-javascript',
|
||||
'parent_locator': 'locator-container'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification when duplicating', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDuplicate(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
AjaxHelpers.respondWithJson(requests, {"locator": "new_item"});
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not duplicate an xblock upon failure', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
clickDuplicate(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
expect(refreshXBlockSpies).not.toHaveBeenCalled();
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
displayNameWrapper = getDisplayNameWrapper();
|
||||
displayNameInput = EditHelpers.inlineEdit(displayNameWrapper, updatedDisplayName);
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation.
|
||||
AjaxHelpers.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
EditHelpers.verifyInlineEditChange(displayNameWrapper, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent;
|
||||
describe("Editing an xblock", function () {
|
||||
afterEach(function () {
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
clickNewComponent = function (index) {
|
||||
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
it('can show an edit modal for a child xblock', function () {
|
||||
var editButtons;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
EditHelpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "locator-group-A"
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
editButtons[0].click();
|
||||
// Make sure that the correct xblock is requested to be edited
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/studio_view')).toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can show an edit modal for a child xblock with broken JavaScript', function () {
|
||||
var editButtons;
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
editButtons[0].click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xmodule", function () {
|
||||
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
|
||||
newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
EditHelpers.installMockXModule({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
afterEach(function () {
|
||||
EditHelpers.uninstallMockXModule();
|
||||
EditHelpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
it('does not insert component upon failure', function () {
|
||||
var requestCount;
|
||||
it('can save changes to settings', function () {
|
||||
var editButtons, modal, mockUpdatedXBlockHtml;
|
||||
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
requestCount = requests.length;
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
// No new requests should be made to refresh the view
|
||||
expect(requests.length).toBe(requestCount);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
editButtons[0].click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXModuleEditor,
|
||||
resources: []
|
||||
});
|
||||
|
||||
modal = $('.edit-xblock-modal');
|
||||
expect(modal.length).toBe(1);
|
||||
// Click on the settings tab
|
||||
modal.find('.settings-button').click();
|
||||
// Change the display name's text
|
||||
modal.find('.setting-input').text("Mock Update");
|
||||
// Press the save button
|
||||
modal.find('.action-save').click();
|
||||
// Respond to the save
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
id: model.id
|
||||
});
|
||||
|
||||
// Respond to the request to refresh
|
||||
respondWithHtml(mockUpdatedXBlockHtml);
|
||||
|
||||
// Verify that the xblock was updated
|
||||
expect(containerPage.$('.mock-updated-content').text()).toBe('Mock Update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template Picker', function() {
|
||||
var showTemplatePicker, verifyCreateHtmlComponent,
|
||||
mockXBlockHtml = readFixtures('mock/mock-xblock.underscore');
|
||||
describe("xblock operations", function () {
|
||||
var getGroupElement, paginated, getDeleteOffset,
|
||||
NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
allComponentsInGroup = _.map(
|
||||
_.range(NUM_COMPONENTS_PER_GROUP),
|
||||
function (index) {
|
||||
return 'locator-component-' + GROUP_TO_TEST + (index + 1);
|
||||
}
|
||||
);
|
||||
|
||||
showTemplatePicker = function() {
|
||||
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
|
||||
paginated = function () {
|
||||
return containerPage instanceof PagedContainerPage;
|
||||
};
|
||||
|
||||
getDeleteOffset = function () {
|
||||
// Paginated containers will make an additional AJAX request.
|
||||
return paginated() ? 3 : 2;
|
||||
};
|
||||
|
||||
getGroupElement = function () {
|
||||
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
|
||||
};
|
||||
|
||||
describe("Deleting an xblock", function () {
|
||||
var clickDelete, deleteComponent, deleteComponentWithSuccess,
|
||||
promptSpy;
|
||||
|
||||
beforeEach(function () {
|
||||
promptSpy = EditHelpers.createPromptSpy();
|
||||
});
|
||||
|
||||
|
||||
clickDelete = function (componentIndex, clickNo) {
|
||||
|
||||
// find all delete buttons for the given group
|
||||
var deleteButtons = getGroupElement().find(".delete-button");
|
||||
expect(deleteButtons.length).toBe(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// click the requested delete button
|
||||
deleteButtons[componentIndex].click();
|
||||
|
||||
// click the 'yes' or 'no' button in the prompt
|
||||
EditHelpers.confirmPrompt(promptSpy, clickNo);
|
||||
};
|
||||
|
||||
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
|
||||
var xblockCount;
|
||||
renderContainerPage(test, mockContainerXBlockHtml);
|
||||
showTemplatePicker();
|
||||
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
|
||||
containerPage.$('.new-component-html a')[templateIndex].click();
|
||||
EditHelpers.verifyXBlockRequest(requests, expectedRequest);
|
||||
deleteComponent = function (componentIndex, requestOffset) {
|
||||
clickDelete(componentIndex);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
AjaxHelpers.expectJsonRequest(requests, 'DELETE',
|
||||
'/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
|
||||
null, requests.length - requestOffset);
|
||||
|
||||
// final request to refresh the xblock info
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
};
|
||||
|
||||
deleteComponentWithSuccess = function (componentIndex) {
|
||||
var deleteOffset;
|
||||
|
||||
deleteOffset = getDeleteOffset();
|
||||
deleteComponent(componentIndex, deleteOffset);
|
||||
|
||||
// verify the new list of components within the group
|
||||
expectComponents(
|
||||
getGroupElement(),
|
||||
_.without(allComponentsInGroup, allComponentsInGroup[componentIndex])
|
||||
);
|
||||
};
|
||||
|
||||
it("can delete the first xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("can delete a middle xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("can delete the last xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it("can delete an xblock with broken JavaScript", function () {
|
||||
var deleteOffset = getDeleteOffset();
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
containerPage.$('.delete-button').first().click();
|
||||
EditHelpers.confirmPrompt(promptSpy);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
|
||||
// expect the second to last request to be a delete of the xblock
|
||||
AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript',
|
||||
null, requests.length - deleteOffset);
|
||||
// expect the last request to be a fetch of the xblock info for the parent container
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
});
|
||||
|
||||
it('does not delete when clicking No in prompt', function () {
|
||||
var numRequests;
|
||||
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
numRequests = requests.length;
|
||||
|
||||
// click delete on the first component but press no
|
||||
clickDelete(0, true);
|
||||
|
||||
// all components should still exist
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
|
||||
// no requests should have been sent to the server
|
||||
expect(requests.length).toBe(numRequests);
|
||||
});
|
||||
|
||||
it('shows a notification during the delete operation', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not delete an xblock upon failure', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Duplicating an xblock", function () {
|
||||
var clickDuplicate, duplicateComponentWithSuccess,
|
||||
refreshXBlockSpies;
|
||||
|
||||
clickDuplicate = function (componentIndex) {
|
||||
|
||||
// find all duplicate buttons for the given group
|
||||
var duplicateButtons = getGroupElement().find(".duplicate-button");
|
||||
expect(duplicateButtons.length).toBe(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// click the requested duplicate button
|
||||
duplicateButtons[componentIndex].click();
|
||||
};
|
||||
|
||||
duplicateComponentWithSuccess = function (componentIndex) {
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
|
||||
clickDuplicate(componentIndex);
|
||||
|
||||
// verify content of request
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'duplicate_source_locator': 'locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
|
||||
'parent_locator': 'locator-group-' + GROUP_TO_TEST
|
||||
});
|
||||
|
||||
// send the response
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
'locator': 'locator-duplicated-component'
|
||||
});
|
||||
|
||||
// expect parent container to be refreshed
|
||||
expect(refreshXBlockSpies).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
it("can duplicate the first xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("can duplicate a middle xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("can duplicate the last xblock", function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it("can duplicate an xblock with broken JavaScript", function () {
|
||||
renderContainerPage(this, mockBadContainerXBlockHtml);
|
||||
containerPage.$('.duplicate-button').first().click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'duplicate_source_locator': 'locator-broken-javascript',
|
||||
'parent_locator': 'locator-container'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification when duplicating', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDuplicate(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
AjaxHelpers.respondWithJson(requests, {"locator": "new_item"});
|
||||
respondWithHtml(mockXBlockHtml);
|
||||
expect(containerPage.$('.studio-xblock-wrapper').length).toBe(xblockCount + 1);
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not duplicate an xblock upon failure', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
clickDuplicate(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
expect(refreshXBlockSpies).not.toHaveBeenCalled();
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent;
|
||||
|
||||
clickNewComponent = function (index) {
|
||||
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
|
||||
};
|
||||
|
||||
it('can add an HTML component without a template', function() {
|
||||
verifyCreateHtmlComponent(this, 0, {
|
||||
"category": "html",
|
||||
it('Attaches a handler to new component button', function() {
|
||||
containerPage = getContainerPage();
|
||||
containerPage.render();
|
||||
// Stub jQuery.scrollTo module.
|
||||
$.scrollTo = jasmine.createSpy('jQuery.scrollTo');
|
||||
containerPage.$('.new-component-button').click();
|
||||
expect($.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
EditHelpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
|
||||
it('can add an HTML component with a template', function() {
|
||||
verifyCreateHtmlComponent(this, 1, {
|
||||
"category": "html",
|
||||
"boilerplate" : "announcement.yaml",
|
||||
"parent_locator": "locator-group-A"
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = EditHelpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
EditHelpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
AjaxHelpers.respondWithJson(requests, { });
|
||||
EditHelpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert component upon failure', function () {
|
||||
var requestCount;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
requestCount = requests.length;
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
// No new requests should be made to refresh the view
|
||||
expect(requests.length).toBe(requestCount);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
});
|
||||
|
||||
describe('Template Picker', function () {
|
||||
var showTemplatePicker, verifyCreateHtmlComponent;
|
||||
|
||||
showTemplatePicker = function () {
|
||||
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
|
||||
};
|
||||
|
||||
verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) {
|
||||
var xblockCount;
|
||||
renderContainerPage(test, mockContainerXBlockHtml);
|
||||
showTemplatePicker();
|
||||
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
|
||||
containerPage.$('.new-component-html a')[templateIndex].click();
|
||||
EditHelpers.verifyXBlockRequest(requests, expectedRequest);
|
||||
AjaxHelpers.respondWithJson(requests, {"locator": "new_item"});
|
||||
respondWithHtml(mockXBlockHtml);
|
||||
expect(containerPage.$('.studio-xblock-wrapper').length).toBe(xblockCount + 1);
|
||||
};
|
||||
|
||||
it('can add an HTML component without a template', function () {
|
||||
verifyCreateHtmlComponent(this, 0, {
|
||||
"category": "html",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
|
||||
it('can add an HTML component with a template', function () {
|
||||
verifyCreateHtmlComponent(this, 1, {
|
||||
"category": "html",
|
||||
"boilerplate": "announcement.yaml",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
parameterized_suite("Non paged",
|
||||
{ },
|
||||
{
|
||||
page: ContainerPage,
|
||||
initial: 'mock/mock-container-xblock.underscore',
|
||||
add_response: 'mock/mock-xblock.underscore'
|
||||
}
|
||||
);
|
||||
parameterized_suite("Paged",
|
||||
{ page_size: 42 },
|
||||
{
|
||||
page: PagedContainerPage,
|
||||
initial: 'mock/mock-container-paged-xblock.underscore',
|
||||
add_response: 'mock/mock-xblock-paged.underscore'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
},
|
||||
mockCreateCourseRerunHTML = readFixtures('mock/mock-create-course-rerun.underscore');
|
||||
|
||||
var CreateCourseUtils = CreateCourseUtilsFactory(selectors, classes);
|
||||
var CreateCourseUtils = new CreateCourseUtilsFactory(selectors, classes);
|
||||
|
||||
var fillInFields = function (org, number, run, name) {
|
||||
$(selectors.org).val(org);
|
||||
@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
|
||||
describe("Field validation", function () {
|
||||
it("returns a message for an empty string", function () {
|
||||
var message = CreateCourseUtils.validateRequiredField('');
|
||||
var message = ViewUtils.validateRequiredField('');
|
||||
expect(message).not.toBe('');
|
||||
});
|
||||
|
||||
it("does not return a message for a non empty string", function () {
|
||||
var message = CreateCourseUtils.validateRequiredField('edX');
|
||||
var message = ViewUtils.validateRequiredField('edX');
|
||||
expect(message).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
describe("Error messages", function () {
|
||||
var setErrorMessage = function(selector, message) {
|
||||
var element = $(selector).parent();
|
||||
CreateCourseUtils.setNewCourseFieldInErr(element, message);
|
||||
CreateCourseUtils.setFieldInErr(element, message);
|
||||
return element;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
"js/views/utils/view_utils"],
|
||||
function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) {
|
||||
describe("Course listing page", function () {
|
||||
var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'), fillInFields;
|
||||
var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore');
|
||||
|
||||
var fillInFields = function (org, number, run, name) {
|
||||
$('.new-course-org').val(org);
|
||||
@@ -11,6 +11,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
$('.new-course-name').val(name);
|
||||
};
|
||||
|
||||
var fillInLibraryFields = function(org, number, name) {
|
||||
$('.new-library-org').val(org).keyup();
|
||||
$('.new-library-number').val(number).keyup();
|
||||
$('.new-library-name').val(name).keyup();
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
ViewHelpers.installMockAnalytics();
|
||||
appendSetFixtures(mockIndexPageHTML);
|
||||
@@ -57,9 +63,86 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
ErrMsg: 'error message'
|
||||
});
|
||||
expect($('.wrap-error')).toHaveClass('is-shown');
|
||||
expect($('.create-course .wrap-error')).toHaveClass('is-shown');
|
||||
expect($('#course_creation_error')).toContainText('error message');
|
||||
expect($('.new-course-save')).toHaveClass('is-disabled');
|
||||
expect($('.new-course-save')).toHaveAttr('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it("saves new libraries", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var redirectSpy = spyOn(ViewUtils, 'redirect');
|
||||
$('.new-library-button').click();
|
||||
fillInLibraryFields('DemoX', 'DM101', 'Demo library');
|
||||
$('.new-library-save').click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/library/', {
|
||||
org: 'DemoX',
|
||||
number: 'DM101',
|
||||
display_name: 'Demo library'
|
||||
});
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
url: 'dummy_test_url'
|
||||
});
|
||||
expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url');
|
||||
});
|
||||
|
||||
it("displays an error when a required field is blank", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var requests_count = requests.length;
|
||||
$('.new-library-button').click();
|
||||
var values = ['DemoX', 'DM101', 'Demo library'];
|
||||
// Try making each of these three values empty one at a time and ensure the form won't submit:
|
||||
for (var i=0; i<values.length;i++) {
|
||||
var values_with_blank = values.slice();
|
||||
values_with_blank[i] = '';
|
||||
fillInLibraryFields.apply(this, values_with_blank);
|
||||
expect($('.create-library li.field.text input[value=]').parent()).toHaveClass('error');
|
||||
expect($('.new-library-save')).toHaveClass('is-disabled');
|
||||
expect($('.new-library-save')).toHaveAttr('aria-disabled', 'true');
|
||||
$('.new-library-save').click();
|
||||
expect(requests.length).toEqual(requests_count); // Expect no new requests
|
||||
}
|
||||
});
|
||||
|
||||
it("can cancel library creation", function () {
|
||||
$('.new-library-button').click();
|
||||
fillInLibraryFields('DemoX', 'DM101', 'Demo library');
|
||||
$('.new-library-cancel').click();
|
||||
expect($('.wrapper-create-library')).not.toHaveClass('is-shown');
|
||||
$('.wrapper-create-library form input[type=text]').each(function() {
|
||||
expect($(this)).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it("displays an error when saving a library fails", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
$('.new-library-button').click();
|
||||
fillInLibraryFields('DemoX', 'DM101', 'Demo library');
|
||||
$('.new-library-save').click();
|
||||
AjaxHelpers.respondWithError(requests, 400, {
|
||||
ErrMsg: 'error message'
|
||||
});
|
||||
expect($('.create-library .wrap-error')).toHaveClass('is-shown');
|
||||
expect($('#library_creation_error')).toContainText('error message');
|
||||
expect($('.new-library-save')).toHaveClass('is-disabled');
|
||||
expect($('.new-library-save')).toHaveAttr('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it("can switch tabs", function() {
|
||||
var $courses_tab = $('.courses-tab'),
|
||||
$libraraies_tab = $('.libraries-tab');
|
||||
|
||||
// precondition check - courses tab is loaded by default
|
||||
expect($courses_tab).toHaveClass('active');
|
||||
expect($libraraies_tab).not.toHaveClass('active');
|
||||
|
||||
$('#course-index-tabs .libraries-tab').click(); // switching to library tab
|
||||
expect($courses_tab).not.toHaveClass('active');
|
||||
expect($libraraies_tab).toHaveClass('active');
|
||||
|
||||
$('#course-index-tabs .courses-tab').click(); // switching to course tab
|
||||
expect($courses_tab).toHaveClass('active');
|
||||
expect($libraraies_tab).not.toHaveClass('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
78
cms/static/js/spec/views/pages/library_users_spec.js
Normal file
78
cms/static/js/spec/views/pages/library_users_spec.js
Normal file
@@ -0,0 +1,78 @@
|
||||
define([
|
||||
"jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helpers",
|
||||
"js/factories/manage_users_lib", "js/views/utils/view_utils"
|
||||
],
|
||||
function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) {
|
||||
"use strict";
|
||||
describe("Library Instructor Access Page", function () {
|
||||
var mockHTML = readFixtures('mock/mock-manage-users-lib.underscore');
|
||||
|
||||
beforeEach(function () {
|
||||
ViewHelpers.installMockAnalytics();
|
||||
appendSetFixtures(mockHTML);
|
||||
ManageUsersFactory(
|
||||
"Mock Library",
|
||||
["honor@example.com", "audit@example.com", "staff@example.com"],
|
||||
"dummy_change_role_url"
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
ViewHelpers.removeMockAnalytics();
|
||||
});
|
||||
|
||||
it("can give a user permission to use the library", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var reloadSpy = spyOn(ViewUtils, 'reload');
|
||||
$('.create-user-button').click();
|
||||
expect($('.wrapper-create-user')).toHaveClass('is-shown');
|
||||
$('.user-email-input').val('other@example.com');
|
||||
$('.form-create.create-user .action-primary').click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', 'dummy_change_role_url', {role: 'library_user'});
|
||||
AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can cancel adding a user to the library", function () {
|
||||
$('.create-user-button').click();
|
||||
$('.form-create.create-user .action-secondary').click();
|
||||
expect($('.wrapper-create-user')).not.toHaveClass('is-shown');
|
||||
});
|
||||
|
||||
it("displays an error when the required field is blank", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
$('.create-user-button').click();
|
||||
$('.user-email-input').val('');
|
||||
var errorPromptSelector = '.wrapper-prompt.is-shown .prompt.error';
|
||||
expect($(errorPromptSelector).length).toEqual(0);
|
||||
$('.form-create.create-user .action-primary').click();
|
||||
expect($(errorPromptSelector).length).toEqual(1);
|
||||
expect($(errorPromptSelector)).toContainText('You must enter a valid email address');
|
||||
expect(requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("displays an error when the user has already been added", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
$('.create-user-button').click();
|
||||
$('.user-email-input').val('honor@example.com');
|
||||
var warningPromptSelector = '.wrapper-prompt.is-shown .prompt.warning';
|
||||
expect($(warningPromptSelector).length).toEqual(0);
|
||||
$('.form-create.create-user .action-primary').click();
|
||||
expect($(warningPromptSelector).length).toEqual(1);
|
||||
expect($(warningPromptSelector)).toContainText('Already a library team member');
|
||||
expect(requests.length).toEqual(0);
|
||||
});
|
||||
|
||||
|
||||
it("can remove a user's permission to access the library", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var reloadSpy = spyOn(ViewUtils, 'reload');
|
||||
$('.user-item[data-email="honor@example.com"] .action-delete .delete').click();
|
||||
expect($('.wrapper-prompt.is-shown .prompt.warning').length).toEqual(1);
|
||||
$('.wrapper-prompt.is-shown .action-primary').click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_change_role_url', {role: null});
|
||||
AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
// child xblocks within the page.
|
||||
requestToken: "",
|
||||
|
||||
new_child_view: 'reorderable_container_child_preview',
|
||||
|
||||
xblockReady: function () {
|
||||
XBlockView.prototype.xblockReady.call(this);
|
||||
var reorderableClass, reorderableContainer,
|
||||
@@ -123,6 +125,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
});
|
||||
},
|
||||
|
||||
acknowledgeXBlockDeletion: function(locator){
|
||||
this.notifyRuntime('deleted-child', locator);
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
var sortableInitializedClass = this.makeRequestSpecificSelector('.reorderable-container.ui-sortable');
|
||||
this.$(sortableInitializedClass).sortable('refresh');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"],
|
||||
function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) {
|
||||
var CreateCourseUtils = CreateCourseUtilsFactory({
|
||||
var CreateCourseUtils = new CreateCourseUtilsFactory({
|
||||
name: '.rerun-course-name',
|
||||
org: '.rerun-course-org',
|
||||
number: '.rerun-course-number',
|
||||
@@ -41,7 +41,7 @@ define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils"
|
||||
};
|
||||
|
||||
analytics.track('Reran a Course', course_info);
|
||||
CreateCourseUtils.createCourse(course_info, function (errorMessage) {
|
||||
CreateCourseUtils.create(course_info, function (errorMessage) {
|
||||
$('.wrapper-error').addClass('is-shown').removeClass('is-hidden');
|
||||
$('#course_rerun_error').html('<p>' + errorMessage + '</p>');
|
||||
$('.rerun-course-save').addClass('is-disabled').attr('aria-disabled', true).removeClass('is-processing').html(gettext('Create Re-run'));
|
||||
|
||||
6
cms/static/js/views/library_container.js
Normal file
6
cms/static/js/views/library_container.js
Normal file
@@ -0,0 +1,6 @@
|
||||
define(["js/views/paged_container"],
|
||||
function (PagedContainerView) {
|
||||
// To be extended with Library-specific features later.
|
||||
var LibraryContainerView = PagedContainerView;
|
||||
return LibraryContainerView;
|
||||
}); // end define();
|
||||
@@ -65,7 +65,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
|
||||
onDisplayXBlock: function() {
|
||||
var editorView = this.editorView,
|
||||
title = this.getTitle();
|
||||
title = this.getTitle(),
|
||||
readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !editorView.xblock.save;
|
||||
|
||||
// Notify the runtime that the modal has been shown
|
||||
editorView.notifyRuntime('modal-shown', this);
|
||||
@@ -88,7 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
// If the xblock is not using custom buttons then choose which buttons to show
|
||||
if (!editorView.hasCustomButtons()) {
|
||||
// If the xblock does not support save then disable the save button
|
||||
if (!editorView.xblock.save) {
|
||||
if (readOnlyView) {
|
||||
this.disableSave();
|
||||
}
|
||||
this.getActionBar().show();
|
||||
@@ -101,8 +102,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
disableSave: function() {
|
||||
var saveButton = this.getActionButton('save'),
|
||||
cancelButton = this.getActionButton('cancel');
|
||||
saveButton.hide();
|
||||
cancelButton.text(gettext('OK'));
|
||||
saveButton.parent().hide();
|
||||
cancelButton.text(gettext('Close'));
|
||||
cancelButton.addClass('action-primary');
|
||||
},
|
||||
|
||||
|
||||
164
cms/static/js/views/paged_container.js
Normal file
164
cms/static/js/views/paged_container.js
Normal file
@@ -0,0 +1,164 @@
|
||||
define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext",
|
||||
"js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer", "js/views/paging_mixin"],
|
||||
function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) {
|
||||
var PagedContainerView = ContainerView.extend(PagingMixin).extend({
|
||||
initialize: function(options){
|
||||
var self = this;
|
||||
ContainerView.prototype.initialize.call(this);
|
||||
this.page_size = this.options.page_size;
|
||||
// Reference to the page model
|
||||
this.page = options.page;
|
||||
// XBlocks are rendered via Django views and templates rather than underscore templates, and so don't
|
||||
// have a Backbone model for us to manipulate in a backbone collection. Here, we emulate the interface
|
||||
// of backbone.paginator so that we can use the Paging Header and Footer with this page. As a
|
||||
// consequence, however, we have to manipulate its members manually.
|
||||
this.collection = {
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
totalCount: 0,
|
||||
sortDirection: "desc",
|
||||
start: 0,
|
||||
_size: 0,
|
||||
// Paging header and footer expect this to be a Backbone model they can listen to for changes, but
|
||||
// they cannot. Provide the bind function for them, but have it do nothing.
|
||||
bind: function() {},
|
||||
// size() on backbone collections shows how many objects are in the collection, or in the case
|
||||
// of paginator, on the current page.
|
||||
size: function() { return self.collection._size; }
|
||||
};
|
||||
},
|
||||
|
||||
new_child_view: 'container_child_preview',
|
||||
|
||||
render: function(options) {
|
||||
options = options || {};
|
||||
options.page_number = typeof options.page_number !== "undefined"
|
||||
? options.page_number
|
||||
: this.collection.currentPage;
|
||||
return this.renderPage(options);
|
||||
},
|
||||
|
||||
renderPage: function(options){
|
||||
var self = this,
|
||||
view = this.view,
|
||||
xblockInfo = this.model,
|
||||
xblockUrl = xblockInfo.url();
|
||||
return $.ajax({
|
||||
url: decodeURIComponent(xblockUrl) + "/" + view,
|
||||
type: 'GET',
|
||||
cache: false,
|
||||
data: this.getRenderParameters(options.page_number),
|
||||
headers: { Accept: 'application/json' },
|
||||
success: function(fragment) {
|
||||
self.handleXBlockFragment(fragment, options);
|
||||
self.processPaging({ requested_page: options.page_number });
|
||||
self.page.renderAddXBlockComponents();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getRenderParameters: function(page_number) {
|
||||
return {
|
||||
page_size: this.page_size,
|
||||
enable_paging: true,
|
||||
page_number: page_number
|
||||
};
|
||||
},
|
||||
|
||||
getPageCount: function(total_count){
|
||||
if (total_count===0) return 1;
|
||||
return Math.ceil(total_count / this.page_size);
|
||||
},
|
||||
|
||||
setPage: function(page_number) {
|
||||
this.render({ page_number: page_number});
|
||||
},
|
||||
|
||||
processPaging: function(options){
|
||||
// We have the Django template sneak us the pagination information,
|
||||
// and we load it from a div here.
|
||||
var $element = this.$el.find('.xblock-container-paging-parameters'),
|
||||
total = $element.data('total'),
|
||||
displayed = $element.data('displayed'),
|
||||
start = $element.data('start');
|
||||
|
||||
this.collection.currentPage = options.requested_page;
|
||||
this.collection.totalCount = total;
|
||||
this.collection.totalPages = this.getPageCount(total);
|
||||
this.collection.start = start;
|
||||
this.collection._size = displayed;
|
||||
|
||||
this.processPagingHeaderAndFooter();
|
||||
},
|
||||
|
||||
processPagingHeaderAndFooter: function(){
|
||||
// Rendering the container view detaches the header and footer from the DOM.
|
||||
// It's just as easy to recreate them as it is to try to shove them back into the tree.
|
||||
if (this.pagingHeader)
|
||||
this.pagingHeader.undelegateEvents();
|
||||
if (this.pagingFooter)
|
||||
this.pagingFooter.undelegateEvents();
|
||||
|
||||
this.pagingHeader = new PagingHeader({
|
||||
view: this,
|
||||
el: this.$el.find('.container-paging-header')
|
||||
});
|
||||
this.pagingFooter = new PagingFooter({
|
||||
view: this,
|
||||
el: this.$el.find('.container-paging-footer')
|
||||
});
|
||||
|
||||
this.pagingHeader.render();
|
||||
this.pagingFooter.render();
|
||||
},
|
||||
|
||||
refresh: function(block_added) {
|
||||
if (block_added) {
|
||||
this.collection.totalCount += 1;
|
||||
this.collection._size +=1;
|
||||
if (this.collection.totalCount == 1) {
|
||||
this.render();
|
||||
return
|
||||
}
|
||||
this.collection.totalPages = this.getPageCount(this.collection.totalCount);
|
||||
var new_page = this.collection.totalPages - 1;
|
||||
// If we're on a new page due to overflow, or this is the first item, set the page.
|
||||
if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) {
|
||||
this.setPage(new_page);
|
||||
} else {
|
||||
this.pagingHeader.render();
|
||||
this.pagingFooter.render();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
acknowledgeXBlockDeletion: function (locator){
|
||||
this.notifyRuntime('deleted-child', locator);
|
||||
this.collection._size -= 1;
|
||||
this.collection.totalCount -= 1;
|
||||
var current_page = this.collection.currentPage;
|
||||
var total_pages = this.getPageCount(this.collection.totalCount);
|
||||
this.collection.totalPages = total_pages;
|
||||
// Starts counting from 0
|
||||
if ((current_page + 1) > total_pages) {
|
||||
// The number of total pages has changed. Move down.
|
||||
// Also, be mindful of the off-by-one.
|
||||
this.setPage(total_pages - 1)
|
||||
} else if ((current_page + 1) != total_pages) {
|
||||
// Refresh page to get any blocks shifted from the next page.
|
||||
this.setPage(current_page)
|
||||
} else {
|
||||
// We're on the last page, just need to update the numbers in the
|
||||
// pagination interface.
|
||||
this.pagingHeader.render();
|
||||
this.pagingFooter.render();
|
||||
}
|
||||
},
|
||||
|
||||
sortDisplayName: function() {
|
||||
return gettext("Date added"); // TODO add support for sorting
|
||||
}
|
||||
});
|
||||
|
||||
return PagedContainerView;
|
||||
}); // end define();
|
||||
@@ -16,17 +16,27 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
events: {
|
||||
"click .edit-button": "editXBlock",
|
||||
"click .duplicate-button": "duplicateXBlock",
|
||||
"click .delete-button": "deleteXBlock"
|
||||
"click .delete-button": "deleteXBlock",
|
||||
"click .new-component-button": "scrollToNewComponentButtons"
|
||||
},
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
collapsedClass: 'is-collapsed',
|
||||
canEdit: true // If not specified, assume user has permission to make changes
|
||||
},
|
||||
|
||||
view: 'container_preview',
|
||||
|
||||
defaultViewClass: ContainerView,
|
||||
|
||||
// Overridable by subclasses-- determines whether the XBlock component
|
||||
// addition menu is added on initialization. You may set this to false
|
||||
// if your subclass handles it.
|
||||
components_on_init: true,
|
||||
|
||||
initialize: function(options) {
|
||||
BasePage.prototype.initialize.call(this, options);
|
||||
this.viewClass = options.viewClass || this.defaultViewClass;
|
||||
this.nameEditor = new XBlockStringFieldEditor({
|
||||
el: this.$('.wrapper-xblock-field'),
|
||||
model: this.model
|
||||
@@ -35,11 +45,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
if (this.options.action === 'new') {
|
||||
this.nameEditor.$('.xblock-field-value-edit').click();
|
||||
}
|
||||
this.xblockView = new ContainerView({
|
||||
el: this.$('.wrapper-xblock'),
|
||||
model: this.model,
|
||||
view: this.view
|
||||
});
|
||||
this.xblockView = this.getXBlockView();
|
||||
this.messageView = new ContainerSubviews.MessageView({
|
||||
el: this.$('.container-message'),
|
||||
model: this.model
|
||||
@@ -75,6 +81,18 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
}
|
||||
},
|
||||
|
||||
getViewParameters: function () {
|
||||
return {
|
||||
el: this.$('.wrapper-xblock'),
|
||||
model: this.model,
|
||||
view: this.view
|
||||
}
|
||||
},
|
||||
|
||||
getXBlockView: function(){
|
||||
return new this.viewClass(this.getViewParameters());
|
||||
},
|
||||
|
||||
render: function(options) {
|
||||
var self = this,
|
||||
xblockView = this.xblockView,
|
||||
@@ -97,8 +115,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
// Notify the runtime that the page has been successfully shown
|
||||
xblockView.notifyRuntime('page-shown', self);
|
||||
|
||||
// Render the add buttons
|
||||
self.renderAddXBlockComponents();
|
||||
if (self.components_on_init) {
|
||||
// Render the add buttons. Paged containers should do this on their own.
|
||||
self.renderAddXBlockComponents();
|
||||
}
|
||||
|
||||
// Refresh the views now that the xblock is visible
|
||||
self.onXBlockRefresh(xblockView);
|
||||
@@ -106,7 +126,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
|
||||
// Re-enable Backbone events for any updated DOM elements
|
||||
self.delegateEvents();
|
||||
}
|
||||
},
|
||||
block_added: options && options.block_added
|
||||
});
|
||||
},
|
||||
|
||||
@@ -118,22 +139,26 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
return this.xblockView.model.urlRoot;
|
||||
},
|
||||
|
||||
onXBlockRefresh: function(xblockView) {
|
||||
this.xblockView.refresh();
|
||||
onXBlockRefresh: function(xblockView, block_added) {
|
||||
this.xblockView.refresh(block_added);
|
||||
// Update publish and last modified information from the server.
|
||||
this.model.fetch();
|
||||
},
|
||||
|
||||
renderAddXBlockComponents: function() {
|
||||
var self = this;
|
||||
this.$('.add-xblock-component').each(function(index, element) {
|
||||
var component = new AddXBlockComponent({
|
||||
el: element,
|
||||
createComponent: _.bind(self.createComponent, self),
|
||||
collection: self.options.templates
|
||||
if (self.options.canEdit) {
|
||||
this.$('.add-xblock-component').each(function(index, element) {
|
||||
var component = new AddXBlockComponent({
|
||||
el: element,
|
||||
createComponent: _.bind(self.createComponent, self),
|
||||
collection: self.options.templates
|
||||
});
|
||||
component.render();
|
||||
});
|
||||
component.render();
|
||||
});
|
||||
} else {
|
||||
this.$('.add-xblock-component').remove();
|
||||
}
|
||||
},
|
||||
|
||||
editXBlock: function(event) {
|
||||
@@ -143,8 +168,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
event.preventDefault();
|
||||
|
||||
modal.edit(xblockElement, this.model, {
|
||||
readOnlyView: !this.options.canEdit,
|
||||
refresh: function() {
|
||||
self.refreshXBlock(xblockElement);
|
||||
self.refreshXBlock(xblockElement, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -226,7 +252,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
|
||||
// Inform the runtime that the child has been deleted in case
|
||||
// other views are listening to deletion events.
|
||||
xblockView.notifyRuntime('deleted-child', parent.data('locator'));
|
||||
xblockView.acknowledgeXBlockDeletion(parent.data('locator'));
|
||||
|
||||
// Update publish and last modified information from the server.
|
||||
this.model.fetch();
|
||||
@@ -235,7 +261,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
onNewXBlock: function(xblockElement, scrollOffset, data) {
|
||||
ViewUtils.setScrollOffset(xblockElement, scrollOffset);
|
||||
xblockElement.data('locator', data.locator);
|
||||
return this.refreshXBlock(xblockElement);
|
||||
return this.refreshXBlock(xblockElement, true);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -243,15 +269,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
* reorderable container then the element will be refreshed inline. If not, then the
|
||||
* parent container will be refreshed instead.
|
||||
* @param element An element representing the xblock to be refreshed.
|
||||
* @param block_added Flag to indicate that new block has been just added.
|
||||
*/
|
||||
refreshXBlock: function(element) {
|
||||
refreshXBlock: function(element, block_added) {
|
||||
var xblockElement = this.findXBlockElement(element),
|
||||
parentElement = xblockElement.parent(),
|
||||
rootLocator = this.xblockView.model.id;
|
||||
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
|
||||
this.render({refresh: true});
|
||||
this.render({refresh: true, block_added: block_added});
|
||||
} else if (parentElement.hasClass('reorderable-container')) {
|
||||
this.refreshChildXBlock(xblockElement);
|
||||
this.refreshChildXBlock(xblockElement, block_added);
|
||||
} else {
|
||||
this.refreshXBlock(this.findXBlockElement(parentElement));
|
||||
}
|
||||
@@ -261,9 +288,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
* Refresh an xblock element inline on the page, using the specified xblockInfo.
|
||||
* Note that the element is removed and replaced with the newly rendered xblock.
|
||||
* @param xblockElement The xblock element to be refreshed.
|
||||
* @param block_added Specifies if a block has been added, rather than just needs
|
||||
* refreshing.
|
||||
* @returns {jQuery promise} A promise representing the complete operation.
|
||||
*/
|
||||
refreshChildXBlock: function(xblockElement) {
|
||||
refreshChildXBlock: function(xblockElement, block_added) {
|
||||
var self = this,
|
||||
xblockInfo,
|
||||
TemporaryXBlockView,
|
||||
@@ -284,15 +313,20 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
});
|
||||
temporaryView = new TemporaryXBlockView({
|
||||
model: xblockInfo,
|
||||
view: 'reorderable_container_child_preview',
|
||||
view: self.xblockView.new_child_view,
|
||||
el: xblockElement
|
||||
});
|
||||
return temporaryView.render({
|
||||
success: function() {
|
||||
self.onXBlockRefresh(temporaryView);
|
||||
self.onXBlockRefresh(temporaryView, block_added);
|
||||
temporaryView.unbind(); // Remove the temporary view
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
scrollToNewComponentButtons: function(event) {
|
||||
event.preventDefault();
|
||||
$.scrollTo(this.$('.add-xblock-component'), {duration: 250});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
36
cms/static/js/views/pages/paged_container.js
Normal file
36
cms/static/js/views/pages/paged_container.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PagedXBlockContainerPage is a variant of XBlockContainerPage that supports Pagination.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/pages/container", "js/views/paged_container"],
|
||||
function ($, _, gettext, XBlockContainerPage, PagedContainerView) {
|
||||
'use strict';
|
||||
var PagedXBlockContainerPage = XBlockContainerPage.extend({
|
||||
|
||||
defaultViewClass: PagedContainerView,
|
||||
components_on_init: false,
|
||||
|
||||
initialize: function (options){
|
||||
this.page_size = options.page_size || 10;
|
||||
XBlockContainerPage.prototype.initialize.call(this, options);
|
||||
},
|
||||
|
||||
getViewParameters: function () {
|
||||
return _.extend(XBlockContainerPage.prototype.getViewParameters.call(this), {
|
||||
page_size: this.page_size,
|
||||
page: this
|
||||
});
|
||||
},
|
||||
|
||||
refreshXBlock: function(element, block_added) {
|
||||
var xblockElement = this.findXBlockElement(element),
|
||||
rootLocator = this.xblockView.model.id;
|
||||
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
|
||||
this.render({refresh: true, block_added: block_added});
|
||||
} else {
|
||||
this.refreshChildXBlock(xblockElement, block_added);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
return PagedXBlockContainerPage;
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"],
|
||||
function(_, BaseView, AlertView, gettext) {
|
||||
define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", "js/views/paging_mixin"],
|
||||
function(_, BaseView, AlertView, gettext, PagingMixin) {
|
||||
|
||||
var PagingView = BaseView.extend({
|
||||
var PagingView = BaseView.extend(PagingMixin).extend({
|
||||
// takes a Backbone Paginator as a model
|
||||
|
||||
sortableColumns: {},
|
||||
@@ -21,43 +21,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
|
||||
this.$('#' + sortColumn).addClass('current-sort');
|
||||
},
|
||||
|
||||
setPage: function(page) {
|
||||
var self = this,
|
||||
collection = self.collection,
|
||||
oldPage = collection.currentPage;
|
||||
collection.goTo(page, {
|
||||
reset: true,
|
||||
success: function() {
|
||||
window.scrollTo(0, 0);
|
||||
},
|
||||
error: function(collection) {
|
||||
collection.currentPage = oldPage;
|
||||
self.onError();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onError: function() {
|
||||
// Do nothing by default
|
||||
},
|
||||
|
||||
nextPage: function() {
|
||||
var collection = this.collection,
|
||||
currentPage = collection.currentPage,
|
||||
lastPage = collection.totalPages - 1;
|
||||
if (currentPage < lastPage) {
|
||||
this.setPage(currentPage + 1);
|
||||
}
|
||||
},
|
||||
|
||||
previousPage: function() {
|
||||
var collection = this.collection,
|
||||
currentPage = collection.currentPage;
|
||||
if (currentPage > 0) {
|
||||
this.setPage(currentPage - 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers information about a column that can be sorted.
|
||||
* @param columnName The element name of the column.
|
||||
@@ -110,6 +77,5 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
|
||||
this.setPage(0);
|
||||
}
|
||||
});
|
||||
|
||||
return PagingView;
|
||||
}); // end define();
|
||||
|
||||
@@ -38,6 +38,14 @@ define(["underscore", "js/views/baseview"], function(_, BaseView) {
|
||||
currentPage = collection.currentPage + 1,
|
||||
pageInput = this.$("#page-number-input"),
|
||||
pageNumber = parseInt(pageInput.val(), 10);
|
||||
if (pageNumber > collection.totalPages) {
|
||||
pageNumber = false;
|
||||
}
|
||||
if (pageNumber <= 0) {
|
||||
pageNumber = false;
|
||||
}
|
||||
// If we still have a page number by this point,
|
||||
// and it's not the current page, load it.
|
||||
if (pageNumber && pageNumber !== currentPage) {
|
||||
view.setPage(pageNumber - 1);
|
||||
}
|
||||
|
||||
37
cms/static/js/views/paging_mixin.js
Normal file
37
cms/static/js/views/paging_mixin.js
Normal file
@@ -0,0 +1,37 @@
|
||||
define([],
|
||||
function () {
|
||||
var PagedMixin = {
|
||||
setPage: function (page) {
|
||||
var self = this,
|
||||
collection = self.collection,
|
||||
oldPage = collection.currentPage;
|
||||
collection.goTo(page, {
|
||||
reset: true,
|
||||
success: function () {
|
||||
window.scrollTo(0, 0);
|
||||
},
|
||||
error: function (collection) {
|
||||
collection.currentPage = oldPage;
|
||||
self.onError();
|
||||
}
|
||||
});
|
||||
},
|
||||
nextPage: function() {
|
||||
var collection = this.collection,
|
||||
currentPage = collection.currentPage,
|
||||
lastPage = collection.totalPages - 1;
|
||||
if (currentPage < lastPage) {
|
||||
this.setPage(currentPage + 1);
|
||||
}
|
||||
},
|
||||
|
||||
previousPage: function() {
|
||||
var collection = this.collection,
|
||||
currentPage = collection.currentPage;
|
||||
if (currentPage > 0) {
|
||||
this.setPage(currentPage - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
return PagedMixin;
|
||||
});
|
||||
@@ -1,84 +1,17 @@
|
||||
/**
|
||||
* Provides utilities for validating courses during creation, for both new courses and reruns.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
|
||||
function ($, _, gettext, ViewUtils) {
|
||||
define(["jquery", "gettext", "js/views/utils/view_utils", "js/views/utils/create_utils_base"],
|
||||
function ($, gettext, ViewUtils, CreateUtilsFactory) {
|
||||
"use strict";
|
||||
return function (selectors, classes) {
|
||||
var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr,
|
||||
hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers;
|
||||
var keyLengthViolationMessage = gettext("The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters.");
|
||||
var keyFieldSelectors = [selectors.org, selectors.number, selectors.run];
|
||||
var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number, selectors.run];
|
||||
|
||||
validateRequiredField = function (msg) {
|
||||
return msg.length === 0 ? gettext('Required field.') : '';
|
||||
};
|
||||
CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors);
|
||||
|
||||
// Check that a course (org, number, run) doesn't use any special characters
|
||||
validateCourseItemEncoding = function (item) {
|
||||
var required = validateRequiredField(item);
|
||||
if (required) {
|
||||
return required;
|
||||
}
|
||||
if ($(selectors.allowUnicode).val() === 'True') {
|
||||
if (/\s/g.test(item)) {
|
||||
return gettext('Please do not use any spaces in this field.');
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (item !== encodeURIComponent(item)) {
|
||||
return gettext('Please do not use any spaces or special characters in this field.');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Ensure that org/course_num/run < 65 chars.
|
||||
validateTotalCourseItemsLength = function () {
|
||||
var totalLength = _.reduce(
|
||||
[selectors.org, selectors.number, selectors.run],
|
||||
function (sum, ele) {
|
||||
return sum + $(ele).val().length;
|
||||
}, 0
|
||||
);
|
||||
if (totalLength > 65) {
|
||||
$(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
|
||||
$(selectors.errorMessage).html('<p>' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '</p>');
|
||||
$(selectors.save).addClass(classes.disabled);
|
||||
}
|
||||
else {
|
||||
$(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding);
|
||||
}
|
||||
};
|
||||
|
||||
setNewCourseFieldInErr = function (el, msg) {
|
||||
if (msg) {
|
||||
el.addClass(classes.error);
|
||||
el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg);
|
||||
$(selectors.save).addClass(classes.disabled);
|
||||
}
|
||||
else {
|
||||
el.removeClass(classes.error);
|
||||
el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing);
|
||||
// One "error" div is always present, but hidden or shown
|
||||
if ($(selectors.error).length === 1) {
|
||||
$(selectors.save).removeClass(classes.disabled);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// One final check for empty values
|
||||
hasInvalidRequiredFields = function () {
|
||||
return _.reduce(
|
||||
[selectors.name, selectors.org, selectors.number, selectors.run],
|
||||
function (acc, ele) {
|
||||
var $ele = $(ele);
|
||||
var error = validateRequiredField($ele.val());
|
||||
setNewCourseFieldInErr($ele.parent(), error);
|
||||
return error ? true : acc;
|
||||
},
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
createCourse = function (courseInfo, errorHandler) {
|
||||
this.create = function (courseInfo, errorHandler) {
|
||||
$.postJSON(
|
||||
'/course/',
|
||||
courseInfo,
|
||||
@@ -91,61 +24,5 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Ensure that all fields are not empty
|
||||
validateFilledFields = function () {
|
||||
return _.reduce(
|
||||
[selectors.org, selectors.number, selectors.run, selectors.name],
|
||||
function (acc, ele) {
|
||||
var $ele = $(ele);
|
||||
return $ele.val().length !== 0 ? acc : false;
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// Handle validation asynchronously
|
||||
configureHandlers = function () {
|
||||
_.each(
|
||||
[selectors.org, selectors.number, selectors.run],
|
||||
function (ele) {
|
||||
var $ele = $(ele);
|
||||
$ele.on('keyup', function (event) {
|
||||
// Don't bother showing "required field" error when
|
||||
// the user tabs into a new field; this is distracting
|
||||
// and unnecessary
|
||||
if (event.keyCode === 9) {
|
||||
return;
|
||||
}
|
||||
var error = validateCourseItemEncoding($ele.val());
|
||||
setNewCourseFieldInErr($ele.parent(), error);
|
||||
validateTotalCourseItemsLength();
|
||||
if (!validateFilledFields()) {
|
||||
$(selectors.save).addClass(classes.disabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
var $name = $(selectors.name);
|
||||
$name.on('keyup', function () {
|
||||
var error = validateRequiredField($name.val());
|
||||
setNewCourseFieldInErr($name.parent(), error);
|
||||
validateTotalCourseItemsLength();
|
||||
if (!validateFilledFields()) {
|
||||
$(selectors.save).addClass(classes.disabled);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
validateRequiredField: validateRequiredField,
|
||||
validateCourseItemEncoding: validateCourseItemEncoding,
|
||||
validateTotalCourseItemsLength: validateTotalCourseItemsLength,
|
||||
setNewCourseFieldInErr: setNewCourseFieldInErr,
|
||||
hasInvalidRequiredFields: hasInvalidRequiredFields,
|
||||
createCourse: createCourse,
|
||||
validateFilledFields: validateFilledFields,
|
||||
configureHandlers: configureHandlers
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
34
cms/static/js/views/utils/create_library_utils.js
Normal file
34
cms/static/js/views/utils/create_library_utils.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Provides utilities for validating libraries during creation.
|
||||
*/
|
||||
define(["jquery", "gettext", "js/views/utils/view_utils", "js/views/utils/create_utils_base"],
|
||||
function ($, gettext, ViewUtils, CreateUtilsFactory) {
|
||||
"use strict";
|
||||
return function (selectors, classes) {
|
||||
var keyLengthViolationMessage = gettext("The combined length of the organization and library code fields cannot be more than <%=limit%> characters.")
|
||||
var keyFieldSelectors = [selectors.org, selectors.number];
|
||||
var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number];
|
||||
|
||||
CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors);
|
||||
|
||||
this.create = function (libraryInfo, errorHandler) {
|
||||
$.postJSON(
|
||||
'/library/',
|
||||
libraryInfo
|
||||
).done(function (data) {
|
||||
ViewUtils.redirect(data.url);
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
var reason = errorThrown;
|
||||
if (jqXHR.responseText) {
|
||||
try {
|
||||
var detailedReason = $.parseJSON(jqXHR.responseText).ErrMsg;
|
||||
if (detailedReason) {
|
||||
reason = detailedReason;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
errorHandler(reason);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
123
cms/static/js/views/utils/create_utils_base.js
Normal file
123
cms/static/js/views/utils/create_utils_base.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Mixin class for creation of things like courses and libraries.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
|
||||
function ($, _, gettext, ViewUtils) {
|
||||
return function (selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors) {
|
||||
var self = this;
|
||||
|
||||
this.selectors = selectors;
|
||||
this.classes = classes;
|
||||
this.validateRequiredField = ViewUtils.validateRequiredField;
|
||||
this.validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
|
||||
this.keyLengthViolationMessage = keyLengthViolationMessage;
|
||||
// Key fields for your model, like [selectors.org, selectors.number]
|
||||
this.keyFieldSelectors = keyFieldSelectors;
|
||||
// Fields that must not be empty on your model.
|
||||
this.nonEmptyCheckFieldSelectors = nonEmptyCheckFieldSelectors;
|
||||
|
||||
this.create = function (courseInfo, errorHandler) {
|
||||
// Replace this with a function that will make a request to create the object.
|
||||
};
|
||||
|
||||
// Ensure that key fields passes checkTotalKeyLengthViolations check
|
||||
this.validateTotalKeyLength = function () {
|
||||
ViewUtils.checkTotalKeyLengthViolations(
|
||||
self.selectors, self.classes,
|
||||
self.keyFieldSelectors,
|
||||
self.keyLengthViolationMessage
|
||||
);
|
||||
};
|
||||
|
||||
this.toggleSaveButton = function (is_enabled) {
|
||||
var is_disabled = !is_enabled;
|
||||
$(self.selectors.save).toggleClass(self.classes.disabled, is_disabled).attr('aria-disabled', is_disabled);
|
||||
};
|
||||
|
||||
this.setFieldInErr = function (element, message) {
|
||||
if (message) {
|
||||
element.addClass(self.classes.error);
|
||||
element.children(self.selectors.tipError).addClass(self.classes.showing).removeClass(self.classes.hiding).text(message);
|
||||
self.toggleSaveButton(false);
|
||||
}
|
||||
else {
|
||||
element.removeClass(self.classes.error);
|
||||
element.children(self.selectors.tipError).addClass(self.classes.hiding).removeClass(self.classes.showing);
|
||||
// One "error" div is always present, but hidden or shown
|
||||
if ($(self.selectors.error).length === 1) {
|
||||
self.toggleSaveButton(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// One final check for empty values
|
||||
this.hasInvalidRequiredFields = function () {
|
||||
return _.reduce(
|
||||
self.nonEmptyCheckFieldSelectors,
|
||||
function (acc, element) {
|
||||
var $element = $(element);
|
||||
var error = self.validateRequiredField($element.val());
|
||||
self.setFieldInErr($element.parent(), error);
|
||||
return error ? true : acc;
|
||||
},
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
// Ensure that all fields are not empty
|
||||
this.validateFilledFields = function () {
|
||||
return _.reduce(
|
||||
self.nonEmptyCheckFieldSelectors,
|
||||
function (acc, element) {
|
||||
var $element = $(element);
|
||||
return $element.val().length !== 0 ? acc : false;
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// Handle validation asynchronously
|
||||
this.configureHandlers = function () {
|
||||
_.each(
|
||||
self.keyFieldSelectors,
|
||||
function (element) {
|
||||
var $element = $(element);
|
||||
$element.on('keyup', function (event) {
|
||||
// Don't bother showing "required field" error when
|
||||
// the user tabs into a new field; this is distracting
|
||||
// and unnecessary
|
||||
if (event.keyCode === $.ui.keyCode.TAB) {
|
||||
return;
|
||||
}
|
||||
var error = self.validateURLItemEncoding($element.val(), $(self.selectors.allowUnicode).val() === 'True');
|
||||
self.setFieldInErr($element.parent(), error);
|
||||
self.validateTotalKeyLength();
|
||||
if (!self.validateFilledFields()) {
|
||||
self.toggleSaveButton(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
var $name = $(self.selectors.name);
|
||||
$name.on('keyup', function () {
|
||||
var error = self.validateRequiredField($name.val());
|
||||
self.setFieldInErr($name.parent(), error);
|
||||
self.validateTotalKeyLength();
|
||||
if (!self.validateFilledFields()) {
|
||||
self.toggleSaveButton(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
validateTotalKeyLength: self.validateTotalKeyLength,
|
||||
setFieldInErr: self.setFieldInErr,
|
||||
hasInvalidRequiredFields: self.hasInvalidRequiredFields,
|
||||
create: self.create,
|
||||
validateFilledFields: self.validateFilledFields,
|
||||
configureHandlers: self.configureHandlers
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -5,7 +5,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
function ($, _, gettext, NotificationView, PromptView) {
|
||||
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
|
||||
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset,
|
||||
setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler;
|
||||
setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler,
|
||||
validateRequiredField, validateURLItemEncoding, validateTotalKeyLength, checkTotalKeyLengthViolations;
|
||||
|
||||
// see https://openedx.atlassian.net/browse/TNL-889 for what is it and why it's 65
|
||||
var MAX_SUM_KEY_LENGTH = 65;
|
||||
|
||||
/**
|
||||
* Toggles the expanded state of the current element.
|
||||
@@ -173,6 +177,55 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method for course/library creation - verifies a required field is not blank.
|
||||
*/
|
||||
validateRequiredField = function (msg) {
|
||||
return msg.length === 0 ? gettext('Required field.') : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper method for course/library creation.
|
||||
* Check that a course (org, number, run) doesn't use any special characters
|
||||
*/
|
||||
validateURLItemEncoding = function (item, allowUnicode) {
|
||||
var required = validateRequiredField(item);
|
||||
if (required) {
|
||||
return required;
|
||||
}
|
||||
if (allowUnicode) {
|
||||
if (/\s/g.test(item)) {
|
||||
return gettext('Please do not use any spaces in this field.');
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (item !== encodeURIComponent(item)) {
|
||||
return gettext('Please do not use any spaces or special characters in this field.');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Ensure that sum length of key field values <= ${MAX_SUM_KEY_LENGTH} chars.
|
||||
validateTotalKeyLength = function (key_field_selectors) {
|
||||
var totalLength = _.reduce(
|
||||
key_field_selectors,
|
||||
function (sum, ele) { return sum + $(ele).val().length;},
|
||||
0
|
||||
);
|
||||
return totalLength <= MAX_SUM_KEY_LENGTH;
|
||||
};
|
||||
|
||||
checkTotalKeyLengthViolations = function(selectors, classes, key_field_selectors, message_tpl) {
|
||||
if (!validateTotalKeyLength(key_field_selectors)) {
|
||||
$(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
|
||||
$(selectors.errorMessage).html('<p>' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '</p>');
|
||||
$(selectors.save).addClass(classes.disabled);
|
||||
} else {
|
||||
$(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
'toggleExpandCollapse': toggleExpandCollapse,
|
||||
'showLoadingIndicator': showLoadingIndicator,
|
||||
@@ -186,6 +239,10 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
'setScrollOffset': setScrollOffset,
|
||||
'redirect': redirect,
|
||||
'reload': reload,
|
||||
'hasChangedAttributes': hasChangedAttributes
|
||||
'hasChangedAttributes': hasChangedAttributes,
|
||||
'validateRequiredField': validateRequiredField,
|
||||
'validateURLItemEncoding': validateURLItemEncoding,
|
||||
'validateTotalKeyLength': validateTotalKeyLength,
|
||||
'checkTotalKeyLengthViolations': checkTotalKeyLengthViolations
|
||||
};
|
||||
});
|
||||
|
||||
@@ -409,7 +409,6 @@ form {
|
||||
// ====================
|
||||
.wrapper-create-element {
|
||||
height: 0;
|
||||
margin-bottom: $baseline;
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
@@ -420,6 +419,7 @@ form {
|
||||
|
||||
&.is-shown {
|
||||
height: auto; // define a specific height for the animating version of this UI to work properly
|
||||
margin-bottom: $baseline;
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
119
cms/static/sass/elements/_pagination.scss
Normal file
119
cms/static/sass/elements/_pagination.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
// studio - elements - pagination
|
||||
// ==========================
|
||||
|
||||
%pagination {
|
||||
@include clearfix();
|
||||
display: inline-block;
|
||||
width: flex-grid(3, 12);
|
||||
|
||||
&.pagination-compact {
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
&.pagination-full {
|
||||
display: block;
|
||||
width: flex-grid(4, 12);
|
||||
margin: $baseline auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
padding: ($baseline/4) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
&.next {
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
border-radius: 3px;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: transparent;
|
||||
color: $gray-l2;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@extend %cont-text-sr;
|
||||
}
|
||||
|
||||
.pagination-form,
|
||||
.current-page,
|
||||
.page-divider,
|
||||
.total-pages {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-page,
|
||||
.page-number-input,
|
||||
.total-pages {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
width: ($baseline*2.5);
|
||||
margin: 0 ($baseline*0.75);
|
||||
padding: ($baseline/4);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
@include left(-($baseline/4));
|
||||
}
|
||||
|
||||
.page-divider {
|
||||
@extend %t-title4;
|
||||
@extend %t-regular;
|
||||
vertical-align: middle;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.pagination-form {
|
||||
@extend %ui-depth2;
|
||||
position: relative;
|
||||
|
||||
.page-number-label,
|
||||
.submit-pagination-form {
|
||||
@extend %cont-text-sr;
|
||||
}
|
||||
|
||||
.page-number-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px dotted $gray-l2;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// borrowing the base input focus styles to match overall app
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 0 3px $shadow-d1 inset;
|
||||
background-color: $white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,120 +28,7 @@
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@include clearfix;
|
||||
display: inline-block;
|
||||
width: flex-grid(3, 12);
|
||||
|
||||
&.pagination-compact {
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
&.pagination-full {
|
||||
display: block;
|
||||
width: flex-grid(4, 12);
|
||||
margin: $baseline auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
padding: ($baseline/4) ($baseline*0.75);
|
||||
|
||||
&.previous {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
&.next {
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $blue;
|
||||
border-radius: 3px;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background-color: transparent;
|
||||
color: $gray-l2;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.pagination-form,
|
||||
.current-page,
|
||||
.page-divider,
|
||||
.total-pages {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.current-page,
|
||||
.page-number-input,
|
||||
.total-pages {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
width: ($baseline*2.5);
|
||||
margin: 0 ($baseline*0.75);
|
||||
padding: ($baseline/4);
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
@include left(-($baseline/4));
|
||||
}
|
||||
|
||||
.page-divider {
|
||||
@extend %t-title4;
|
||||
@extend %t-regular;
|
||||
vertical-align: middle;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.pagination-form {
|
||||
@extend %ui-depth2;
|
||||
position: relative;
|
||||
|
||||
.page-number-label,
|
||||
.submit-pagination-form {
|
||||
@extend .sr;
|
||||
}
|
||||
|
||||
.page-number-input {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 1px dotted $gray-l2;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $white;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
// borrowing the base input focus styles to match overall app
|
||||
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 0 3px $shadow-d1 inset;
|
||||
background-color: $white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@extend %pagination;
|
||||
}
|
||||
|
||||
.assets-table {
|
||||
|
||||
@@ -103,6 +103,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.container-paging-header {
|
||||
.meta-wrap {
|
||||
margin: $baseline ($baseline/2);
|
||||
}
|
||||
.meta {
|
||||
@extend %t-copy-sub2;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: flex-grid(9, 12);
|
||||
color: $gray-l1;
|
||||
|
||||
.count-current-shown,
|
||||
.count-total,
|
||||
.sort-order {
|
||||
@extend %t-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@extend %pagination;
|
||||
}
|
||||
}
|
||||
|
||||
.container-paging-footer {
|
||||
.pagination {
|
||||
@extend %pagination;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ====================
|
||||
|
||||
//UI: default internal xblock content styles
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
// +Base - Elements
|
||||
// ====================
|
||||
@import 'elements/typography';
|
||||
@import 'elements/pagination'; // pagination
|
||||
@import 'elements/icons'; // references to icons used
|
||||
@import 'elements/controls'; // buttons, link styles, sliders, etc.
|
||||
@import 'elements/xblocks'; // studio rendering chrome for xblocks
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
// +Base - Elements
|
||||
// ====================
|
||||
@import 'elements/typography';
|
||||
@import 'elements/pagination'; // pagination
|
||||
@import 'elements/icons'; // references to icons used
|
||||
@import 'elements/controls'; // buttons, link styles, sliders, etc.
|
||||
@import 'elements/xblocks'; // studio rendering chrome for xblocks
|
||||
|
||||
@@ -237,13 +237,13 @@
|
||||
}
|
||||
|
||||
// location widget
|
||||
.unit-location {
|
||||
.unit-location, .library-location {
|
||||
@extend %bar-module;
|
||||
border-top: none;
|
||||
|
||||
.wrapper-unit-id {
|
||||
.wrapper-unit-id, .wrapper-library-id {
|
||||
|
||||
.unit-id-value {
|
||||
.unit-id-value, .library-id-value {
|
||||
@extend %cont-text-wrap;
|
||||
@extend %t-copy-sub1;
|
||||
display: inline-block;
|
||||
|
||||
@@ -289,10 +289,44 @@
|
||||
|
||||
// ====================
|
||||
|
||||
// ELEM: course listings
|
||||
.courses {
|
||||
margin: $baseline 0;
|
||||
// Course/Library tabs
|
||||
#course-index-tabs {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
line-height: $baseline*2;
|
||||
margin: 0 10px;
|
||||
|
||||
&.active {
|
||||
border-bottom: 4px solid $blue;
|
||||
}
|
||||
|
||||
&.active, &:hover {
|
||||
a {
|
||||
color: $gray-d2;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ELEM: course listings
|
||||
.courses-tab, .libraries-tab {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.courses, .libraries {
|
||||
.title {
|
||||
@extend %t-title6;
|
||||
margin-bottom: $baseline;
|
||||
@@ -311,7 +345,6 @@
|
||||
}
|
||||
|
||||
.list-courses {
|
||||
margin-top: $baseline;
|
||||
border-radius: 3px;
|
||||
border: 1px solid $gray-l2;
|
||||
background: $white;
|
||||
@@ -464,26 +497,21 @@
|
||||
.metadata-item {
|
||||
display: inline-block;
|
||||
|
||||
&:after {
|
||||
& + .metadata-item:before {
|
||||
content: "/";
|
||||
margin-left: ($baseline/10);
|
||||
margin-right: ($baseline/10);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@extend %cont-text-sr;
|
||||
}
|
||||
}
|
||||
|
||||
.extra-metadata {
|
||||
margin-left: ($baseline/10);
|
||||
}
|
||||
}
|
||||
|
||||
.course-actions {
|
||||
@@ -622,7 +650,7 @@
|
||||
|
||||
// course listings
|
||||
|
||||
.create-course {
|
||||
.create-course, .create-library {
|
||||
|
||||
.row {
|
||||
@include clearfix();
|
||||
|
||||
@@ -116,11 +116,16 @@
|
||||
&.flag-role-admin {
|
||||
background: $pink;
|
||||
}
|
||||
|
||||
&.flag-role-user {
|
||||
background: $yellow-d1;
|
||||
.msg-you { color: $yellow-l1; }
|
||||
}
|
||||
}
|
||||
|
||||
// ELEM: item - metadata
|
||||
.item-metadata {
|
||||
width: flex-grid(5, 9);
|
||||
width: flex-grid(4, 9);
|
||||
@include margin-right(flex-gutter());
|
||||
|
||||
.user-username, .user-email {
|
||||
@@ -143,7 +148,7 @@
|
||||
|
||||
// ELEM: item - actions
|
||||
.item-actions {
|
||||
width: flex-grid(4, 9);
|
||||
width: flex-grid(5, 9);
|
||||
position: static; // nasty reset needed due to base.scss
|
||||
text-align: right;
|
||||
|
||||
@@ -153,12 +158,34 @@
|
||||
}
|
||||
|
||||
.action-role {
|
||||
width: flex-grid(3, 4);
|
||||
width: flex-grid(7, 8);
|
||||
margin-right: flex-gutter();
|
||||
|
||||
.add-admin-role {
|
||||
@include blue-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
}
|
||||
|
||||
.remove-admin-role {
|
||||
@include grey-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
}
|
||||
.notoggleforyou {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray-l2;
|
||||
}
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
width: flex-grid(1, 4);
|
||||
width: flex-grid(1, 8);
|
||||
|
||||
// STATE: disabled
|
||||
&.is-disabled {
|
||||
@@ -178,33 +205,6 @@
|
||||
float: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// ELEM: admin role controls
|
||||
.toggle-admin-role {
|
||||
|
||||
&.add-admin-role {
|
||||
@include blue-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
}
|
||||
|
||||
&.remove-admin-role {
|
||||
@include grey-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.notoggleforyou {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray-l2;
|
||||
}
|
||||
}
|
||||
|
||||
// STATE: hover
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
${context_course.display_name_with_default | h} |
|
||||
% elif context_library:
|
||||
${context_library.display_name_with_default | h} |
|
||||
% endif
|
||||
${settings.STUDIO_NAME}
|
||||
</title>
|
||||
|
||||
@@ -18,13 +18,6 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%!
|
||||
templates = ["basic-modal", "modal-button", "edit-xblock-modal",
|
||||
"editor-mode-button", "upload-dialog", "image-modal",
|
||||
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
|
||||
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
|
||||
"unit-outline", "container-message"]
|
||||
%>
|
||||
<%block name="header_extras">
|
||||
% for template_name in templates:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
@@ -38,7 +31,11 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
|
||||
require(["js/factories/container"], function(ContainerFactory) {
|
||||
ContainerFactory(
|
||||
${component_templates | n}, ${json.dumps(xblock_info) | n},
|
||||
"${action}", ${json.dumps(is_unit_page)}
|
||||
"${action}",
|
||||
{
|
||||
isUnitPage: ${json.dumps(is_unit_page)},
|
||||
canEdit: true
|
||||
}
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
@@ -107,7 +104,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
% if not is_unit_page:
|
||||
% if xblock.category == 'split_test':
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("Adding components")}</h3>
|
||||
<p>${_("Select a component type under {em_start}Add New Component{em_end}. Then select a template.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
@@ -123,8 +120,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
|
||||
<div class="bit external-help">
|
||||
<a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about component containers")}</a>
|
||||
</div>
|
||||
% endif
|
||||
% if is_unit_page:
|
||||
% elif is_unit_page:
|
||||
<div id="publish-unit"></div>
|
||||
<div id="publish-history" class="unit-publish-history"></div>
|
||||
<div class="unit-location is-hidden">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "home" %></%def>
|
||||
<%block name="title">${_("My Courses")}</%block>
|
||||
<%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block>
|
||||
<%block name="bodyclass">is-signedin index view-dashboard</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
@@ -14,7 +14,7 @@
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions">
|
||||
<h1 class="page-header">${_("My Courses")}</h1>
|
||||
<h1 class="page-header">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</h1>
|
||||
|
||||
% if user.is_active:
|
||||
<nav class="nav-actions">
|
||||
@@ -24,6 +24,10 @@
|
||||
% if course_creator_status=='granted':
|
||||
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
|
||||
${_("New Course")}</a>
|
||||
% if libraries_enabled:
|
||||
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
|
||||
${_("New Library")}</a>
|
||||
% endif
|
||||
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
|
||||
<a href="mailto:${settings.FEATURES.get('STUDIO_REQUEST_EMAIL','')}">${_("Email staff to create course")}</a>
|
||||
% endif
|
||||
@@ -39,21 +43,6 @@
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
<div class="introduction">
|
||||
<h2 class="title">${_("Welcome, {0}!").format(user.username)}</h2>
|
||||
|
||||
%if len(courses) > 0:
|
||||
<div class="copy">
|
||||
<p>${_("Here are all of the courses you currently have access to in {studio_name}:").format(studio_name=settings.STUDIO_SHORT_NAME)}</p>
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="copy">
|
||||
<p>${_("You currently aren't associated with any {studio_name} Courses.").format(studio_name=settings.STUDIO_SHORT_NAME)}</p>
|
||||
</div>
|
||||
%endif
|
||||
</div>
|
||||
|
||||
% if course_creator_status=='granted':
|
||||
<div class="wrapper-create-element wrapper-create-course">
|
||||
<form class="form-create create-course course-info" id="create-course-form" name="create-course-form">
|
||||
@@ -72,29 +61,34 @@
|
||||
<ol class="list-input">
|
||||
<li class="field text required" id="field-course-name">
|
||||
<label for="new-course-name">${_("Course Name")}</label>
|
||||
<input class="new-course-name" id="new-course-name" type="text" name="new-course-name" aria-required="true" placeholder="${_('e.g. Introduction to Computer Science')}" />
|
||||
<span class="tip">${_("The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
## Translators: This is an example name for a new course, seen when filling out the form to create a new course.
|
||||
<input class="new-course-name" id="new-course-name" type="text" name="new-course-name" required placeholder="${_('e.g. Introduction to Computer Science')}" aria-describedby="tip-new-course-name tip-error-new-course-name" />
|
||||
<span class="tip" id="tip-new-course-name">${_("The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-course-name"></span>
|
||||
</li>
|
||||
<li class="field text required" id="field-organization">
|
||||
<label for="new-course-org">${_("Organization")}</label>
|
||||
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" aria-required="true" placeholder="${_('e.g. UniversityX or OrganizationX')}" />
|
||||
<span class="tip">${_("The name of the organization sponsoring the course.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
## Translators: This is an example for the name of the organization sponsoring a course, seen when filling out the form to create a new course. The organization name cannot contain spaces.
|
||||
## Translators: "e.g. UniversityX or OrganizationX" is a placeholder displayed when user put no data into this field.
|
||||
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-course-org tip-error-new-course-org" />
|
||||
<span class="tip" id="tip-new-course-org">${_("The name of the organization sponsoring the course.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-course-org"></span>
|
||||
</li>
|
||||
|
||||
<li class="field text required" id="field-course-number">
|
||||
<label for="new-course-number">${_("Course Number")}</label>
|
||||
<input class="new-course-number" id="new-course-number" type="text" name="new-course-number" aria-required="true" placeholder="${_('e.g. CS101')}" />
|
||||
<span class="tip">${_("The unique number that identifies your course within your organization.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
## Translators: This is an example for the number used to identify a course, seen when filling out the form to create a new course. The number here is short for "Computer Science 101". It can contain letters but cannot contain spaces.
|
||||
<input class="new-course-number" id="new-course-number" type="text" name="new-course-number" required placeholder="${_('e.g. CS101')}" aria-describedby="tip-new-course-number tip-error-new-course-number" />
|
||||
<span class="tip" id="tip-new-course-number">${_("The unique number that identifies your course within your organization.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-course-number"></span>
|
||||
</li>
|
||||
|
||||
<li class="field text required" id="field-course-run">
|
||||
<label for="new-course-run">${_("Course Run")}</label>
|
||||
<input class="new-course-run" id="new-course-run" type="text" name="new-course-run" aria-required="true"placeholder="${_('e.g. 2014_T1')}" />
|
||||
<span class="tip">${_("The term in which your course will run.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
## Translators: This is an example for the "run" used to identify different instances of a course, seen when filling out the form to create a new course.
|
||||
<input class="new-course-run" id="new-course-run" type="text" name="new-course-run" required placeholder="${_('e.g. 2014_T1')}" aria-describedby="tip-new-course-run tip-error-new-course-run" />
|
||||
<span class="tip" id="tip-new-course-run">${_("The term in which your course will run.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-course-run"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -108,6 +102,58 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
%if libraries_enabled:
|
||||
<div class="wrapper-create-element wrapper-create-library">
|
||||
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
|
||||
<div class="wrap-error">
|
||||
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
|
||||
<p>${_("Please correct the highlighted fields below.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-form">
|
||||
<h3 class="title">${_("Create a New Library")}</h3>
|
||||
|
||||
<fieldset>
|
||||
<legend class="sr">${_("Required Information to Create a New Library")}</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text required" id="field-library-name">
|
||||
<label for="new-library-name">${_("Library Name")}</label>
|
||||
## Translators: This is an example name for a new content library, seen when filling out the form to create a new library. (A library is a collection of content or problems.)
|
||||
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" required placeholder="${_('e.g. Computer Science Problems')}" aria-describedby="tip-new-library-name tip-error-new-library-name" />
|
||||
<span class="tip" id="tip-new-library-name">${_("The public display name for your library.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-library-name"></span>
|
||||
</li>
|
||||
<li class="field text required" id="field-organization">
|
||||
<label for="new-library-org">${_("Organization")}</label>
|
||||
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-library-org tip-error-new-library-org" />
|
||||
<span class="tip" id="tip-new-library-org">${_("The public organization name for your library.")} ${_("This cannot be changed.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-library-org"></span>
|
||||
</li>
|
||||
|
||||
<li class="field text required" id="field-library-number">
|
||||
<label for="new-library-number">${_("Library Code")}</label>
|
||||
## Translators: This is an example for the "code" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces.
|
||||
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" required placeholder="${_('e.g. CSPROB')}" aria-describedby="tip-new-library-number tip-error-new-library-number" />
|
||||
<span class="tip" id="tip-new-library-number">${_("The unique code that identifies this library.")} <strong>${_("Note: This is part of your library URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-library-number"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input type="hidden" value="${allow_unicode_course_id}" class="allow-unicode-course-id" />
|
||||
<input type="submit" value="${_('Create')}" class="action action-primary new-library-save" />
|
||||
<input type="button" value="${_('Cancel')}" class="action action-secondary action-cancel new-library-cancel" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
% endif
|
||||
|
||||
<!-- STATE: processing courses -->
|
||||
@@ -208,8 +254,15 @@
|
||||
</div>
|
||||
%endif
|
||||
|
||||
%if libraries_enabled:
|
||||
<ul id="course-index-tabs">
|
||||
<li class="courses-tab active"><a>${_("Courses")}</a></li>
|
||||
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
|
||||
</ul>
|
||||
%endif
|
||||
|
||||
%if len(courses) > 0:
|
||||
<div class="courses">
|
||||
<div class="courses courses-tab active">
|
||||
<ul class="list-courses">
|
||||
%for course_info in sorted(courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
|
||||
<li class="course-item" data-course-key="${course_info['course_key'] | h}">
|
||||
@@ -246,10 +299,7 @@
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="courses">
|
||||
</div>
|
||||
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices">
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices courses-tab active">
|
||||
<div class="notice-item">
|
||||
<div class="msg">
|
||||
<h3 class="title">${_("Are you staff on an existing {studio_name} course?").format(studio_name=settings.STUDIO_SHORT_NAME)}</h3>
|
||||
@@ -363,6 +413,44 @@
|
||||
</div>
|
||||
% endif
|
||||
|
||||
%if len(libraries) > 0:
|
||||
<div class="libraries libraries-tab">
|
||||
<ul class="list-courses">
|
||||
%for library_info in sorted(libraries, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
|
||||
<li class="course-item">
|
||||
<a class="library-link" href="${library_info['url']}">
|
||||
<h3 class="course-title">${library_info['display_name']}</h3>
|
||||
|
||||
<div class="course-metadata">
|
||||
<span class="course-org metadata-item">
|
||||
<span class="label">${_("Organization:")}</span> <span class="value">${library_info['org']}</span>
|
||||
</span>
|
||||
<span class="course-num metadata-item">
|
||||
<span class="label">${_("Course Number:")}</span>
|
||||
<span class="value">${library_info['number']}</span>
|
||||
</span>
|
||||
% if not library_info["can_edit"]:
|
||||
<span class="extra-metadata">${_("(Read-only)")}</span>
|
||||
% endif
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
%endfor
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices libraries-tab">
|
||||
<div class="notice-item">
|
||||
<div class="msg">
|
||||
<div class="copy">
|
||||
<p>${_("You don't have any content libraries yet.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
%endif
|
||||
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button new-component-button">
|
||||
<i class="icon fa fa-plus icon-inline"></i>
|
||||
<span class="action-button-text">${_("Add Component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
261
cms/templates/js/mock/mock-container-paged-xblock.underscore
Normal file
261
cms/templates/js/mock/mock-container-paged-xblock.underscore
Normal file
@@ -0,0 +1,261 @@
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
<span class="xblock-display-name">Test Container</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-locator="locator-container" data-request-token="page-render-token"
|
||||
data-init="MockXBlock" data-runtime-class="StudioRuntime" data-runtime-version="1">
|
||||
|
||||
<script type="text/template" id="paging-header-tpl">
|
||||
<div class="meta-wrap">
|
||||
<div class="meta">
|
||||
<%= messageHtml %>
|
||||
</div>
|
||||
<nav class="pagination pagination-compact top">
|
||||
<ol>
|
||||
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
|
||||
<li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</script>
|
||||
<script type="text/template" id="paging-footer-tpl">
|
||||
<nav class="pagination pagination-full bottom">
|
||||
<ol>
|
||||
<li class="nav-item previous"><a class="nav-link previous-page-link" href="#"><i class="icon fa fa-angle-left"></i> <span class="nav-label"><%= gettext("Previous") %></span></a></li>
|
||||
<li class="nav-item page">
|
||||
<div class="pagination-form">
|
||||
<label class="page-number-label" for="page-number"><%= gettext("Page number") %></label>
|
||||
<input id="page-number-input" class="page-number-input" name="page-number" type="text" size="4" />
|
||||
</div>
|
||||
|
||||
<span class="current-page"><%= current_page + 1 %></span>
|
||||
<span class="page-divider">/</span>
|
||||
<span class="total-pages"><%= total_pages %></span>
|
||||
</li>
|
||||
<li class="nav-item next"><a class="nav-link next-page-link" href="#"><span class="nav-label"><%= gettext("Next") %></span> <i class="icon fa fa-angle-right"></i></a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</script>
|
||||
|
||||
<div class="container-paging-header"></div>
|
||||
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-group-A">
|
||||
<section class="wrapper-xblock level-nesting">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
<a href="#" data-tooltip="Expand or Collapse" class="action expand-collapse expand">
|
||||
<i class="icon fa fa-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">Expand or Collapse</span>
|
||||
</a>
|
||||
<span class="xblock-display-name">Group A</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-request-token="page-render-token">
|
||||
<div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-A1">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-A2">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<div class="xblock-header-primary">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-A3">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-xblock-component new-component-item adding"></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-group-B">
|
||||
<section class="wrapper-xblock level-nesting">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-details">
|
||||
<a href="#" data-tooltip="Expand or Collapse" class="action expand-collapse expand">
|
||||
<i class="icon fa fa-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">Expand or Collapse</span>
|
||||
</a>
|
||||
<span class="xblock-display-name">Group B</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-request-token="page-render-token">
|
||||
<div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-B1">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-B2">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<div class="studio-xblock-wrapper" data-locator="locator-component-B3">
|
||||
<section class="wrapper-xblock level-element">
|
||||
<header class="xblock-header">
|
||||
<div class="xblock-header-primary">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" class="delete-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-xblock-component new-component-item adding"></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<div class="container-paging-footer"></div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions">
|
||||
<h1 class="page-header">My Courses</h1>
|
||||
<h1 class="page-header">Studio Home</h1>
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">Page Actions</h3>
|
||||
<ul>
|
||||
@@ -8,6 +8,10 @@
|
||||
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
|
||||
New Course</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
|
||||
New Library</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -17,13 +21,6 @@
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
|
||||
<div class="introduction">
|
||||
<h2 class="title">Welcome, user!</h2>
|
||||
<div class="copy">
|
||||
<p>Here are all of the courses you currently have access to in Studio:</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-create-element wrapper-create-course">
|
||||
<form class="form-create create-course course-info" id="create-course-form" name="create-course-form">
|
||||
<div class="wrap-error">
|
||||
@@ -78,6 +75,53 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-create-element wrapper-create-library">
|
||||
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
|
||||
<div class="wrap-error">
|
||||
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
|
||||
<p>Please correct the highlighted fields below.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-form">
|
||||
<h3 class="title">Create a New Library</h3>
|
||||
|
||||
<fieldset>
|
||||
<legend class="sr">Required Information to Create a New Library</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text required" id="field-library-name">
|
||||
<label for="new-library-name">Library Name</label>
|
||||
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" aria-required="true" placeholder="e.g. Computer Science Problems" />
|
||||
<span class="tip">The public display name for your library.</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
<li class="field text required" id="field-organization">
|
||||
<label for="new-library-org">Organization</label>
|
||||
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" />
|
||||
<span class="tip">The public organization name for your library. This cannot be changed.</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
|
||||
<li class="field text required" id="field-library-number">
|
||||
<label for="new-library-number">Major Version Number</label>
|
||||
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" aria-required="true" value="1" />
|
||||
<span class="tip">The <strong>major version number</strong> of your library. Minor revisions are tracked as edits happen within a library.</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input type="hidden" value="False" class="allow-unicode-course-id" />
|
||||
<input type="submit" value="Create" class="action action-primary new-library-save" />
|
||||
<input type="button" value="Cancel" class="action action-secondary action-cancel new-library-cancel" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- STATE: processing courses -->
|
||||
<div class="courses courses-processing">
|
||||
<h3 class="title">Courses Being Processed</h3>
|
||||
@@ -163,6 +207,15 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul id="course-index-tabs">
|
||||
<li class="courses-tab active"><a>${_("Courses")}</a></li>
|
||||
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="courses courses-tab active">
|
||||
<div class="libraries libraries-tab"></div>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
146
cms/templates/js/mock/mock-manage-users-lib.underscore
Normal file
146
cms/templates/js/mock/mock-manage-users-lib.underscore
Normal file
@@ -0,0 +1,146 @@
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">Settings</small>
|
||||
<span class="sr">> </span>Instructor Access
|
||||
</h1>
|
||||
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">Page Actions</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button create-user-button"><i class="icon fa fa-plus"></i> Add Instructor</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<div class="wrapper-create-element animate wrapper-create-user">
|
||||
<form class="form-create create-user" id="create-user-form" name="create-user-form">
|
||||
<div class="wrapper-form">
|
||||
<h3 class="title">Grant Instructor Access to This Library</h3>
|
||||
|
||||
<fieldset class="form-fields">
|
||||
<legend class="sr">New Instructor Information</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text required create-user-email">
|
||||
<label for="user-email-input">User's Email Address</label>
|
||||
<input id="user-email-input" class="user-email-input" name="user-email" type="text" placeholder="example: username@domain.com" value="">
|
||||
<span class="tip tip-stacked">Please provide the email address of the instructor you'd like to add</span>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action action-primary" type="submit">Add User</button>
|
||||
<button class="action action-secondary action-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ol class="user-list">
|
||||
|
||||
<li class="user-item" data-email="honor@example.com">
|
||||
|
||||
<span class="wrapper-ui-badge">
|
||||
<span class="flag flag-role flag-role-staff is-hanging">
|
||||
<span class="label sr">Current Role:</span>
|
||||
<span class="value">
|
||||
Staff
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="item-metadata">
|
||||
<h3 class="user-name">
|
||||
<span class="user-username">honor</span>
|
||||
<span class="user-email">
|
||||
<a class="action action-email" href="mailto:honor@example.com" title="send an email message to honor@example.com">honor@example.com</a>
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="item-actions user-actions">
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-instructor admin-role add-admin-role">Add Admin Access</span></a>
|
||||
<a href="#" class="make-user admin-role remove-admin-role">Remove Staff Access</span></a>
|
||||
</li>
|
||||
<li class="action action-delete ">
|
||||
<a href="#" class="delete remove-user action-icon" data-tooltip="Remove this user"><i class="icon fa fa-trash-o"></i><span class="sr">Delete the user, honor</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</li>
|
||||
|
||||
<li class="user-item" data-email="audit@example.com">
|
||||
|
||||
<span class="wrapper-ui-badge">
|
||||
<span class="flag flag-role flag-role-admin is-hanging">
|
||||
<span class="label sr">Current Role:</span>
|
||||
<span class="value">
|
||||
Admin
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="item-metadata">
|
||||
<h3 class="user-name">
|
||||
<span class="user-username">audit</span>
|
||||
<span class="user-email">
|
||||
<a class="action action-email" href="mailto:audit@example.com" title="send an email message to audit@example.com">audit@example.com</a>
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="item-actions user-actions">
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-staff admin-role remove-admin-role">Remove Admin Access</span></a>
|
||||
</li>
|
||||
<li class="action action-delete ">
|
||||
<a href="#" class="delete remove-user action-icon" data-tooltip="Remove this user"><i class="icon fa fa-trash-o"></i><span class="sr">Delete the user, audit</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</li>
|
||||
|
||||
<li class="user-item" data-email="staff@example.com">
|
||||
|
||||
<span class="wrapper-ui-badge">
|
||||
<span class="flag flag-role flag-role-user is-hanging">
|
||||
<span class="label sr">Current Role:</span>
|
||||
<span class="value">
|
||||
User
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="item-metadata">
|
||||
<h3 class="user-name">
|
||||
<span class="user-username">staff</span>
|
||||
<span class="user-email">
|
||||
<a class="action action-email" href="mailto:staff@example.com" title="send an email message to staff@example.com">staff@example.com</a>
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="item-actions user-actions">
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-staff admin-role add-admin-role">Add Staff Access</span></a>
|
||||
</li>
|
||||
<li class="action action-delete ">
|
||||
<a href="#" class="delete remove-user action-icon" data-tooltip="Remove this user"><i class="icon fa fa-trash-o"></i><span class="sr">Delete the user, staff</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
21
cms/templates/js/mock/mock-xblock-paged.underscore
Normal file
21
cms/templates/js/mock/mock-xblock-paged.underscore
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="studio-xblock-wrapper">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span class="xblock-display-name">Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
</ul>
|
||||
<a href="#" class="button action-button notification-action-button" data-notification-action="add-missing-groups">
|
||||
<span class="action-button-text">Add Missing Groups</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<p>Mock XBlock</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
id="<%= type %>-<%= intent %>"
|
||||
aria-hidden="<% if(obj.shown) { %>false<% } else { %>true<% } %>"
|
||||
aria-labelledby="<%= type %>-<%= intent %>-title"
|
||||
tabindex="-1"
|
||||
<% if (obj.message) { %>aria-describedby="<%= type %>-<%= intent %>-description" <% } %>
|
||||
<% if (obj.actions) { %>role="dialog"<% } %>
|
||||
>
|
||||
|
||||
101
cms/templates/library.html
Normal file
101
cms/templates/library.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "content_libraries" %></%def>
|
||||
<%!
|
||||
import json
|
||||
|
||||
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">${context_library.display_name_with_default} ${xblock_type_display_name(context_library)}</%block>
|
||||
<%block name="bodyclass">is-signedin course container view-container view-library</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in templates:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/library"], function(LibraryFactory) {
|
||||
LibraryFactory(
|
||||
${component_templates | n},
|
||||
${json.dumps(xblock_info) | n},
|
||||
{
|
||||
isUnitPage: false,
|
||||
page_size: 10,
|
||||
canEdit: ${"true" if can_edit else "false"}
|
||||
}
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-navigation has-subtitle">
|
||||
<div class="page-header">
|
||||
<small class="subtitle">${_("Content Library")}</small>
|
||||
<div class="wrapper-xblock-field incontext-editor is-editable"
|
||||
data-field="display_name" data-field-display-name="${_("Display Name")}">
|
||||
<h1 class="page-header-title xblock-field-value incontext-editor-value"><span class="title-value">${context_library.display_name_with_default | h}</span></h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">${_("Page Actions")}</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button new-component-button">
|
||||
<i class="icon fa fa-plus icon-inline"></i> <span class="action-button-text">${_("Add Component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<section class="content-area">
|
||||
|
||||
<article class="content-primary">
|
||||
<div class="container-message wrapper-message"></div>
|
||||
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${context_library.location | h}" data-course-key="${context_library.location.library_key | h}">
|
||||
</section>
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="library-location">
|
||||
<h4 class="bar-mod-title">${_("Library ID")}</h4>
|
||||
<div class="wrapper-library-id bar-mod-content">
|
||||
<h5 class="title">${_("Library ID")}</h5>
|
||||
<p class="library-id">
|
||||
<span class="library-id-value">${context_library.location.library_key | h}</span>
|
||||
<span class="tip"><span class="sr">${_("Tip:")}</span> ${_("To add content from this library to a course that uses a Randomized Content Block, copy this ID and enter it in the Libraries field in the Randomized Content Block settings.")}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
% if can_edit:
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("Adding content to your library")}</h3>
|
||||
<p>${_("Add components to your library for use in courses, using Add New Component at the bottom of this page.")}</p>
|
||||
<p>${_("Components are listed in the order in which they are added, with the most recently added at the bottom. Use the pagination arrows to navigate from page to page if you have more than one page of components in your library.")}</p>
|
||||
<h3 class="title-3">${_("Using library content in courses")}</h3>
|
||||
<p>${_("Use library content in courses by adding the {em_start}library_content{em_end} policy key to Advanced Settings, then adding a Randomized Content Block to your courseware. In the settings for each Randomized Content Block, enter the Library ID for each library from which you want to draw content, and specify the number of problems to be randomly selected and displayed to each student.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
</div>
|
||||
% endif
|
||||
<div class="bit external-help">
|
||||
<a href="${get_online_help_info('library')['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about content libraries")}</a>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
170
cms/templates/manage_users_lib.html
Normal file
170
cms/templates/manage_users_lib.html
Normal file
@@ -0,0 +1,170 @@
|
||||
<%! import json %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "team" %></%def>
|
||||
<%block name="title">${_("Library User Access")}</%block>
|
||||
<%block name="bodyclass">is-signedin course users view-team</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">${_("Settings")}</small>
|
||||
<span class="sr">> </span>${_("User Access")}
|
||||
</h1>
|
||||
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">${_("Page Actions")}</h3>
|
||||
<ul>
|
||||
%if allow_actions:
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button create-user-button"><i class="icon fa fa-plus"></i> ${_("New Team Member")}</a>
|
||||
</li>
|
||||
%endif
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
%if allow_actions:
|
||||
<div class="wrapper-create-element animate wrapper-create-user">
|
||||
<form class="form-create create-user" id="create-user-form" name="create-user-form">
|
||||
<div class="wrapper-form">
|
||||
<h3 class="title">${_("Grant Access to This Library")}</h3>
|
||||
|
||||
<fieldset class="form-fields">
|
||||
<legend class="sr">${_("New Team Member Information")}</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text required create-user-email">
|
||||
<label for="user-email-input">${_("User's Email Address")}</label>
|
||||
<input id="user-email-input" class="user-email-input" name="user-email" type="text" placeholder="${_('example: username@domain.com')}" value="">
|
||||
<span class="tip tip-stacked">${_("Provide the email address of the user you want to add")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action action-primary" type="submit">${_("Add User")}</button>
|
||||
<button class="action action-secondary action-cancel">${_("Cancel")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
%endif
|
||||
|
||||
<ol class="user-list">
|
||||
% for user in all_users:
|
||||
<%
|
||||
is_instructor = user in instructors
|
||||
is_staff = user in staff
|
||||
role_id = 'admin' if is_instructor else ('staff' if is_staff else 'user')
|
||||
role_desc = _("Admin") if is_instructor else (_("Staff") if is_staff else _("User"))
|
||||
%>
|
||||
|
||||
<li class="user-item" data-email="${user.email}">
|
||||
|
||||
<span class="wrapper-ui-badge">
|
||||
<span class="flag flag-role flag-role-${role_id} is-hanging">
|
||||
<span class="label sr">${_("Current Role:")}</span>
|
||||
<span class="value">
|
||||
${role_desc}
|
||||
% if request.user.id == user.id:
|
||||
<span class="msg-you">${_("You!")}</span>
|
||||
% endif
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="item-metadata">
|
||||
<h3 class="user-name">
|
||||
<span class="user-username">${user.username}</span>
|
||||
<span class="user-email">
|
||||
<a class="action action-email" href="mailto:${user.email}" title="${_("send an email message to {email}").format(email=user.email)}">${user.email}</a>
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
% if allow_actions:
|
||||
<ul class="item-actions user-actions">
|
||||
% if is_instructor:
|
||||
% if len(instructors) > 1:
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-staff admin-role remove-admin-role">${_("Remove Admin Access")}</span></a>
|
||||
</li>
|
||||
% else:
|
||||
<li class="action action-role">
|
||||
<span class="admin-role notoggleforyou">${_("Promote another member to Admin to remove your admin rights")}</span>
|
||||
</li>
|
||||
% endif
|
||||
% elif is_staff:
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-instructor admin-role add-admin-role">${_("Add Admin Access")}</span></a>
|
||||
<a href="#" class="make-user admin-role remove-admin-role">${_("Remove Staff Access")}</span></a>
|
||||
</li>
|
||||
% else:
|
||||
<li class="action action-role">
|
||||
<a href="#" class="make-staff admin-role add-admin-role">${_("Add Staff Access")}</span></a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="action action-delete ${"is-disabled" if request.user.id == user.id and is_instructor and len(instructors) == 1 else ""}">
|
||||
<a href="#" class="delete remove-user action-icon" data-tooltip="${_("Remove this user")}"><i class="icon fa fa-trash-o"></i><span class="sr">${_("Delete the user, {username}").format(username=user.username)}</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
% elif request.user.id == user.id:
|
||||
<ul class="item-actions user-actions">
|
||||
<li class="action action-delete">
|
||||
<a href="#" class="delete remove-user action-icon" data-tooltip="${_("Remove me")}"><i class="icon fa fa-trash-o"></i><span class="sr">${_("Remove me from this library")}</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
% endif
|
||||
|
||||
</li>
|
||||
% endfor
|
||||
</ol>
|
||||
|
||||
% if allow_actions and len(all_users) == 1:
|
||||
<div class="notice notice-incontext notice-create has-actions">
|
||||
<div class="msg">
|
||||
<h3 class="title">${_('Add More Users to This Library')}</h3>
|
||||
<div class="copy">
|
||||
<p>${_('Grant other members of your course team access to this library. New library users must have an active {studio_name} account.').format(studio_name=settings.STUDIO_SHORT_NAME)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-actions">
|
||||
<li class="action-item">
|
||||
<a href="#" class="action action-primary button new-button create-user-button"><i class="icon fa fa-plus icon-inline"></i> ${_('Add a New User')}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
%endif
|
||||
</article>
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("Library Access Roles")}</h3>
|
||||
<p>${_("There are three access roles for libraries: User, Staff, and Admin.")}</p>
|
||||
<p>${_("Users can view library content and can reference or use library components in their courses, but they cannot edit the contents of a library.")}</p>
|
||||
<p>${_("Staff are content co-authors. They have full editing privileges on the contents of a library.")}</p>
|
||||
<p>${_("Admins have full editing privileges and can also add and remove other team members. There must be at least one user with Admin privileges in a library.")}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/manage_users_lib"], function(ManageUsersFactory) {
|
||||
ManageUsersFactory(
|
||||
"${context_library.display_name_with_default | h}",
|
||||
${json.dumps([user.email for user in all_users])},
|
||||
"${reverse('contentstore.views.course_team_handler', kwargs={'course_key_string': library_key, 'email': '@@EMAIL@@'})}"
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
@@ -55,30 +55,38 @@ messages = json.dumps(xblock.validate().to_json())
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
% if not is_root:
|
||||
% if not show_inline:
|
||||
<li class="action-item action-edit">
|
||||
% if can_edit:
|
||||
% if not show_inline:
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon fa fa-pencil"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon fa fa-copy"></i>
|
||||
<span class="sr">${_("Duplicate")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon fa fa-trash-o"></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>
|
||||
</li>
|
||||
% endif
|
||||
% elif not show_inline:
|
||||
<li class="action-item action-edit action-edit-view-only">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon fa fa-pencil"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
<span class="action-button-text">${_("Details")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon fa fa-copy"></i>
|
||||
<span class="sr">${_("Duplicate")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon fa fa-trash-o"></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>
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
</ul>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</h2>
|
||||
|
||||
<nav class="nav-course nav-dd ui-left">
|
||||
<h2 class="sr">${_("{course_name}'s Navigation:").format(course_name=context_course.display_name_with_default)}</h2>
|
||||
<h2 class="sr">${_("Navigation for {course_name}").format(course_name=context_course.display_name_with_default)}</h2>
|
||||
<ol>
|
||||
<li class="nav-item nav-course-courseware">
|
||||
<h3 class="title"><span class="label"><span class="label-prefix sr">${_("Course")} </span>${_("Content")}</span> <i class="icon fa fa-caret-down ui-toggle-dd"></i></h3>
|
||||
@@ -132,6 +132,38 @@
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
% elif context_library:
|
||||
<%
|
||||
library_key = context_library.location.course_key
|
||||
index_url = reverse('contentstore.views.library_handler', kwargs={'library_key_string': unicode(library_key)})
|
||||
%>
|
||||
<h2 class="info-course">
|
||||
<span class="sr">${_("Current Library:")}</span>
|
||||
<a class="course-link" href="${index_url}">
|
||||
<span class="course-org">${context_library.display_org_with_default | h}</span><span class="course-number">${context_library.display_number_with_default | h}</span>
|
||||
<span class="course-title" title="${context_library.display_name_with_default}">${context_library.display_name_with_default}</span>
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<nav class="nav-course nav-dd ui-left">
|
||||
<h2 class="sr">${_("Navigation for {course_name}").format(course_name=context_library.display_name_with_default)}</h2>
|
||||
<ol>
|
||||
|
||||
<li class="nav-item nav-library-settings">
|
||||
<h3 class="title"><span class="label"><span class="label-prefix sr">${_("Library")} </span>${_("Settings")}</span> <i class="icon fa fa-caret-down ui-toggle-dd"></i></h3>
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-library-settings-team">
|
||||
<a href="${lib_users_url}">${_("User Access")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
@@ -151,7 +183,7 @@
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">${_("My Courses")}</a>
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-account-signout">
|
||||
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
|
||||
|
||||
18
cms/urls.py
18
cms/urls.py
@@ -5,6 +5,13 @@ from django.conf.urls import patterns, include, url
|
||||
from ratelimitbackend import admin
|
||||
admin.autodiscover()
|
||||
|
||||
# Pattern to match a course key or a library key
|
||||
COURSELIKE_KEY_PATTERN = r'(?P<course_key_string>({}|{}))'.format(
|
||||
r'[^/]+/[^/]+/[^/]+', r'[^/:]+:[^/+]+\+[^/+]+(\+[^/]+)?'
|
||||
)
|
||||
# Pattern to match a library key only
|
||||
LIBRARY_KEY_PATTERN = r'(?P<library_key_string>library-v1:[^/+]+\+[^/+]+)'
|
||||
|
||||
urlpatterns = patterns('', # nopep8
|
||||
|
||||
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
|
||||
@@ -66,12 +73,13 @@ urlpatterns += patterns(
|
||||
url(r'^signin$', 'login_page', name='login'),
|
||||
url(r'^request_course_creator$', 'request_course_creator'),
|
||||
|
||||
url(r'^course_team/{}/(?P<email>.+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_team_handler'),
|
||||
url(r'^course_team/{}/(?P<email>.+)?$'.format(COURSELIKE_KEY_PATTERN), 'course_team_handler'),
|
||||
url(r'^course_info/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_info_handler'),
|
||||
url(
|
||||
r'^course_info_update/{}/(?P<provided_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN),
|
||||
'course_info_update_handler'
|
||||
),
|
||||
url(r'^home/$', 'course_listing', name='home'),
|
||||
url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'),
|
||||
url(r'^course_notifications/{}/(?P<action_state_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'),
|
||||
url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
|
||||
@@ -112,6 +120,14 @@ urlpatterns += patterns(
|
||||
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
|
||||
)
|
||||
|
||||
if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'):
|
||||
urlpatterns += (
|
||||
url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN),
|
||||
'contentstore.views.library_handler', name='library_handler'),
|
||||
url(r'^library/{}/team/$'.format(LIBRARY_KEY_PATTERN),
|
||||
'contentstore.views.manage_library_users', name='manage_library_users'),
|
||||
)
|
||||
|
||||
if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
|
||||
urlpatterns += (url(
|
||||
r'^export_git/{}$'.format(
|
||||
|
||||
@@ -6,9 +6,18 @@ to decide whether to check course creator role, and other such functions.
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.conf import settings
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
|
||||
from student.roles import GlobalStaff, CourseCreatorRole, CourseStaffRole, CourseInstructorRole, CourseRole, \
|
||||
CourseBetaTesterRole, OrgInstructorRole, OrgStaffRole
|
||||
CourseBetaTesterRole, OrgInstructorRole, OrgStaffRole, LibraryUserRole, OrgLibraryUserRole
|
||||
|
||||
|
||||
# Studio permissions:
|
||||
STUDIO_EDIT_ROLES = 8
|
||||
STUDIO_VIEW_USERS = 4
|
||||
STUDIO_EDIT_CONTENT = 2
|
||||
STUDIO_VIEW_CONTENT = 1
|
||||
# In addition to the above, one is always allowed to "demote" oneself to a lower role within a course, or remove oneself
|
||||
|
||||
|
||||
def has_access(user, role):
|
||||
@@ -40,9 +49,36 @@ def has_access(user, role):
|
||||
return False
|
||||
|
||||
|
||||
def has_course_author_access(user, course_key, role=CourseStaffRole):
|
||||
def get_user_permissions(user, course_key, org=None):
|
||||
"""
|
||||
Return True if user has studio (write) access to the given course.
|
||||
Get the bitmask of permissions that this user has in the given course context.
|
||||
Can also set course_key=None and pass in an org to get the user's
|
||||
permissions for that organization as a whole.
|
||||
"""
|
||||
if org is None:
|
||||
org = course_key.org
|
||||
course_key = course_key.for_branch(None)
|
||||
else:
|
||||
assert course_key is None
|
||||
all_perms = STUDIO_EDIT_ROLES | STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
|
||||
# global staff, org instructors, and course instructors have all permissions:
|
||||
if GlobalStaff().has_user(user) or OrgInstructorRole(org=org).has_user(user):
|
||||
return all_perms
|
||||
if course_key and has_access(user, CourseInstructorRole(course_key)):
|
||||
return all_perms
|
||||
# Staff have all permissions except EDIT_ROLES:
|
||||
if OrgStaffRole(org=org).has_user(user) or (course_key and has_access(user, CourseStaffRole(course_key))):
|
||||
return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
|
||||
# Otherwise, for libraries, users can view only:
|
||||
if course_key and isinstance(course_key, LibraryLocator):
|
||||
if OrgLibraryUserRole(org=org).has_user(user) or has_access(user, LibraryUserRole(course_key)):
|
||||
return STUDIO_VIEW_USERS | STUDIO_VIEW_CONTENT
|
||||
return 0
|
||||
|
||||
|
||||
def has_studio_write_access(user, course_key):
|
||||
"""
|
||||
Return True if user has studio write access to the given course.
|
||||
Note that the CMS permissions model is with respect to courses.
|
||||
There is a super-admin permissions if user.is_staff is set.
|
||||
Also, since we're unifying the user database between LMS and CAS,
|
||||
@@ -52,16 +88,26 @@ def has_course_author_access(user, course_key, role=CourseStaffRole):
|
||||
|
||||
:param user:
|
||||
:param course_key: a CourseKey
|
||||
:param role: an AccessRole
|
||||
"""
|
||||
if GlobalStaff().has_user(user):
|
||||
return True
|
||||
if OrgInstructorRole(org=course_key.org).has_user(user):
|
||||
return True
|
||||
if OrgStaffRole(org=course_key.org).has_user(user):
|
||||
return True
|
||||
# temporary to ensure we give universal access given a course until we impl branch specific perms
|
||||
return has_access(user, role(course_key.for_branch(None)))
|
||||
return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key))
|
||||
|
||||
|
||||
def has_course_author_access(user, course_key):
|
||||
"""
|
||||
Old name for has_studio_write_access
|
||||
"""
|
||||
return has_studio_write_access(user, course_key)
|
||||
|
||||
|
||||
def has_studio_read_access(user, course_key):
|
||||
"""
|
||||
Return True iff user is allowed to view this course/library in studio.
|
||||
Will also return True if user has write access in studio (has_course_author_access)
|
||||
|
||||
There is currently no such thing as read-only course access in studio, but
|
||||
there is read-only access to content libraries.
|
||||
"""
|
||||
return bool(STUDIO_VIEW_CONTENT & get_user_permissions(user, course_key))
|
||||
|
||||
|
||||
def add_users(caller, role, *users):
|
||||
|
||||
@@ -227,6 +227,17 @@ class CourseBetaTesterRole(CourseRole):
|
||||
super(CourseBetaTesterRole, self).__init__(self.ROLE, *args, **kwargs)
|
||||
|
||||
|
||||
class LibraryUserRole(CourseRole):
|
||||
"""
|
||||
A user who can view a library and import content from it, but not edit it.
|
||||
Used in Studio only.
|
||||
"""
|
||||
ROLE = 'library_user'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs)
|
||||
|
||||
|
||||
class OrgStaffRole(OrgRole):
|
||||
"""An organization staff member"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -239,6 +250,17 @@ class OrgInstructorRole(OrgRole):
|
||||
super(OrgInstructorRole, self).__init__('instructor', *args, **kwargs)
|
||||
|
||||
|
||||
class OrgLibraryUserRole(OrgRole):
|
||||
"""
|
||||
A user who can view any libraries in an org and import content from them, but not edit them.
|
||||
Used in Studio only.
|
||||
"""
|
||||
ROLE = LibraryUserRole.ROLE
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(OrgLibraryUserRole, self).__init__(self.ROLE, *args, **kwargs)
|
||||
|
||||
|
||||
class CourseCreatorRole(RoleBase):
|
||||
"""
|
||||
This is the group of people who have permission to create new courses (we may want to eventually
|
||||
|
||||
@@ -482,7 +482,7 @@ class LoginOAuthTokenMixin(object):
|
||||
self._setup_user_response(success=True)
|
||||
response = self.client.post(self.url, {"access_token": "dummy"})
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertEqual(self.client.session['_auth_user_id'], self.user.id)
|
||||
self.assertEqual(self.client.session['_auth_user_id'], self.user.id) # pylint: disable=no-member
|
||||
|
||||
def test_invalid_token(self):
|
||||
self._setup_user_response(success=False)
|
||||
|
||||
@@ -1745,6 +1745,7 @@ def auto_auth(request):
|
||||
* `staff`: Set to "true" to make the user global staff.
|
||||
* `course_id`: Enroll the student in the course with `course_id`
|
||||
* `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
|
||||
* `no_login`: Define this to create the user but not login
|
||||
|
||||
If username, email, or password are not provided, use
|
||||
randomly generated credentials.
|
||||
@@ -1764,6 +1765,7 @@ def auto_auth(request):
|
||||
if course_id:
|
||||
course_key = CourseLocator.from_string(course_id)
|
||||
role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()]
|
||||
login_when_done = 'no_login' not in request.GET
|
||||
|
||||
# Get or create the user object
|
||||
post_data = {
|
||||
@@ -1807,14 +1809,16 @@ def auto_auth(request):
|
||||
user.roles.add(role)
|
||||
|
||||
# Log in as the user
|
||||
user = authenticate(username=username, password=password)
|
||||
login(request, user)
|
||||
if login_when_done:
|
||||
user = authenticate(username=username, password=password)
|
||||
login(request, user)
|
||||
|
||||
create_comments_service_user(user)
|
||||
|
||||
# Provide the user with a valid CSRF token
|
||||
# then return a 200 response
|
||||
success_msg = u"Logged in user {0} ({1}) with password {2} and user_id {3}".format(
|
||||
success_msg = u"{} user {} ({}) with password {} and user_id {}".format(
|
||||
u"Logged in" if login_when_done else "Created",
|
||||
username, email, password, user.id
|
||||
)
|
||||
response = HttpResponse(success_msg)
|
||||
|
||||
@@ -28,27 +28,27 @@ GLOBAL_WAIT_FOR_TIMEOUT = 60
|
||||
|
||||
REQUIREJS_WAIT = {
|
||||
# Settings - Schedule & Details
|
||||
re.compile('^Schedule & Details Settings \|'): [
|
||||
re.compile(r'^Schedule & Details Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course",
|
||||
"js/models/settings/course_details", "js/views/settings/main"],
|
||||
|
||||
# Settings - Advanced Settings
|
||||
re.compile('^Advanced Settings \|'): [
|
||||
re.compile(r'^Advanced Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course", "js/models/settings/advanced",
|
||||
"js/views/settings/advanced", "codemirror"],
|
||||
|
||||
# Unit page
|
||||
re.compile('^Unit \|'): [
|
||||
re.compile(r'^Unit \|'): [
|
||||
"jquery", "js/base", "js/models/xblock_info", "js/views/pages/container",
|
||||
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
|
||||
# Content - Outline
|
||||
# Note that calling your org, course number, or display name, 'course' will mess this up
|
||||
re.compile('^Course Outline \|'): [
|
||||
re.compile(r'^Course Outline \|'): [
|
||||
"js/base", "js/models/course", "js/models/location", "js/models/section"],
|
||||
|
||||
# Dashboard
|
||||
re.compile('^My Courses \|'): [
|
||||
re.compile(r'^Studio Home \|'): [
|
||||
"js/sock", "gettext", "js/base",
|
||||
"jquery.ui", "coffee/src/main", "underscore"],
|
||||
|
||||
@@ -59,7 +59,7 @@ REQUIREJS_WAIT = {
|
||||
],
|
||||
|
||||
# Pages
|
||||
re.compile('^Pages \|'): [
|
||||
re.compile(r'^Pages \|'): [
|
||||
'js/models/explicit_url', 'coffee/src/views/tabs',
|
||||
'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1'
|
||||
],
|
||||
|
||||
@@ -58,6 +58,8 @@ registry = TagRegistry()
|
||||
CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name
|
||||
CORRECTMAP_PY = None
|
||||
|
||||
# Make '_' a no-op so we can scrape strings
|
||||
_ = lambda text: text
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
@@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse):
|
||||
Javascript using Node.js.
|
||||
"""
|
||||
|
||||
human_name = _('JavaScript Input')
|
||||
tags = ['javascriptresponse']
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['javascriptinput']
|
||||
@@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse):
|
||||
|
||||
"""
|
||||
|
||||
human_name = _('Checkboxes')
|
||||
tags = ['choiceresponse']
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['checkboxgroup', 'radiogroup']
|
||||
@@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
"""
|
||||
# TODO: handle direction and randomize
|
||||
|
||||
human_name = _('Multiple Choice')
|
||||
tags = ['multiplechoiceresponse']
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['choicegroup']
|
||||
@@ -1042,6 +1047,7 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
@registry.register
|
||||
class TrueFalseResponse(MultipleChoiceResponse):
|
||||
|
||||
human_name = _('True/False Choice')
|
||||
tags = ['truefalseresponse']
|
||||
|
||||
def mc_setup_response(self):
|
||||
@@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse):
|
||||
TODO: handle direction and randomize
|
||||
"""
|
||||
|
||||
human_name = _('Dropdown')
|
||||
tags = ['optionresponse']
|
||||
hint_tag = 'optionhint'
|
||||
allowed_inputfields = ['optioninput']
|
||||
@@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse):
|
||||
to a number (e.g. `4+5/2^2`), and accepts with a tolerance.
|
||||
"""
|
||||
|
||||
human_name = _('Numerical Input')
|
||||
tags = ['numericalresponse']
|
||||
hint_tag = 'numericalhint'
|
||||
allowed_inputfields = ['textline', 'formulaequationinput']
|
||||
@@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse):
|
||||
</hintgroup>
|
||||
</stringresponse>
|
||||
"""
|
||||
human_name = _('Text Input')
|
||||
tags = ['stringresponse']
|
||||
hint_tag = 'stringhint'
|
||||
allowed_inputfields = ['textline']
|
||||
@@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse):
|
||||
or in a <script>...</script>
|
||||
"""
|
||||
|
||||
human_name = _('Custom Evaluated Script')
|
||||
tags = ['customresponse']
|
||||
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography',
|
||||
@@ -1800,6 +1810,7 @@ class SymbolicResponse(CustomResponse):
|
||||
Symbolic math response checking, using symmath library.
|
||||
"""
|
||||
|
||||
human_name = _('Symbolic Math Input')
|
||||
tags = ['symbolicresponse']
|
||||
max_inputfields = 1
|
||||
|
||||
@@ -1868,6 +1879,7 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
"""
|
||||
|
||||
human_name = _('Code Input')
|
||||
tags = ['coderesponse']
|
||||
allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
|
||||
max_inputfields = 1
|
||||
@@ -2145,6 +2157,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
|
||||
"""
|
||||
|
||||
human_name = _('External Grader')
|
||||
tags = ['externalresponse']
|
||||
allowed_inputfields = ['textline', 'textbox']
|
||||
awdmap = {
|
||||
@@ -2302,6 +2315,7 @@ class FormulaResponse(LoncapaResponse):
|
||||
Checking of symbolic math response using numerical sampling.
|
||||
"""
|
||||
|
||||
human_name = _('Math Expression Input')
|
||||
tags = ['formularesponse']
|
||||
hint_tag = 'formulahint'
|
||||
allowed_inputfields = ['textline', 'formulaequationinput']
|
||||
@@ -2514,6 +2528,7 @@ class SchematicResponse(LoncapaResponse):
|
||||
"""
|
||||
Circuit schematic response type.
|
||||
"""
|
||||
human_name = _('Circuit Schematic Builder')
|
||||
tags = ['schematicresponse']
|
||||
allowed_inputfields = ['schematic']
|
||||
|
||||
@@ -2592,6 +2607,7 @@ class ImageResponse(LoncapaResponse):
|
||||
True, if click is inside any region or rectangle. Otherwise False.
|
||||
"""
|
||||
|
||||
human_name = _('Image Mapped Input')
|
||||
tags = ['imageresponse']
|
||||
allowed_inputfields = ['imageinput']
|
||||
|
||||
@@ -2710,6 +2726,7 @@ class AnnotationResponse(LoncapaResponse):
|
||||
The response contains both a comment (student commentary) and an option (student tag).
|
||||
Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
|
||||
"""
|
||||
human_name = _('Annotation Input')
|
||||
tags = ['annotationresponse']
|
||||
allowed_inputfields = ['annotationinput']
|
||||
max_inputfields = 1
|
||||
@@ -2834,6 +2851,7 @@ class ChoiceTextResponse(LoncapaResponse):
|
||||
ChoiceResponse.
|
||||
"""
|
||||
|
||||
human_name = _('Checkboxes With Text Input')
|
||||
tags = ['choicetextresponse']
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['choicetextgroup',
|
||||
|
||||
@@ -11,6 +11,7 @@ XMODULES = [
|
||||
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"html = xmodule.html_module:HtmlDescriptor",
|
||||
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
|
||||
"poll_question = xmodule.poll_module:PollDescriptor",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_base import CapaMixin, CapaFields, ComplexEncoder
|
||||
from capa import responsetypes
|
||||
from .progress import Progress
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
@@ -172,6 +174,13 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@property
|
||||
def problem_types(self):
|
||||
""" Low-level problem type introspection for content libraries filtering by problem type """
|
||||
tree = etree.XML(self.data) # pylint: disable=no-member
|
||||
registered_tags = responsetypes.registry.registered_tags()
|
||||
return set([node.tag for node in tree.iter() if node.tag in registered_tags])
|
||||
|
||||
# Proxy to CapaModule for access to any of its attributes
|
||||
answer_available = module_attr('answer_available')
|
||||
check_button_name = module_attr('check_button_name')
|
||||
|
||||
534
common/lib/xmodule/xmodule/library_content_module.py
Normal file
534
common/lib/xmodule/xmodule/library_content_module.py
Normal file
@@ -0,0 +1,534 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LibraryContent: The XBlock used to include blocks from a library in a course.
|
||||
"""
|
||||
from bson.objectid import ObjectId, InvalidId
|
||||
from collections import namedtuple
|
||||
from copy import copy
|
||||
from capa.responsetypes import registry
|
||||
from gettext import ngettext
|
||||
|
||||
from .mako_module import MakoModuleDescriptor
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
import random
|
||||
from webob import Response
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, String, List, Integer, Boolean
|
||||
from xblock.fragment import Fragment
|
||||
from xmodule.validation import StudioValidationMessage, StudioValidation
|
||||
from xmodule.x_module import XModule, STUDENT_VIEW
|
||||
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
|
||||
from .xml_module import XmlDescriptor
|
||||
from pkg_resources import resource_string # pylint: disable=no-name-in-module
|
||||
|
||||
|
||||
# Make '_' a no-op so we can scrape strings
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
ANY_CAPA_TYPE_VALUE = 'any'
|
||||
|
||||
|
||||
def enum(**enums):
|
||||
""" enum helper in lieu of enum34 """
|
||||
return type('Enum', (), enums)
|
||||
|
||||
|
||||
def _get_human_name(problem_class):
|
||||
"""
|
||||
Get the human-friendly name for a problem type.
|
||||
"""
|
||||
return getattr(problem_class, 'human_name', problem_class.__name__)
|
||||
|
||||
|
||||
def _get_capa_types():
|
||||
"""
|
||||
Gets capa types tags and labels
|
||||
"""
|
||||
capa_types = {tag: _get_human_name(registry.get_class_for_tag(tag)) for tag in registry.registered_tags()}
|
||||
|
||||
return [{'value': ANY_CAPA_TYPE_VALUE, 'display_name': _('Any Type')}] + sorted([
|
||||
{'value': capa_type, 'display_name': caption}
|
||||
for capa_type, caption in capa_types.items()
|
||||
], key=lambda item: item.get('display_name'))
|
||||
|
||||
|
||||
class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")):
|
||||
"""
|
||||
A reference to a specific library, with an optional version.
|
||||
The version is used to find out when the LibraryContentXBlock was last
|
||||
updated with the latest content from the library.
|
||||
|
||||
library_id is a LibraryLocator
|
||||
version is an ObjectId or None
|
||||
"""
|
||||
def __new__(cls, library_id, version=None):
|
||||
# pylint: disable=super-on-old-class
|
||||
if not isinstance(library_id, LibraryLocator):
|
||||
library_id = LibraryLocator.from_string(library_id)
|
||||
if library_id.version_guid:
|
||||
assert (version is None) or (version == library_id.version_guid)
|
||||
if not version:
|
||||
version = library_id.version_guid
|
||||
library_id = library_id.for_version(None)
|
||||
if version and not isinstance(version, ObjectId):
|
||||
try:
|
||||
version = ObjectId(version)
|
||||
except InvalidId:
|
||||
raise ValueError(version)
|
||||
return super(LibraryVersionReference, cls).__new__(cls, library_id, version)
|
||||
|
||||
@staticmethod
|
||||
def from_json(value):
|
||||
"""
|
||||
Implement from_json to convert from JSON
|
||||
"""
|
||||
return LibraryVersionReference(*value)
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Implement to_json to convert value to JSON
|
||||
"""
|
||||
# TODO: Is there anyway for an xblock to *store* an ObjectId as
|
||||
# part of the List() field value?
|
||||
return [unicode(self.library_id), unicode(self.version) if self.version else None] # pylint: disable=no-member
|
||||
|
||||
|
||||
class LibraryList(List):
|
||||
"""
|
||||
Special List class for listing references to content libraries.
|
||||
Is simply a list of LibraryVersionReference tuples.
|
||||
"""
|
||||
def from_json(self, values):
|
||||
"""
|
||||
Implement from_json to convert from JSON.
|
||||
|
||||
values might be a list of lists, or a list of strings
|
||||
Normally the runtime gives us:
|
||||
[[u'library-v1:ProblemX+PR0B', '5436ffec56c02c13806a4c1b'], ...]
|
||||
But the studio editor gives us:
|
||||
[u'library-v1:ProblemX+PR0B,5436ffec56c02c13806a4c1b', ...]
|
||||
"""
|
||||
def parse(val):
|
||||
""" Convert this list entry from its JSON representation """
|
||||
if isinstance(val, basestring):
|
||||
val = val.strip(' []')
|
||||
parts = val.rsplit(',', 1)
|
||||
val = [parts[0], parts[1] if len(parts) > 1 else None]
|
||||
try:
|
||||
return LibraryVersionReference.from_json(val)
|
||||
except InvalidKeyError:
|
||||
try:
|
||||
friendly_val = val[0] # Just get the library key part, not the version
|
||||
except IndexError:
|
||||
friendly_val = unicode(val)
|
||||
raise ValueError(_('"{value}" is not a valid library ID.').format(value=friendly_val))
|
||||
return [parse(v) for v in values]
|
||||
|
||||
def to_json(self, values):
|
||||
"""
|
||||
Implement to_json to convert value to JSON
|
||||
"""
|
||||
return [lvr.to_json() for lvr in values]
|
||||
|
||||
|
||||
class LibraryContentFields(object):
|
||||
"""
|
||||
Fields for the LibraryContentModule.
|
||||
|
||||
Separated out for now because they need to be added to the module and the
|
||||
descriptor.
|
||||
"""
|
||||
# Please note the display_name of each field below is used in
|
||||
# common/test/acceptance/pages/studio/overview.py:StudioLibraryContentXBlockEditModal
|
||||
# to locate input elements - keep synchronized
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("Display name for this module"),
|
||||
default="Randomized Content Block",
|
||||
scope=Scope.settings,
|
||||
)
|
||||
source_libraries = LibraryList(
|
||||
display_name=_("Libraries"),
|
||||
help=_("Enter a library ID for each library from which you want to draw content."),
|
||||
default=[],
|
||||
scope=Scope.settings,
|
||||
)
|
||||
mode = String(
|
||||
display_name=_("Mode"),
|
||||
help=_("Determines how content is drawn from the library"),
|
||||
default="random",
|
||||
values=[
|
||||
{"display_name": _("Choose n at random"), "value": "random"}
|
||||
# Future addition: Choose a new random set of n every time the student refreshes the block, for self tests
|
||||
# Future addition: manually selected blocks
|
||||
],
|
||||
scope=Scope.settings,
|
||||
)
|
||||
max_count = Integer(
|
||||
display_name=_("Count"),
|
||||
help=_("Enter the number of components to display to each student."),
|
||||
default=1,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
capa_type = String(
|
||||
display_name=_("Problem Type"),
|
||||
help=_('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.'),
|
||||
default=ANY_CAPA_TYPE_VALUE,
|
||||
values=_get_capa_types(),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
filters = String(default="") # TBD
|
||||
has_score = Boolean(
|
||||
display_name=_("Scored"),
|
||||
help=_("Set this value to True if this module is either a graded assignment or a practice problem."),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
selected = List(
|
||||
# This is a list of (block_type, block_id) tuples used to record
|
||||
# which random/first set of matching blocks was selected per user
|
||||
default=[],
|
||||
scope=Scope.user_state,
|
||||
)
|
||||
has_children = True
|
||||
|
||||
|
||||
#pylint: disable=abstract-method
|
||||
@XBlock.wants('library_tools') # Only needed in studio
|
||||
class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
|
||||
"""
|
||||
An XBlock whose children are chosen dynamically from a content library.
|
||||
Can be used to create randomized assessments among other things.
|
||||
|
||||
Note: technically, all matching blocks from the content library are added
|
||||
as children of this block, but only a subset of those children are shown to
|
||||
any particular student.
|
||||
"""
|
||||
def selected_children(self):
|
||||
"""
|
||||
Returns a set() of block_ids indicating which of the possible children
|
||||
have been selected to display to the current user.
|
||||
|
||||
This reads and updates the "selected" field, which has user_state scope.
|
||||
|
||||
Note: self.selected and the return value contain block_ids. To get
|
||||
actual BlockUsageLocators, it is necessary to use self.children,
|
||||
because the block_ids alone do not specify the block type.
|
||||
"""
|
||||
if hasattr(self, "_selected_set"):
|
||||
# Already done:
|
||||
return self._selected_set # pylint: disable=access-member-before-definition
|
||||
|
||||
selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples assigned to this student
|
||||
previous_count = len(selected)
|
||||
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
format_block_keys = lambda keys: lib_tools.create_block_analytics_summary(self.location.course_key, keys)
|
||||
|
||||
def publish_event(event_name, **kwargs):
|
||||
""" Publish an event for analytics purposes """
|
||||
event_data = {
|
||||
"location": unicode(self.location),
|
||||
"result": format_block_keys(selected),
|
||||
"previous_count": previous_count,
|
||||
"max_count": self.max_count,
|
||||
}
|
||||
event_data.update(kwargs)
|
||||
self.runtime.publish(self, "edx.librarycontentblock.content.{}".format(event_name), event_data)
|
||||
|
||||
# Determine which of our children we will show:
|
||||
valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member
|
||||
# Remove any selected blocks that are no longer valid:
|
||||
invalid_block_keys = (selected - valid_block_keys)
|
||||
if invalid_block_keys:
|
||||
selected -= invalid_block_keys
|
||||
# Publish an event for analytics purposes:
|
||||
# reason "invalid" means deleted from library or a different library is now being used.
|
||||
publish_event("removed", removed=format_block_keys(invalid_block_keys), reason="invalid")
|
||||
# If max_count has been decreased, we may have to drop some previously selected blocks:
|
||||
overlimit_block_keys = set()
|
||||
while len(selected) > self.max_count:
|
||||
overlimit_block_keys.add(selected.pop())
|
||||
if overlimit_block_keys:
|
||||
# Publish an event for analytics purposes:
|
||||
publish_event("removed", removed=format_block_keys(overlimit_block_keys), reason="overlimit")
|
||||
# Do we have enough blocks now?
|
||||
num_to_add = self.max_count - len(selected)
|
||||
if num_to_add > 0:
|
||||
added_block_keys = None
|
||||
# We need to select [more] blocks to display to this user:
|
||||
pool = valid_block_keys - selected
|
||||
if self.mode == "random":
|
||||
num_to_add = min(len(pool), num_to_add)
|
||||
added_block_keys = set(random.sample(pool, num_to_add))
|
||||
# We now have the correct n random children to show for this user.
|
||||
else:
|
||||
raise NotImplementedError("Unsupported mode.")
|
||||
selected |= added_block_keys
|
||||
if added_block_keys:
|
||||
# Publish an event for analytics purposes:
|
||||
publish_event("assigned", added=format_block_keys(added_block_keys))
|
||||
# Save our selections to the user state, to ensure consistency:
|
||||
self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page.
|
||||
# Cache the results
|
||||
self._selected_set = selected # pylint: disable=attribute-defined-outside-init
|
||||
return selected
|
||||
|
||||
def _get_selected_child_blocks(self):
|
||||
"""
|
||||
Generator returning XBlock instances of the children selected for the
|
||||
current user.
|
||||
"""
|
||||
for block_type, block_id in self.selected_children():
|
||||
yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
|
||||
|
||||
def student_view(self, context):
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
child_context = {} if not context else copy(context)
|
||||
|
||||
for child in self._get_selected_child_blocks():
|
||||
for displayable in child.displayable_items():
|
||||
rendered_child = displayable.render(STUDENT_VIEW, child_context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
contents.append({
|
||||
'id': displayable.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 validate(self):
|
||||
"""
|
||||
Validates the state of this Library Content Module Instance.
|
||||
"""
|
||||
return self.descriptor.validate()
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio views.
|
||||
Normal studio view: If block is properly configured, displays library status summary
|
||||
Studio container view: displays a preview of all possible children.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.location == self.location
|
||||
|
||||
if is_root:
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
if self.children: # pylint: disable=no-member
|
||||
fragment.add_content(self.system.render_template("library-block-author-preview-header.html", {
|
||||
'max_count': self.max_count,
|
||||
'display_name': self.display_name or self.url_name,
|
||||
}))
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
# else: When shown on a unit page, don't show any sort of preview -
|
||||
# just the status of this block in the validation area.
|
||||
|
||||
# The following JS is used to make the "Update now" button work on the unit page and the container view:
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
|
||||
fragment.initialize_js('LibraryContentAuthorView')
|
||||
return fragment
|
||||
|
||||
def get_child_descriptors(self):
|
||||
"""
|
||||
Return only the subset of our children relevant to the current student.
|
||||
"""
|
||||
return list(self._get_selected_child_blocks())
|
||||
|
||||
|
||||
@XBlock.wants('user')
|
||||
@XBlock.wants('library_tools') # Only needed in studio
|
||||
@XBlock.wants('studio_user_permissions') # Only available in studio
|
||||
class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor):
|
||||
"""
|
||||
Descriptor class for LibraryContentModule XBlock.
|
||||
"""
|
||||
module_class = LibraryContentModule
|
||||
mako_template = 'widgets/metadata-edit.html'
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
|
||||
js_module_name = "VerticalDescriptor"
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(LibraryContentDescriptor, self).non_editable_metadata_fields
|
||||
# The only supported mode is currently 'random'.
|
||||
# Add the mode field to non_editable_metadata_fields so that it doesn't
|
||||
# render in the edit form.
|
||||
non_editable_fields.append(LibraryContentFields.mode)
|
||||
return non_editable_fields
|
||||
|
||||
@XBlock.handler
|
||||
def refresh_children(self, request=None, suffix=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Refresh children:
|
||||
This method is to be used when any of the libraries that this block
|
||||
references have been updated. It will re-fetch all matching blocks from
|
||||
the libraries, and copy them as children of this block. The children
|
||||
will be given new block_ids, but the definition ID used should be the
|
||||
exact same definition ID used in the library.
|
||||
|
||||
This method will update this block's 'source_libraries' field to store
|
||||
the version number of the libraries used, so we easily determine if
|
||||
this block is up to date or not.
|
||||
"""
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
user_service = self.runtime.service(self, 'user')
|
||||
user_perms = self.runtime.service(self, 'studio_user_permissions')
|
||||
user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures
|
||||
lib_tools.update_children(self, user_id, user_perms)
|
||||
return Response()
|
||||
|
||||
def _validate_library_version(self, validation, lib_tools, version, library_key):
|
||||
"""
|
||||
Validates library version
|
||||
"""
|
||||
latest_version = lib_tools.get_library_version(library_key)
|
||||
if latest_version is not None:
|
||||
if version is None or version != latest_version:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
_(u'This component is out of date. The library has new content.'),
|
||||
# TODO: change this to action_runtime_event='...' once the unit page supports that feature.
|
||||
# See https://openedx.atlassian.net/browse/TNL-993
|
||||
action_class='library-update-btn',
|
||||
# Translators: {refresh_icon} placeholder is substituted to "↻" (without double quotes)
|
||||
action_label=_(u"{refresh_icon} Update now.").format(refresh_icon=u"↻")
|
||||
)
|
||||
)
|
||||
return False
|
||||
else:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.ERROR,
|
||||
_(u'Library is invalid, corrupt, or has been deleted.'),
|
||||
action_class='edit-button',
|
||||
action_label=_(u"Edit Library List.")
|
||||
)
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _set_validation_error_if_empty(self, validation, summary):
|
||||
""" Helper method to only set validation summary if it's empty """
|
||||
if validation.empty:
|
||||
validation.set_summary(summary)
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the state of this Library Content Module Instance. This
|
||||
is the override of the general XBlock method, and it will also ask
|
||||
its superclass to validate.
|
||||
"""
|
||||
validation = super(LibraryContentDescriptor, self).validate()
|
||||
if not isinstance(validation, StudioValidation):
|
||||
validation = StudioValidation.copy(validation)
|
||||
if not self.source_libraries:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.NOT_CONFIGURED,
|
||||
_(u"A library has not yet been selected."),
|
||||
action_class='edit-button',
|
||||
action_label=_(u"Select a Library.")
|
||||
)
|
||||
)
|
||||
return validation
|
||||
lib_tools = self.runtime.service(self, 'library_tools')
|
||||
for library_key, version in self.source_libraries:
|
||||
if not self._validate_library_version(validation, lib_tools, version, library_key):
|
||||
break
|
||||
|
||||
# Note: we assume refresh_children() has been called
|
||||
# since the last time fields like source_libraries or capa_types were changed.
|
||||
matching_children_count = len(self.children) # pylint: disable=no-member
|
||||
if matching_children_count == 0:
|
||||
self._set_validation_error_if_empty(
|
||||
validation,
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
_(u'There are no matching problem types in the specified libraries.'),
|
||||
action_class='edit-button',
|
||||
action_label=_(u"Select another problem type.")
|
||||
)
|
||||
)
|
||||
|
||||
if matching_children_count < self.max_count:
|
||||
self._set_validation_error_if_empty(
|
||||
validation,
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
(
|
||||
ngettext(
|
||||
u'The specified libraries are configured to fetch {count} problem, ',
|
||||
u'The specified libraries are configured to fetch {count} problems, ',
|
||||
self.max_count
|
||||
) +
|
||||
ngettext(
|
||||
u'but there are only {actual} matching problem.',
|
||||
u'but there are only {actual} matching problems.',
|
||||
matching_children_count
|
||||
)
|
||||
).format(count=self.max_count, actual=matching_children_count),
|
||||
action_class='edit-button',
|
||||
action_label=_(u"Edit the library configuration.")
|
||||
)
|
||||
)
|
||||
|
||||
return validation
|
||||
|
||||
def editor_saved(self, user, old_metadata, old_content):
|
||||
"""
|
||||
If source_libraries or capa_type has been edited, refresh_children automatically.
|
||||
"""
|
||||
old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', []))
|
||||
if (set(old_source_libraries) != set(self.source_libraries) or
|
||||
old_metadata.get('capa_type', ANY_CAPA_TYPE_VALUE) != self.capa_type):
|
||||
try:
|
||||
self.refresh_children()
|
||||
except ValueError:
|
||||
pass # The validation area will display an error message, no need to do anything now.
|
||||
|
||||
def has_dynamic_children(self):
|
||||
"""
|
||||
Inform the runtime that our children vary per-user.
|
||||
See get_child_descriptors() above
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_content_titles(self):
|
||||
"""
|
||||
Returns list of friendly titles for our selected children only; without
|
||||
thi, all possible children's titles would be seen in the sequence bar in
|
||||
the LMS.
|
||||
|
||||
This overwrites the get_content_titles method included in x_module by default.
|
||||
"""
|
||||
titles = []
|
||||
for child in self._xmodule.get_child_descriptors():
|
||||
titles.extend(child.get_content_titles())
|
||||
return titles
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
""" XML support not yet implemented. """
|
||||
raise NotImplementedError
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
""" XML support not yet implemented. """
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, id_generator):
|
||||
""" XML support not yet implemented. """
|
||||
raise NotImplementedError
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
""" XML support not yet implemented. """
|
||||
raise NotImplementedError
|
||||
@@ -3,10 +3,10 @@
|
||||
"""
|
||||
import logging
|
||||
|
||||
from .studio_editable import StudioEditableModule
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, String, List
|
||||
from xblock.fragment import Fragment
|
||||
from xmodule.studio_editable import StudioEditableModule
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,29 +42,53 @@ class LibraryRoot(XBlock):
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view, which supports drag and drop.
|
||||
Renders the Studio preview view.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=True)
|
||||
return fragment
|
||||
|
||||
def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument
|
||||
"""
|
||||
Renders the children of the module with HTML appropriate for Studio. Reordering is not supported.
|
||||
"""
|
||||
contents = []
|
||||
|
||||
for child_key in self.children: # pylint: disable=E1101
|
||||
context['reorderable_items'].add(child_key)
|
||||
paging = context.get('paging', None)
|
||||
|
||||
children_count = len(self.children) # pylint: disable=no-member
|
||||
item_start, item_end = 0, children_count
|
||||
|
||||
# TODO sort children
|
||||
if paging:
|
||||
page_number = paging.get('page_number', 0)
|
||||
raw_page_size = paging.get('page_size', None)
|
||||
page_size = raw_page_size if raw_page_size is not None else children_count
|
||||
item_start, item_end = page_size * page_number, page_size * (page_number + 1)
|
||||
|
||||
children_to_show = self.children[item_start:item_end] # pylint: disable=no-member
|
||||
|
||||
for child_key in children_to_show: # pylint: disable=E1101
|
||||
child = self.runtime.get_block(child_key)
|
||||
rendered_child = self.runtime.render_child(child, StudioEditableModule.get_preview_view_name(child), context)
|
||||
child_view_name = StudioEditableModule.get_preview_view_name(child)
|
||||
rendered_child = self.runtime.render_child(child, child_view_name, context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'id': unicode(child_key),
|
||||
'id': unicode(child.location),
|
||||
'content': rendered_child.content,
|
||||
})
|
||||
|
||||
fragment.add_content(self.runtime.render_template("studio_render_children_view.html", {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
'can_add': True,
|
||||
'can_reorder': True,
|
||||
}))
|
||||
return fragment
|
||||
fragment.add_content(
|
||||
self.runtime.render_template("studio_render_paged_children_view.html", {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
'can_add': can_add,
|
||||
'first_displayed': item_start,
|
||||
'total_children': children_count,
|
||||
'displayed_children': len(children_to_show)
|
||||
})
|
||||
)
|
||||
|
||||
@property
|
||||
def display_org_with_default(self):
|
||||
|
||||
135
common/lib/xmodule/xmodule/library_tools.py
Normal file
135
common/lib/xmodule/xmodule/library_tools.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
XBlock runtime services for LibraryContentModule
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from xmodule.library_content_module import LibraryVersionReference, ANY_CAPA_TYPE_VALUE
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
|
||||
|
||||
class LibraryToolsService(object):
|
||||
"""
|
||||
Service that allows LibraryContentModule to interact with libraries in the
|
||||
modulestore.
|
||||
"""
|
||||
def __init__(self, modulestore):
|
||||
self.store = modulestore
|
||||
|
||||
def _get_library(self, library_key):
|
||||
"""
|
||||
Given a library key like "library-v1:ProblemX+PR0B", return the
|
||||
'library' XBlock with meta-information about the library.
|
||||
|
||||
Returns None on error.
|
||||
"""
|
||||
if not isinstance(library_key, LibraryLocator):
|
||||
library_key = LibraryLocator.from_string(library_key)
|
||||
assert library_key.version_guid is None
|
||||
|
||||
try:
|
||||
return self.store.get_library(library_key, remove_version=False, remove_branch=False)
|
||||
except ItemNotFoundError:
|
||||
return None
|
||||
|
||||
def get_library_version(self, lib_key):
|
||||
"""
|
||||
Get the version (an ObjectID) of the given library.
|
||||
Returns None if the library does not exist.
|
||||
"""
|
||||
library = self._get_library(lib_key)
|
||||
if library:
|
||||
# We need to know the library's version so ensure it's set in library.location.library_key.version_guid
|
||||
assert library.location.library_key.version_guid is not None
|
||||
return library.location.library_key.version_guid
|
||||
return None
|
||||
|
||||
def create_block_analytics_summary(self, course_key, block_keys):
|
||||
"""
|
||||
Given a CourseKey and a list of (block_type, block_id) pairs,
|
||||
prepare the JSON-ready metadata needed for analytics logging.
|
||||
|
||||
This is [
|
||||
{"usage_key": x, "original_usage_key": y, "original_usage_version": z, "descendants": [...]}
|
||||
]
|
||||
where the main list contains all top-level blocks, and descendants contains a *flat* list of all
|
||||
descendants of the top level blocks, if any.
|
||||
"""
|
||||
def summarize_block(usage_key):
|
||||
""" Basic information about the given block """
|
||||
orig_key, orig_version = self.store.get_block_original_usage(usage_key)
|
||||
return {
|
||||
"usage_key": unicode(usage_key),
|
||||
"original_usage_key": unicode(orig_key) if orig_key else None,
|
||||
"original_usage_version": unicode(orig_version) if orig_version else None,
|
||||
}
|
||||
|
||||
result_json = []
|
||||
for block_key in block_keys:
|
||||
key = course_key.make_usage_key(*block_key)
|
||||
info = summarize_block(key)
|
||||
info['descendants'] = []
|
||||
try:
|
||||
block = self.store.get_item(key, depth=None) # Load the item and all descendants
|
||||
children = list(getattr(block, "children", []))
|
||||
while children:
|
||||
child_key = children.pop()
|
||||
child = self.store.get_item(child_key)
|
||||
info['descendants'].append(summarize_block(child_key))
|
||||
children.extend(getattr(child, "children", []))
|
||||
except ItemNotFoundError:
|
||||
pass # The block has been deleted
|
||||
result_json.append(info)
|
||||
return result_json
|
||||
|
||||
def _filter_child(self, usage_key, capa_type):
|
||||
"""
|
||||
Filters children by CAPA problem type, if configured
|
||||
"""
|
||||
if capa_type == ANY_CAPA_TYPE_VALUE:
|
||||
return True
|
||||
|
||||
if usage_key.block_type != "problem":
|
||||
return False
|
||||
|
||||
descriptor = self.store.get_item(usage_key, depth=0)
|
||||
assert isinstance(descriptor, CapaDescriptor)
|
||||
return capa_type in descriptor.problem_types
|
||||
|
||||
def update_children(self, dest_block, user_id, user_perms=None):
|
||||
"""
|
||||
This method is to be used when any of the libraries that a LibraryContentModule
|
||||
references have been updated. It will re-fetch all matching blocks from
|
||||
the libraries, and copy them as children of dest_block. The children
|
||||
will be given new block_ids, but the definition ID used should be the
|
||||
exact same definition ID used in the library.
|
||||
|
||||
This method will update dest_block's 'source_libraries' field to store
|
||||
the version number of the libraries used, so we easily determine if
|
||||
dest_block is up to date or not.
|
||||
"""
|
||||
if user_perms and not user_perms.can_write(dest_block.location.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
new_libraries = []
|
||||
source_blocks = []
|
||||
for library_key, __ in dest_block.source_libraries:
|
||||
library = self._get_library(library_key)
|
||||
if library is None:
|
||||
raise ValueError("Required library not found.")
|
||||
if user_perms and not user_perms.can_read(library_key):
|
||||
raise PermissionDenied()
|
||||
filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
|
||||
if filter_children:
|
||||
# Apply simple filtering based on CAPA problem types:
|
||||
source_blocks.extend([key for key in library.children if self._filter_child(key, dest_block.capa_type)])
|
||||
else:
|
||||
source_blocks.extend(library.children)
|
||||
new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid))
|
||||
|
||||
with self.store.bulk_operations(dest_block.location.course_key):
|
||||
dest_block.source_libraries = new_libraries
|
||||
self.store.update_item(dest_block, user_id)
|
||||
dest_block.children = self.store.copy_from_template(source_blocks, dest_block.location, user_id)
|
||||
# ^-- copy_from_template updates the children in the DB
|
||||
# but we must also set .children here to avoid overwriting the DB again
|
||||
@@ -211,8 +211,8 @@ def inherit_metadata(descriptor, inherited_data):
|
||||
|
||||
def own_metadata(module):
|
||||
"""
|
||||
Return a dictionary that contains only non-inherited field keys,
|
||||
mapped to their serialized values
|
||||
Return a JSON-friendly dictionary that contains only non-inherited field
|
||||
keys, mapped to their serialized values
|
||||
"""
|
||||
return module.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
|
||||
@@ -283,6 +283,8 @@ class InheritanceKeyValueStore(KeyValueStore):
|
||||
|
||||
def default(self, key):
|
||||
"""
|
||||
Check to see if the default should be from inheritance rather than from the field's global default
|
||||
Check to see if the default should be from inheritance. If not
|
||||
inheriting, this will raise KeyError which will cause the caller to use
|
||||
the field's global default.
|
||||
"""
|
||||
return self.inherited_settings[key.field_name]
|
||||
|
||||
@@ -509,6 +509,18 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
||||
store = self._get_modulestore_for_courseid(location.course_key)
|
||||
return store.get_parent_location(location, **kwargs)
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator from which the
|
||||
copy was inherited.
|
||||
"""
|
||||
try:
|
||||
store = self._verify_modulestore_support(usage_key.course_key, 'get_block_original_usage')
|
||||
return store.get_block_original_usage(usage_key)
|
||||
except NotImplementedError:
|
||||
return None, None
|
||||
|
||||
def get_modulestore_type(self, course_id):
|
||||
"""
|
||||
Returns a type which identifies which modulestore is servicing the given course_id.
|
||||
@@ -676,6 +688,14 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
|
||||
store = self._verify_modulestore_support(course_key, 'import_xblock')
|
||||
return store.import_xblock(user_id, course_key, block_type, block_id, fields, runtime)
|
||||
|
||||
@strip_key
|
||||
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
|
||||
"""
|
||||
See :py:meth `SplitMongoModuleStore.copy_from_template`
|
||||
"""
|
||||
store = self._verify_modulestore_support(dest_key.course_key, 'copy_from_template')
|
||||
return store.copy_from_template(source_keys, dest_key, user_id)
|
||||
|
||||
@strip_key
|
||||
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ from lazy import lazy
|
||||
from xblock.runtime import KvsFieldData
|
||||
from xblock.fields import ScopeIds
|
||||
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
@@ -71,6 +72,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
self.module_data = module_data
|
||||
self.default_class = default_class
|
||||
self.local_modules = {}
|
||||
self._services['library_tools'] = LibraryToolsService(modulestore)
|
||||
|
||||
@lazy
|
||||
@contract(returns="dict(BlockKey: BlockKey)")
|
||||
@@ -167,16 +169,17 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
if block_key is None:
|
||||
block_key = BlockKey(json_data['block_type'], LocalId())
|
||||
|
||||
convert_fields = lambda field: self.modulestore.convert_references_to_keys(
|
||||
course_key, class_, field, self.course_entry.structure['blocks'],
|
||||
)
|
||||
|
||||
if definition_id is not None and not json_data.get('definition_loaded', False):
|
||||
definition_loader = DefinitionLazyLoader(
|
||||
self.modulestore,
|
||||
course_key,
|
||||
block_key.type,
|
||||
definition_id,
|
||||
lambda fields: self.modulestore.convert_references_to_keys(
|
||||
course_key, self.load_block_type(block_key.type),
|
||||
fields, self.course_entry.structure['blocks'],
|
||||
)
|
||||
convert_fields,
|
||||
)
|
||||
else:
|
||||
definition_loader = None
|
||||
@@ -191,9 +194,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
block_id=block_key.id,
|
||||
)
|
||||
|
||||
converted_fields = self.modulestore.convert_references_to_keys(
|
||||
block_locator.course_key, class_, json_data.get('fields', {}), self.course_entry.structure['blocks'],
|
||||
)
|
||||
converted_fields = convert_fields(json_data.get('fields', {}))
|
||||
converted_defaults = convert_fields(json_data.get('defaults', {}))
|
||||
if block_key in self._parent_map:
|
||||
parent_key = self._parent_map[block_key]
|
||||
parent = course_key.make_usage_key(parent_key.type, parent_key.id)
|
||||
@@ -202,6 +204,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
kvs = SplitMongoKVS(
|
||||
definition_loader,
|
||||
converted_fields,
|
||||
converted_defaults,
|
||||
parent=parent,
|
||||
field_decorator=kwargs.get('field_decorator')
|
||||
)
|
||||
|
||||
@@ -255,7 +255,7 @@ class MongoConnection(object):
|
||||
"""
|
||||
Retrieve all definitions listed in `definitions`.
|
||||
"""
|
||||
return self.definitions.find({'$in': {'_id': definitions}})
|
||||
return self.definitions.find({'_id': {'$in': definitions}})
|
||||
|
||||
def insert_definition(self, definition):
|
||||
"""
|
||||
|
||||
@@ -27,6 +27,8 @@ Representation:
|
||||
**** 'definition': the db id of the record containing the content payload for this xblock
|
||||
**** 'fields': the Scope.settings and children field values
|
||||
***** 'children': This is stored as a list of (block_type, block_id) pairs
|
||||
**** 'defaults': Scope.settings default values copied from a template block (used e.g. when
|
||||
blocks are copied from a library to a course)
|
||||
**** 'edit_info': dictionary:
|
||||
***** 'edited_on': when was this xblock's fields last changed (will be edited_on value of
|
||||
update_version structure)
|
||||
@@ -53,6 +55,7 @@ Representation:
|
||||
import copy
|
||||
import threading
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
from contracts import contract, new_contract
|
||||
from importlib import import_module
|
||||
@@ -451,12 +454,17 @@ class SplitBulkWriteMixin(BulkOperationsMixin):
|
||||
if block_info['edit_info'].get('update_version') == update_version:
|
||||
return
|
||||
|
||||
original_usage = block_info['edit_info'].get('original_usage')
|
||||
original_usage_version = block_info['edit_info'].get('original_usage_version')
|
||||
block_info['edit_info'] = {
|
||||
'edited_on': datetime.datetime.now(UTC),
|
||||
'edited_by': user_id,
|
||||
'previous_version': block_info['edit_info']['update_version'],
|
||||
'update_version': update_version,
|
||||
}
|
||||
if original_usage:
|
||||
block_info['edit_info']['original_usage'] = original_usage
|
||||
block_info['edit_info']['original_usage_version'] = original_usage_version
|
||||
|
||||
def find_matching_course_indexes(self, branch=None, search_targets=None):
|
||||
"""
|
||||
@@ -670,7 +678,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
new_module_data = {}
|
||||
for block_id in base_block_ids:
|
||||
new_module_data = self.descendants(
|
||||
system.course_entry.structure['blocks'],
|
||||
# copy or our changes like setting 'definition_loaded' will affect the active bulk operation data
|
||||
copy.deepcopy(system.course_entry.structure['blocks']),
|
||||
block_id,
|
||||
depth,
|
||||
new_module_data
|
||||
@@ -691,12 +700,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
|
||||
for block in new_module_data.itervalues():
|
||||
if block['definition'] in definitions:
|
||||
converted_fields = self.convert_references_to_keys(
|
||||
course_key, system.load_block_type(block['block_type']),
|
||||
definitions[block['definition']].get('fields'),
|
||||
system.course_entry.structure['blocks'],
|
||||
)
|
||||
block['fields'].update(converted_fields)
|
||||
definition = definitions[block['definition']]
|
||||
# convert_fields was being done here, but it gets done later in the runtime's xblock_from_json
|
||||
block['fields'].update(definition.get('fields'))
|
||||
block['definition_loaded'] = True
|
||||
|
||||
system.module_data.update(new_module_data)
|
||||
@@ -1255,6 +1261,21 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
# TODO implement
|
||||
pass
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator and version from
|
||||
which the copy was inherited.
|
||||
|
||||
Returns usage_key, version if the data is available, otherwise returns (None, None)
|
||||
"""
|
||||
blocks = self._lookup_course(usage_key.course_key).structure['blocks']
|
||||
block = blocks.get(BlockKey.from_usage_key(usage_key))
|
||||
if block and 'original_usage' in block['edit_info']:
|
||||
usage_key = BlockUsageLocator.from_string(block['edit_info']['original_usage'])
|
||||
return usage_key, block['edit_info'].get('original_usage_version')
|
||||
return None, None
|
||||
|
||||
def create_definition_from_data(self, course_key, new_def_data, category, user_id):
|
||||
"""
|
||||
Pull the definition fields out of descriptor and save to the db as a new definition
|
||||
@@ -2073,6 +2094,168 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
self.update_structure(destination_course, destination_structure)
|
||||
self._update_head(destination_course, index_entry, destination_course.branch, destination_structure['_id'])
|
||||
|
||||
@contract(source_keys="list(BlockUsageLocator)", dest_usage=BlockUsageLocator)
|
||||
def copy_from_template(self, source_keys, dest_usage, user_id):
|
||||
"""
|
||||
Flexible mechanism for inheriting content from an external course/library/etc.
|
||||
|
||||
Will copy all of the XBlocks whose keys are passed as `source_course` so that they become
|
||||
children of the XBlock whose key is `dest_usage`. Any previously existing children of
|
||||
`dest_usage` that haven't been replaced/updated by this copy_from_template operation will
|
||||
be deleted.
|
||||
|
||||
Unlike `copy()`, this does not care whether the resulting blocks are positioned similarly
|
||||
in their new course/library. However, the resulting blocks will be in the same relative
|
||||
order as `source_keys`.
|
||||
|
||||
If any of the blocks specified already exist as children of the destination block, they
|
||||
will be updated rather than duplicated or replaced. If they have Scope.settings field values
|
||||
overriding inherited default values, those overrides will be preserved.
|
||||
|
||||
IMPORTANT: This method does not preserve block_id - in other words, every block that is
|
||||
copied will be assigned a new block_id. This is because we assume that the same source block
|
||||
may be copied into one course in multiple places. However, it *is* guaranteed that every
|
||||
time this method is called for the same source block and dest_usage, the same resulting
|
||||
block id will be generated.
|
||||
|
||||
:param source_keys: a list of BlockUsageLocators. Order is preserved.
|
||||
|
||||
:param dest_usage: The BlockUsageLocator that will become the parent of an inherited copy
|
||||
of all the xblocks passed in `source_keys`.
|
||||
|
||||
:param user_id: The user who will get credit for making this change.
|
||||
"""
|
||||
# Preload the block structures for all source courses/libraries/etc.
|
||||
# so that we can access descendant information quickly
|
||||
source_structures = {}
|
||||
for key in source_keys:
|
||||
course_key = key.course_key.for_version(None)
|
||||
if course_key.branch is None:
|
||||
raise ItemNotFoundError("branch is required for all source keys when using copy_from_template")
|
||||
if course_key not in source_structures:
|
||||
with self.bulk_operations(course_key):
|
||||
source_structures[course_key] = self._lookup_course(course_key).structure
|
||||
|
||||
destination_course = dest_usage.course_key
|
||||
with self.bulk_operations(destination_course):
|
||||
index_entry = self.get_course_index(destination_course)
|
||||
if index_entry is None:
|
||||
raise ItemNotFoundError(destination_course)
|
||||
dest_structure = self._lookup_course(destination_course).structure
|
||||
old_dest_structure_version = dest_structure['_id']
|
||||
dest_structure = self.version_structure(destination_course, dest_structure, user_id)
|
||||
|
||||
# Set of all descendent block IDs of dest_usage that are to be replaced:
|
||||
block_key = BlockKey(dest_usage.block_type, dest_usage.block_id)
|
||||
orig_descendants = set(self.descendants(dest_structure['blocks'], block_key, depth=None, descendent_map={}))
|
||||
# The descendants() method used above adds the block itself, which we don't consider a descendant.
|
||||
orig_descendants.remove(block_key)
|
||||
new_descendants = self._copy_from_template(
|
||||
source_structures, source_keys, dest_structure, block_key, user_id
|
||||
)
|
||||
|
||||
# Update the edit info:
|
||||
dest_info = dest_structure['blocks'][block_key]
|
||||
|
||||
# Update the edit_info:
|
||||
dest_info['edit_info']['previous_version'] = dest_info['edit_info']['update_version']
|
||||
dest_info['edit_info']['update_version'] = old_dest_structure_version
|
||||
dest_info['edit_info']['edited_by'] = user_id
|
||||
dest_info['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
|
||||
orphans = orig_descendants - new_descendants
|
||||
for orphan in orphans:
|
||||
del dest_structure['blocks'][orphan]
|
||||
|
||||
self.update_structure(destination_course, dest_structure)
|
||||
self._update_head(destination_course, index_entry, destination_course.branch, dest_structure['_id'])
|
||||
# Return usage locators for all the new children:
|
||||
return [
|
||||
destination_course.make_usage_key(*k)
|
||||
for k in dest_structure['blocks'][block_key]['fields']['children']
|
||||
]
|
||||
|
||||
def _copy_from_template(self, source_structures, source_keys, dest_structure, new_parent_block_key, user_id):
|
||||
"""
|
||||
Internal recursive implementation of copy_from_template()
|
||||
|
||||
Returns the new set of BlockKeys that are the new descendants of the block with key 'block_key'
|
||||
"""
|
||||
# pylint: disable=no-member
|
||||
# ^-- Until pylint gets namedtuple support, it will give warnings about BlockKey attributes
|
||||
new_blocks = set()
|
||||
|
||||
new_children = list() # ordered list of the new children of new_parent_block_key
|
||||
|
||||
for usage_key in source_keys:
|
||||
src_course_key = usage_key.course_key.for_version(None)
|
||||
block_key = BlockKey(usage_key.block_type, usage_key.block_id)
|
||||
source_structure = source_structures.get(src_course_key, [])
|
||||
if block_key not in source_structure['blocks']:
|
||||
raise ItemNotFoundError(usage_key)
|
||||
source_block_info = source_structure['blocks'][block_key]
|
||||
|
||||
# Compute a new block ID. This new block ID must be consistent when this
|
||||
# method is called with the same (source_key, dest_structure) pair
|
||||
unique_data = "{}:{}:{}".format(
|
||||
unicode(src_course_key).encode("utf-8"),
|
||||
block_key.id,
|
||||
new_parent_block_key.id,
|
||||
)
|
||||
new_block_id = hashlib.sha1(unique_data).hexdigest()[:20]
|
||||
new_block_key = BlockKey(block_key.type, new_block_id)
|
||||
|
||||
# Now clone block_key to new_block_key:
|
||||
new_block_info = copy.deepcopy(source_block_info)
|
||||
# Note that new_block_info now points to the same definition ID entry as source_block_info did
|
||||
existing_block_info = dest_structure['blocks'].get(new_block_key, {})
|
||||
# Inherit the Scope.settings values from 'fields' to 'defaults'
|
||||
new_block_info['defaults'] = new_block_info['fields']
|
||||
|
||||
# <workaround>
|
||||
# CAPA modules store their 'markdown' value (an alternate representation of their content)
|
||||
# in Scope.settings rather than Scope.content :-/
|
||||
# markdown is a field that really should not be overridable - it fundamentally changes the content.
|
||||
# capa modules also use a custom editor that always saves their markdown field to the metadata,
|
||||
# even if it hasn't changed, which breaks our override system.
|
||||
# So until capa modules are fixed, we special-case them and remove their markdown fields,
|
||||
# forcing the inherited version to use XML only.
|
||||
if usage_key.block_type == 'problem' and 'markdown' in new_block_info['defaults']:
|
||||
del new_block_info['defaults']['markdown']
|
||||
# </workaround>
|
||||
|
||||
new_block_info['fields'] = existing_block_info.get('fields', {}) # Preserve any existing overrides
|
||||
if 'children' in new_block_info['defaults']:
|
||||
del new_block_info['defaults']['children'] # Will be set later
|
||||
new_block_info['block_id'] = new_block_key.id
|
||||
new_block_info['edit_info'] = existing_block_info.get('edit_info', {})
|
||||
new_block_info['edit_info']['previous_version'] = new_block_info['edit_info'].get('update_version', None)
|
||||
new_block_info['edit_info']['update_version'] = dest_structure['_id']
|
||||
# Note we do not set 'source_version' - it's only used for copying identical blocks
|
||||
# from draft to published as part of publishing workflow.
|
||||
# Setting it to the source_block_info structure version here breaks split_draft's has_changes() method.
|
||||
new_block_info['edit_info']['edited_by'] = user_id
|
||||
new_block_info['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
new_block_info['edit_info']['original_usage'] = unicode(usage_key.replace(branch=None, version_guid=None))
|
||||
new_block_info['edit_info']['original_usage_version'] = source_block_info['edit_info'].get('update_version')
|
||||
dest_structure['blocks'][new_block_key] = new_block_info
|
||||
|
||||
children = source_block_info['fields'].get('children')
|
||||
if children:
|
||||
children = [src_course_key.make_usage_key(child.type, child.id) for child in children]
|
||||
new_blocks |= self._copy_from_template(
|
||||
source_structures, children, dest_structure, new_block_key, user_id
|
||||
)
|
||||
|
||||
new_blocks.add(new_block_key)
|
||||
# And add new_block_key to the list of new_parent_block_key's new children:
|
||||
new_children.append(new_block_key)
|
||||
|
||||
# Update the children of new_parent_block_key
|
||||
dest_structure['blocks'][new_parent_block_key]['fields']['children'] = new_children
|
||||
|
||||
return new_blocks
|
||||
|
||||
def delete_item(self, usage_locator, user_id, force=False):
|
||||
"""
|
||||
Delete the block or tree rooted at block (if delete_children) and any references w/in the course to the block
|
||||
@@ -2702,7 +2885,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
self._filter_blacklist(copy.copy(new_block['fields']), blacklist),
|
||||
new_block['definition'],
|
||||
destination_version,
|
||||
raw=True
|
||||
raw=True,
|
||||
block_defaults=new_block.get('defaults')
|
||||
)
|
||||
|
||||
# introduce new edit info field for tracing where copied/published blocks came
|
||||
@@ -2741,7 +2925,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
self._delete_if_true_orphan(BlockKey(*child), structure)
|
||||
del structure['blocks'][orphan]
|
||||
|
||||
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False):
|
||||
def _new_block(self, user_id, category, block_fields, definition_id, new_id, raw=False, block_defaults=None):
|
||||
"""
|
||||
Create the core document structure for a block.
|
||||
|
||||
@@ -2752,7 +2936,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
"""
|
||||
if not raw:
|
||||
block_fields = self._serialize_fields(category, block_fields)
|
||||
return {
|
||||
document = {
|
||||
'block_type': category,
|
||||
'definition': definition_id,
|
||||
'fields': block_fields,
|
||||
@@ -2763,6 +2947,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
'update_version': new_id
|
||||
}
|
||||
}
|
||||
if block_defaults:
|
||||
document['defaults'] = block_defaults
|
||||
return document
|
||||
|
||||
@contract(block_key=BlockKey)
|
||||
def _get_block_from_structure(self, structure, block_key):
|
||||
|
||||
@@ -5,7 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
|
||||
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import InsufficientSpecificationError
|
||||
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
|
||||
from xmodule.modulestore.draft_and_published import (
|
||||
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
|
||||
)
|
||||
@@ -93,6 +93,27 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
|
||||
# version_agnostic b/c of above assumption in docstring
|
||||
self.publish(location.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
|
||||
|
||||
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
|
||||
"""
|
||||
See :py:meth `SplitMongoModuleStore.copy_from_template`
|
||||
"""
|
||||
source_keys = [self._map_revision_to_branch(key) for key in source_keys]
|
||||
dest_key = self._map_revision_to_branch(dest_key)
|
||||
new_keys = super(DraftVersioningModuleStore, self).copy_from_template(source_keys, dest_key, user_id)
|
||||
if dest_key.branch == ModuleStoreEnum.BranchName.draft:
|
||||
# Check if any of new_keys or their descendants need to be auto-published.
|
||||
# We don't use _auto_publish_no_children since children may need to be published.
|
||||
with self.bulk_operations(dest_key.course_key):
|
||||
keys_to_check = list(new_keys)
|
||||
while keys_to_check:
|
||||
usage_key = keys_to_check.pop()
|
||||
if usage_key.category in DIRECT_ONLY_CATEGORIES:
|
||||
self.publish(usage_key.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
|
||||
children = getattr(self.get_item(usage_key, **kwargs), "children", [])
|
||||
# e.g. if usage_key is a chapter, it may have an auto-publish sequential child
|
||||
keys_to_check.extend(children)
|
||||
return new_keys
|
||||
|
||||
def update_item(self, descriptor, user_id, allow_not_found=False, force=False, **kwargs):
|
||||
old_descriptor_locn = descriptor.location
|
||||
descriptor.location = self._map_revision_to_branch(old_descriptor_locn)
|
||||
@@ -247,6 +268,15 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
|
||||
location = self._map_revision_to_branch(location, revision=revision)
|
||||
return super(DraftVersioningModuleStore, self).get_parent_location(location, **kwargs)
|
||||
|
||||
def get_block_original_usage(self, usage_key):
|
||||
"""
|
||||
If a block was inherited into another structure using copy_from_template,
|
||||
this will return the original block usage locator from which the
|
||||
copy was inherited.
|
||||
"""
|
||||
usage_key = self._map_revision_to_branch(usage_key)
|
||||
return super(DraftVersioningModuleStore, self).get_block_original_usage(usage_key)
|
||||
|
||||
def get_orphans(self, course_key, **kwargs):
|
||||
course_key = self._map_revision_to_branch(course_key)
|
||||
return super(DraftVersioningModuleStore, self).get_orphans(course_key, **kwargs)
|
||||
@@ -421,7 +451,12 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
|
||||
pass
|
||||
|
||||
def _get_head(self, xblock, branch):
|
||||
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
|
||||
""" Gets block at the head of specified branch """
|
||||
try:
|
||||
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
|
||||
except ItemNotFoundError:
|
||||
# There is no published version xblock container, e.g. Library
|
||||
return None
|
||||
return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location))
|
||||
|
||||
def _get_version(self, block):
|
||||
|
||||
@@ -19,17 +19,20 @@ class SplitMongoKVS(InheritanceKeyValueStore):
|
||||
"""
|
||||
|
||||
@contract(parent="BlockUsageLocator | None")
|
||||
def __init__(self, definition, initial_values, parent, field_decorator=None):
|
||||
def __init__(self, definition, initial_values, default_values, parent, field_decorator=None):
|
||||
"""
|
||||
|
||||
:param definition: either a lazyloader or definition id for the definition
|
||||
:param initial_values: a dictionary of the locally set values
|
||||
:param default_values: any Scope.settings field defaults that are set locally
|
||||
(copied from a template block with copy_from_template)
|
||||
"""
|
||||
# deepcopy so that manipulations of fields does not pollute the source
|
||||
super(SplitMongoKVS, self).__init__(copy.deepcopy(initial_values))
|
||||
self._definition = definition # either a DefinitionLazyLoader or the db id of the definition.
|
||||
# if the db id, then the definition is presumed to be loaded into _fields
|
||||
|
||||
self._defaults = default_values
|
||||
# a decorator function for field values (to be called when a field is accessed)
|
||||
if field_decorator is None:
|
||||
self.field_decorator = lambda x: x
|
||||
@@ -110,6 +113,16 @@ class SplitMongoKVS(InheritanceKeyValueStore):
|
||||
# if someone changes it so that they do, then change any tests of field.name in xx._field_data
|
||||
return key.field_name in self._fields
|
||||
|
||||
def default(self, key):
|
||||
"""
|
||||
Check to see if the default should be from the template's defaults (if any)
|
||||
rather than the global default or inheritance.
|
||||
"""
|
||||
if self._defaults and key.field_name in self._defaults:
|
||||
return self._defaults[key.field_name]
|
||||
# If not, try inheriting from a parent, then use the XBlock type's normal default value:
|
||||
return super(SplitMongoKVS, self).default(key)
|
||||
|
||||
def _load_definition(self):
|
||||
"""
|
||||
Update fields w/ the lazily loaded definitions
|
||||
|
||||
@@ -6,14 +6,11 @@ Higher-level tests are in `cms/djangoapps/contentstore`.
|
||||
"""
|
||||
from bson.objectid import ObjectId
|
||||
import ddt
|
||||
from mock import patch
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.runtime import Runtime as VanillaRuntime
|
||||
|
||||
from xmodule.modulestore.exceptions import DuplicateCourseError
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory, ItemFactory, check_mongo_calls
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -179,30 +176,13 @@ class TestLibraries(MixedSplitTestCase):
|
||||
version = lib.location.library_key.version_guid
|
||||
self.assertIsInstance(version, ObjectId)
|
||||
|
||||
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render)
|
||||
def test_library_author_view(self):
|
||||
"""
|
||||
Test that LibraryRoot.author_view can run and includes content from its
|
||||
children.
|
||||
We have to patch the runtime (module system) in order to be able to
|
||||
render blocks in our test environment.
|
||||
"""
|
||||
def test_xblock_in_lib_have_published_version_returns_false(self):
|
||||
library = LibraryFactory.create(modulestore=self.store)
|
||||
# Add one HTML block to the library:
|
||||
ItemFactory.create(
|
||||
block = ItemFactory.create(
|
||||
category="html",
|
||||
parent_location=library.location,
|
||||
user_id=self.user_id,
|
||||
publish_item=False,
|
||||
modulestore=self.store,
|
||||
)
|
||||
library = self.store.get_library(library.location.library_key)
|
||||
|
||||
context = {'reorderable_items': set(), }
|
||||
# Patch the HTML block to always render "Hello world"
|
||||
message = u"Hello world"
|
||||
hello_render = lambda _, context: Fragment(message)
|
||||
with patch('xmodule.html_module.HtmlDescriptor.author_view', hello_render, create=True):
|
||||
with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []):
|
||||
result = library.render(AUTHOR_VIEW, context)
|
||||
self.assertIn(message, result.content)
|
||||
self.assertFalse(self.store.has_published_version(block))
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Tests for split's copy_from_template method.
|
||||
Currently it is only used for content libraries.
|
||||
However for these tests, we make sure it also works when copying from course to course.
|
||||
"""
|
||||
import ddt
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestSplitCopyTemplate(MixedSplitTestCase):
|
||||
"""
|
||||
Test for split's copy_from_template method.
|
||||
"""
|
||||
@ddt.data(
|
||||
LibraryFactory,
|
||||
CourseFactory,
|
||||
)
|
||||
def test_copy_from_template(self, source_type):
|
||||
"""
|
||||
Test that the behavior of copy_from_template() matches its docstring
|
||||
"""
|
||||
source_container = source_type.create(modulestore=self.store) # Either a library or a course
|
||||
course = CourseFactory.create(modulestore=self.store)
|
||||
# Add a vertical with a capa child to the source library/course:
|
||||
vertical_block = self.make_block("vertical", source_container)
|
||||
problem_library_display_name = "Problem Library Display Name"
|
||||
problem_block = self.make_block(
|
||||
"problem", vertical_block, display_name=problem_library_display_name, markdown="Problem markdown here"
|
||||
)
|
||||
|
||||
if source_type == LibraryFactory:
|
||||
source_container = self.store.get_library(
|
||||
source_container.location.library_key, remove_version=False, remove_branch=False
|
||||
)
|
||||
else:
|
||||
source_container = self.store.get_course(
|
||||
source_container.location.course_key, remove_version=False, remove_branch=False
|
||||
)
|
||||
|
||||
# Inherit the vertical and the problem from the library into the course:
|
||||
source_keys = [source_container.children[0]]
|
||||
new_blocks = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
|
||||
self.assertEqual(len(new_blocks), 1)
|
||||
|
||||
course = self.store.get_course(course.location.course_key) # Reload from modulestore
|
||||
|
||||
self.assertEqual(len(course.children), 1)
|
||||
vertical_block_course = self.store.get_item(course.children[0])
|
||||
self.assertEqual(new_blocks[0], vertical_block_course.location)
|
||||
problem_block_course = self.store.get_item(vertical_block_course.children[0])
|
||||
self.assertEqual(problem_block_course.display_name, problem_library_display_name)
|
||||
|
||||
# Check that when capa modules are copied, their "markdown" fields (Scope.settings) are removed.
|
||||
# (See note in split.py:copy_from_template())
|
||||
self.assertIsNotNone(problem_block.markdown)
|
||||
self.assertIsNone(problem_block_course.markdown)
|
||||
|
||||
# Override the display_name and weight:
|
||||
new_display_name = "The Trouble with Tribbles"
|
||||
new_weight = 20
|
||||
problem_block_course.display_name = new_display_name
|
||||
problem_block_course.weight = new_weight
|
||||
self.store.update_item(problem_block_course, self.user_id)
|
||||
|
||||
# Test that "Any previously existing children of `dest_usage`
|
||||
# that haven't been replaced/updated by this copy_from_template operation will be deleted."
|
||||
extra_block = self.make_block("html", vertical_block_course)
|
||||
|
||||
# Repeat the copy_from_template():
|
||||
new_blocks2 = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
|
||||
self.assertEqual(new_blocks, new_blocks2)
|
||||
# Reload problem_block_course:
|
||||
problem_block_course = self.store.get_item(problem_block_course.location)
|
||||
self.assertEqual(problem_block_course.display_name, new_display_name)
|
||||
self.assertEqual(problem_block_course.weight, new_weight)
|
||||
|
||||
# Ensure that extra_block was deleted:
|
||||
vertical_block_course = self.store.get_item(new_blocks2[0])
|
||||
self.assertEqual(len(vertical_block_course.children), 1)
|
||||
with self.assertRaises(ItemNotFoundError):
|
||||
self.store.get_item(extra_block.location)
|
||||
|
||||
def test_copy_from_template_publish(self):
|
||||
"""
|
||||
Test that copy_from_template's "defaults" data is not lost
|
||||
when blocks are published.
|
||||
"""
|
||||
# Create a library with a problem:
|
||||
source_library = LibraryFactory.create(modulestore=self.store)
|
||||
display_name_expected = "CUSTOM Library Display Name"
|
||||
self.make_block("problem", source_library, display_name=display_name_expected)
|
||||
# Reload source_library since we need its branch and version to use copy_from_template:
|
||||
source_library = self.store.get_library(
|
||||
source_library.location.library_key, remove_version=False, remove_branch=False
|
||||
)
|
||||
# And a course with a vertical:
|
||||
course = CourseFactory.create(modulestore=self.store)
|
||||
self.make_block("vertical", course)
|
||||
|
||||
problem_key_in_course = self.store.copy_from_template(
|
||||
source_library.children, dest_key=course.location, user_id=self.user_id
|
||||
)[0]
|
||||
|
||||
# We do the following twice because different methods get used inside
|
||||
# split modulestore on first vs. subsequent publish
|
||||
for __ in range(0, 2):
|
||||
# Publish:
|
||||
self.store.publish(problem_key_in_course, self.user_id)
|
||||
# Test that the defaults values are there.
|
||||
problem_published = self.store.get_item(
|
||||
problem_key_in_course.for_branch(ModuleStoreEnum.BranchName.published)
|
||||
)
|
||||
self.assertEqual(problem_published.display_name, display_name_expected)
|
||||
|
||||
def test_copy_from_template_auto_publish(self):
|
||||
"""
|
||||
Make sure that copy_from_template works with things like 'chapter' that
|
||||
are always auto-published.
|
||||
"""
|
||||
source_course = CourseFactory.create(modulestore=self.store)
|
||||
course = CourseFactory.create(modulestore=self.store)
|
||||
|
||||
# Populate the course:
|
||||
about = self.make_block("about", source_course)
|
||||
chapter = self.make_block("chapter", source_course)
|
||||
sequential = self.make_block("sequential", chapter)
|
||||
# And three blocks that are NOT auto-published:
|
||||
vertical = self.make_block("vertical", sequential)
|
||||
self.make_block("problem", vertical)
|
||||
html = self.make_block("html", source_course)
|
||||
|
||||
# Reload source_course since we need its branch and version to use copy_from_template:
|
||||
source_course = self.store.get_course(
|
||||
source_course.location.course_key, remove_version=False, remove_branch=False
|
||||
)
|
||||
|
||||
# Inherit the vertical and the problem from the library into the course:
|
||||
source_keys = [block.location for block in [about, chapter, html]]
|
||||
block_keys = self.store.copy_from_template(source_keys, dest_key=course.location, user_id=self.user_id)
|
||||
self.assertEqual(len(block_keys), len(source_keys))
|
||||
|
||||
# Build dict of the new blocks in 'course', keyed by category (which is a unique key in our case)
|
||||
new_blocks = {}
|
||||
block_keys = set(block_keys)
|
||||
while block_keys:
|
||||
key = block_keys.pop()
|
||||
block = self.store.get_item(key)
|
||||
new_blocks[block.category] = block
|
||||
block_keys.update(set(getattr(block, "children", [])))
|
||||
|
||||
# Check that auto-publish blocks with no children are indeed published:
|
||||
def published_version_exists(block):
|
||||
""" Does a published version of block exist? """
|
||||
try:
|
||||
self.store.get_item(block.location.for_branch(ModuleStoreEnum.BranchName.published))
|
||||
return True
|
||||
except ItemNotFoundError:
|
||||
return False
|
||||
|
||||
# Check that the auto-publish blocks have been published:
|
||||
self.assertFalse(self.store.has_changes(new_blocks["about"]))
|
||||
# We can't use has_changes because it includes descendants
|
||||
self.assertTrue(published_version_exists(new_blocks["chapter"]))
|
||||
self.assertTrue(published_version_exists(new_blocks["sequential"])) # Ditto
|
||||
# Check that non-auto-publish blocks and blocks with non-auto-publish descendants show changes:
|
||||
self.assertTrue(self.store.has_changes(new_blocks["html"]))
|
||||
self.assertTrue(self.store.has_changes(new_blocks["problem"]))
|
||||
# Will have changes since a child block has changes.
|
||||
self.assertTrue(self.store.has_changes(new_blocks["chapter"]))
|
||||
# Verify that our published_version_exists works
|
||||
self.assertFalse(published_version_exists(new_blocks["vertical"]))
|
||||
@@ -10,6 +10,7 @@ from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished
|
||||
from xmodule.modulestore.edit_info import EditInfoMixin
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
from xmodule.modulestore.mixed import MixedModuleStore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
|
||||
from xmodule.tests import DATA_DIR
|
||||
|
||||
@@ -108,3 +109,18 @@ class MixedSplitTestCase(TestCase):
|
||||
)
|
||||
self.addCleanup(self.store.close_all_connections)
|
||||
self.addCleanup(self.store._drop_database) # pylint: disable=protected-access
|
||||
|
||||
def make_block(self, category, parent_block, **kwargs):
|
||||
"""
|
||||
Create a block of type `category` as a child of `parent_block`, in any
|
||||
course or library. You can pass any field values as kwargs.
|
||||
"""
|
||||
extra = {"publish_item": False, "user_id": self.user_id}
|
||||
extra.update(kwargs)
|
||||
return ItemFactory.create(
|
||||
category=category,
|
||||
parent=parent_block,
|
||||
parent_location=parent_block.location,
|
||||
modulestore=self.store,
|
||||
**extra
|
||||
)
|
||||
|
||||
36
common/lib/xmodule/xmodule/public/js/library_content_edit.js
Normal file
36
common/lib/xmodule/xmodule/public/js/library_content_edit.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
|
||||
window.LibraryContentAuthorView = function (runtime, element) {
|
||||
"use strict";
|
||||
var $element = $(element);
|
||||
var usage_id = $element.data('usage-id');
|
||||
// The "Update Now" button is not a child of 'element', as it is in the validation message area
|
||||
// But it is still inside this xblock's wrapper element, which we can easily find:
|
||||
var $wrapper = $element.parents('*[data-locator="'+usage_id+'"]');
|
||||
|
||||
// We can't bind to the button itself because in the bok choy test environment,
|
||||
// it may not yet exist at this point in time... not sure why.
|
||||
$wrapper.on('click', '.library-update-btn', function(e) {
|
||||
e.preventDefault();
|
||||
// Update the XBlock with the latest matching content from the library:
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
element: element,
|
||||
message: gettext('Updating with latest library content')
|
||||
});
|
||||
$.post(runtime.handlerUrl(element, 'refresh_children')).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) {
|
||||
// We are on a course unit page. The notify('save') should refresh this block,
|
||||
// but that is only working on the container page view of this block.
|
||||
// Why? On the unit page, this XBlock's runtime has no reference to the
|
||||
// XBlockContainerPage - only the top-level XBlock (a vertical) runtime does.
|
||||
// But unfortunately there is no way to get a reference to our parent block's
|
||||
// JS 'runtime' object. So instead we must refresh the whole page:
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -19,10 +19,11 @@ from webob.multidict import MultiDict
|
||||
|
||||
import xmodule
|
||||
from xmodule.tests import DATA_DIR
|
||||
from capa import responsetypes
|
||||
from capa.responsetypes import (StudentInputError, LoncapaProblemError,
|
||||
ResponseError)
|
||||
from capa.xqueue_interface import XQueueInterface
|
||||
from xmodule.capa_module import CapaModule, ComplexEncoder
|
||||
from xmodule.capa_module import CapaModule, CapaDescriptor, ComplexEncoder
|
||||
from opaque_keys.edx.locations import Location
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fields import ScopeIds
|
||||
@@ -1661,6 +1662,63 @@ class CapaModuleTest(unittest.TestCase):
|
||||
self.assertEquals(event_info['success'], 'incorrect')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CapaDescriptorTest(unittest.TestCase):
|
||||
def _create_descriptor(self, xml):
|
||||
""" Creates a CapaDescriptor to run test against """
|
||||
descriptor = CapaDescriptor(get_test_system(), scope_ids=1)
|
||||
descriptor.data = xml
|
||||
return descriptor
|
||||
|
||||
@ddt.data(*responsetypes.registry.registered_tags())
|
||||
def test_all_response_types(self, response_tag):
|
||||
""" Tests that every registered response tag is correctly returned """
|
||||
xml = "<problem><{response_tag}></{response_tag}></problem>".format(response_tag=response_tag)
|
||||
descriptor = self._create_descriptor(xml)
|
||||
self.assertEquals(descriptor.problem_types, {response_tag})
|
||||
|
||||
def test_response_types_ignores_non_response_tags(self):
|
||||
xml = textwrap.dedent("""
|
||||
<problem>
|
||||
<p>Label</p>
|
||||
<div>Some comment</div>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice" answer-pool="4">
|
||||
<choice correct="false">Apple</choice>
|
||||
<choice correct="false">Banana</choice>
|
||||
<choice correct="false">Chocolate</choice>
|
||||
<choice correct ="true">Donut</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
descriptor = self._create_descriptor(xml)
|
||||
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse"})
|
||||
|
||||
def test_response_types_multiple_tags(self):
|
||||
xml = textwrap.dedent("""
|
||||
<problem>
|
||||
<p>Label</p>
|
||||
<div>Some comment</div>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice" answer-pool="1">
|
||||
<choice correct ="true">Donut</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice" answer-pool="1">
|
||||
<choice correct ="true">Buggy</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
<optionresponse>
|
||||
<optioninput label="Option" options="('1','2')" correct="2"></optioninput>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""")
|
||||
descriptor = self._create_descriptor(xml)
|
||||
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse", "optionresponse"})
|
||||
|
||||
|
||||
class ComplexEncoderTest(unittest.TestCase):
|
||||
def test_default(self):
|
||||
"""
|
||||
@@ -1690,18 +1748,10 @@ class TestProblemCheckTracking(unittest.TestCase):
|
||||
<p>Which piece of furniture is built for sitting?</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">
|
||||
<text>a table</text>
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<text>a desk</text>
|
||||
</choice>
|
||||
<choice correct="true">
|
||||
<text>a chair</text>
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
<text>a bookshelf</text>
|
||||
</choice>
|
||||
<choice correct="false"><text>a table</text></choice>
|
||||
<choice correct="false"><text>a desk</text></choice>
|
||||
<choice correct="true"><text>a chair</text></choice>
|
||||
<choice correct="false"><text>a bookshelf</text></choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
<p>Which of the following are musical instruments?</p>
|
||||
|
||||
484
common/lib/xmodule/xmodule/tests/test_library_content.py
Normal file
484
common/lib/xmodule/xmodule/tests/test_library_content.py
Normal file
@@ -0,0 +1,484 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Basic unit tests for LibraryContentModule
|
||||
|
||||
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
|
||||
"""
|
||||
from bson.objectid import ObjectId
|
||||
from mock import Mock, patch
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from unittest import TestCase
|
||||
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.runtime import Runtime as VanillaRuntime
|
||||
|
||||
from xmodule.library_content_module import (
|
||||
LibraryVersionReference, LibraryList, ANY_CAPA_TYPE_VALUE, LibraryContentDescriptor
|
||||
)
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory
|
||||
from xmodule.modulestore.tests.utils import MixedSplitTestCase
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.validation import StudioValidationMessage
|
||||
from xmodule.x_module import AUTHOR_VIEW
|
||||
|
||||
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class LibraryContentTest(MixedSplitTestCase):
|
||||
"""
|
||||
Base class for tests of LibraryContentModule (library_content_module.py)
|
||||
"""
|
||||
def setUp(self):
|
||||
super(LibraryContentTest, self).setUp()
|
||||
|
||||
self.tools = LibraryToolsService(self.store)
|
||||
self.library = LibraryFactory.create(modulestore=self.store)
|
||||
self.lib_blocks = [
|
||||
self.make_block("html", self.library, data="Hello world from block {}".format(i))
|
||||
for i in range(1, 5)
|
||||
]
|
||||
self.course = CourseFactory.create(modulestore=self.store)
|
||||
self.chapter = self.make_block("chapter", self.course)
|
||||
self.sequential = self.make_block("sequential", self.chapter)
|
||||
self.vertical = self.make_block("vertical", self.sequential)
|
||||
self.lc_block = self.make_block(
|
||||
"library_content",
|
||||
self.vertical,
|
||||
max_count=1,
|
||||
source_libraries=[LibraryVersionReference(self.library.location.library_key)]
|
||||
)
|
||||
|
||||
def _bind_course_module(self, module):
|
||||
"""
|
||||
Bind a module (part of self.course) so we can access student-specific data.
|
||||
"""
|
||||
module_system = get_test_system(course_id=self.course.location.course_key)
|
||||
module_system.descriptor_runtime = module.runtime
|
||||
module_system._services['library_tools'] = self.tools # pylint: disable=protected-access
|
||||
|
||||
def get_module(descriptor):
|
||||
"""Mocks module_system get_module function"""
|
||||
sub_module_system = get_test_system(course_id=self.course.location.course_key)
|
||||
sub_module_system.get_module = get_module
|
||||
sub_module_system.descriptor_runtime = descriptor.runtime
|
||||
descriptor.bind_for_student(sub_module_system, descriptor._field_data) # pylint: disable=protected-access
|
||||
return descriptor
|
||||
|
||||
module_system.get_module = get_module
|
||||
module.xmodule_runtime = module_system
|
||||
|
||||
|
||||
class TestLibraryContentModule(LibraryContentTest):
|
||||
"""
|
||||
Basic unit tests for LibraryContentModule
|
||||
"""
|
||||
def _get_capa_problem_type_xml(self, *args):
|
||||
""" Helper function to create empty CAPA problem definition """
|
||||
problem = "<problem>"
|
||||
for problem_type in args:
|
||||
problem += "<{problem_type}></{problem_type}>".format(problem_type=problem_type)
|
||||
problem += "</problem>"
|
||||
return problem
|
||||
|
||||
def _create_capa_problems(self):
|
||||
"""
|
||||
Helper function to create a set of capa problems to test against.
|
||||
|
||||
Creates four blocks total.
|
||||
"""
|
||||
problem_types = [
|
||||
["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
|
||||
["coderesponse", "optionresponse"]
|
||||
]
|
||||
for problem_type in problem_types:
|
||||
self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
|
||||
|
||||
def test_lib_content_block(self):
|
||||
"""
|
||||
Test that blocks from a library are copied and added as children
|
||||
"""
|
||||
# Check that the LibraryContent block has no children initially
|
||||
# Normally the children get added when the "source_libraries" setting
|
||||
# is updated, but the way we do it through a factory doesn't do that.
|
||||
self.assertEqual(len(self.lc_block.children), 0)
|
||||
# Update the LibraryContent module:
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
# Check that all blocks from the library are now children of the block:
|
||||
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
|
||||
|
||||
def test_children_seen_by_a_user(self):
|
||||
"""
|
||||
Test that each student sees only one block as a child of the LibraryContent block.
|
||||
"""
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_module(self.lc_block)
|
||||
# Make sure the runtime knows that the block's children vary per-user:
|
||||
self.assertTrue(self.lc_block.has_dynamic_children())
|
||||
|
||||
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
|
||||
|
||||
# Check how many children each user will see:
|
||||
self.assertEqual(len(self.lc_block.get_child_descriptors()), 1)
|
||||
# Check that get_content_titles() doesn't return titles for hidden/unused children
|
||||
self.assertEqual(len(self.lc_block.get_content_titles()), 1)
|
||||
|
||||
def test_validation_of_course_libraries(self):
|
||||
"""
|
||||
Test that the validation method of LibraryContent blocks can validate
|
||||
the source_libraries setting.
|
||||
"""
|
||||
# When source_libraries is blank, the validation summary should say this block needs to be configured:
|
||||
self.lc_block.source_libraries = []
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, result.summary.type)
|
||||
|
||||
# When source_libraries references a non-existent library, we should get an error:
|
||||
self.lc_block.source_libraries = [LibraryVersionReference("library-v1:BAD+WOLF")]
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.ERROR, result.summary.type)
|
||||
self.assertIn("invalid", result.summary.text)
|
||||
|
||||
# When source_libraries is set but the block needs to be updated, the summary should say so:
|
||||
self.lc_block.source_libraries = [LibraryVersionReference(self.library.location.library_key)]
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
|
||||
self.assertIn("out of date", result.summary.text)
|
||||
|
||||
# Now if we update the block, all validation should pass:
|
||||
self.lc_block.refresh_children()
|
||||
self.assertTrue(self.lc_block.validate())
|
||||
|
||||
def test_validation_of_matching_blocks(self):
|
||||
"""
|
||||
Test that the validation method of LibraryContent blocks can warn
|
||||
the user about problems with other settings (max_count and capa_type).
|
||||
"""
|
||||
# Set max_count to higher value than exists in library
|
||||
self.lc_block.max_count = 50
|
||||
# In the normal studio editing process, editor_saved() calls refresh_children at this point
|
||||
self.lc_block.refresh_children()
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
|
||||
self.assertIn("only 4 matching problems", result.summary.text)
|
||||
|
||||
# Add some capa problems so we can check problem type validation messages
|
||||
self.lc_block.max_count = 1
|
||||
self._create_capa_problems()
|
||||
self.lc_block.refresh_children()
|
||||
self.assertTrue(self.lc_block.validate())
|
||||
|
||||
# Existing problem type should pass validation
|
||||
self.lc_block.max_count = 1
|
||||
self.lc_block.capa_type = 'multiplechoiceresponse'
|
||||
self.lc_block.refresh_children()
|
||||
self.assertTrue(self.lc_block.validate())
|
||||
|
||||
# ... unless requested more blocks than exists in library
|
||||
self.lc_block.max_count = 10
|
||||
self.lc_block.capa_type = 'multiplechoiceresponse'
|
||||
self.lc_block.refresh_children()
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
|
||||
self.assertIn("only 1 matching problem", result.summary.text)
|
||||
|
||||
# Missing problem type should always fail validation
|
||||
self.lc_block.max_count = 1
|
||||
self.lc_block.capa_type = 'customresponse'
|
||||
self.lc_block.refresh_children()
|
||||
result = self.lc_block.validate()
|
||||
self.assertFalse(result) # Validation fails due to at least one warning/message
|
||||
self.assertTrue(result.summary)
|
||||
self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
|
||||
self.assertIn("no matching problem types", result.summary.text)
|
||||
|
||||
def test_capa_type_filtering(self):
|
||||
"""
|
||||
Test that the capa type filter is actually filtering children
|
||||
"""
|
||||
self._create_capa_problems()
|
||||
self.assertEqual(len(self.lc_block.children), 0) # precondition check
|
||||
self.lc_block.capa_type = "multiplechoiceresponse"
|
||||
self.lc_block.refresh_children()
|
||||
self.assertEqual(len(self.lc_block.children), 1)
|
||||
|
||||
self.lc_block.capa_type = "optionresponse"
|
||||
self.lc_block.refresh_children()
|
||||
self.assertEqual(len(self.lc_block.children), 3)
|
||||
|
||||
self.lc_block.capa_type = "coderesponse"
|
||||
self.lc_block.refresh_children()
|
||||
self.assertEqual(len(self.lc_block.children), 2)
|
||||
|
||||
self.lc_block.capa_type = "customresponse"
|
||||
self.lc_block.refresh_children()
|
||||
self.assertEqual(len(self.lc_block.children), 0)
|
||||
|
||||
self.lc_block.capa_type = ANY_CAPA_TYPE_VALUE
|
||||
self.lc_block.refresh_children()
|
||||
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks) + 4)
|
||||
|
||||
def test_non_editable_settings(self):
|
||||
"""
|
||||
Test the settings that are marked as "non-editable".
|
||||
"""
|
||||
non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields
|
||||
self.assertIn(LibraryContentDescriptor.mode, non_editable_metadata_fields)
|
||||
self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields)
|
||||
|
||||
|
||||
@patch(
|
||||
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render
|
||||
)
|
||||
@patch('xmodule.html_module.HtmlModule.author_view', dummy_render, create=True)
|
||||
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
|
||||
class TestLibraryContentRender(LibraryContentTest):
|
||||
"""
|
||||
Rendering unit tests for LibraryContentModule
|
||||
"""
|
||||
def test_preivew_view(self):
|
||||
""" Test preview view rendering """
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
|
||||
self._bind_course_module(self.lc_block)
|
||||
rendered = self.lc_block.render(AUTHOR_VIEW, {'root_xblock': self.lc_block})
|
||||
self.assertIn("Hello world from block 1", rendered.content)
|
||||
|
||||
def test_author_view(self):
|
||||
""" Test author view rendering """
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
|
||||
self._bind_course_module(self.lc_block)
|
||||
rendered = self.lc_block.render(AUTHOR_VIEW, {})
|
||||
self.assertEqual("", rendered.content) # content should be empty
|
||||
self.assertEqual("LibraryContentAuthorView", rendered.js_init_fn) # but some js initialization should happen
|
||||
|
||||
|
||||
class TestLibraryList(TestCase):
|
||||
""" Tests for LibraryList XBlock Field """
|
||||
def test_from_json_runtime_style(self):
|
||||
"""
|
||||
Test that LibraryList can parse raw libraries list as passed by runtime
|
||||
"""
|
||||
lib_list = LibraryList()
|
||||
lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b'
|
||||
lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992'
|
||||
raw = [[lib1_key, lib1_version], [lib2_key, lib2_version]]
|
||||
parsed = lib_list.from_json(raw)
|
||||
self.assertEqual(len(parsed), 2)
|
||||
self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key))
|
||||
self.assertEquals(parsed[0].version, ObjectId(lib1_version))
|
||||
self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key))
|
||||
self.assertEquals(parsed[1].version, ObjectId(lib2_version))
|
||||
|
||||
def test_from_json_studio_editor_style(self):
|
||||
"""
|
||||
Test that LibraryList can parse raw libraries list as passed by studio editor
|
||||
"""
|
||||
lib_list = LibraryList()
|
||||
lib1_key, lib1_version = u'library-v1:Org1+Lib1', '5436ffec56c02c13806a4c1b'
|
||||
lib2_key, lib2_version = u'library-v1:Org2+Lib2', '112dbaf312c0daa019ce9992'
|
||||
raw = [lib1_key + ',' + lib1_version, lib2_key + ',' + lib2_version]
|
||||
parsed = lib_list.from_json(raw)
|
||||
self.assertEqual(len(parsed), 2)
|
||||
self.assertEquals(parsed[0].library_id, LibraryLocator.from_string(lib1_key))
|
||||
self.assertEquals(parsed[0].version, ObjectId(lib1_version))
|
||||
self.assertEquals(parsed[1].library_id, LibraryLocator.from_string(lib2_key))
|
||||
self.assertEquals(parsed[1].version, ObjectId(lib2_version))
|
||||
|
||||
def test_from_json_invalid_value(self):
|
||||
"""
|
||||
Test that LibraryList raises Value error if invalid library key is given
|
||||
"""
|
||||
lib_list = LibraryList()
|
||||
with self.assertRaises(ValueError):
|
||||
lib_list.from_json(["Not-a-library-key,whatever"])
|
||||
|
||||
|
||||
class TestLibraryContentAnalytics(LibraryContentTest):
|
||||
"""
|
||||
Test analytics features of LibraryContentModule
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestLibraryContentAnalytics, self).setUp()
|
||||
self.publisher = Mock()
|
||||
self.lc_block.refresh_children()
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_module(self.lc_block)
|
||||
self.lc_block.xmodule_runtime.publish = self.publisher
|
||||
|
||||
def _assert_event_was_published(self, event_type):
|
||||
"""
|
||||
Check that a LibraryContentModule analytics event was published by self.lc_block.
|
||||
"""
|
||||
self.assertTrue(self.publisher.called)
|
||||
self.assertTrue(len(self.publisher.call_args[0]), 3)
|
||||
_, event_name, event_data = self.publisher.call_args[0]
|
||||
self.assertEqual(event_name, "edx.librarycontentblock.content.{}".format(event_type))
|
||||
self.assertEqual(event_data["location"], unicode(self.lc_block.location))
|
||||
return event_data
|
||||
|
||||
def test_assigned_event(self):
|
||||
"""
|
||||
Test the "assigned" event emitted when a student is assigned specific blocks.
|
||||
"""
|
||||
# In the beginning was the lc_block and it assigned one child to the student:
|
||||
child = self.lc_block.get_child_descriptors()[0]
|
||||
child_lib_location, child_lib_version = self.store.get_block_original_usage(child.location)
|
||||
self.assertIsInstance(child_lib_version, ObjectId)
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
block_info = {
|
||||
"usage_key": unicode(child.location),
|
||||
"original_usage_key": unicode(child_lib_location),
|
||||
"original_usage_version": unicode(child_lib_version),
|
||||
"descendants": [],
|
||||
}
|
||||
self.assertEqual(event_data, {
|
||||
"location": unicode(self.lc_block.location),
|
||||
"added": [block_info],
|
||||
"result": [block_info],
|
||||
"previous_count": 0,
|
||||
"max_count": 1,
|
||||
})
|
||||
self.publisher.reset_mock()
|
||||
|
||||
# Now increase max_count so that one more child will be added:
|
||||
self.lc_block.max_count = 2
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 2)
|
||||
child, new_child = children if children[0].location == child.location else reversed(children)
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
self.assertEqual(event_data["added"][0]["usage_key"], unicode(new_child.location))
|
||||
self.assertEqual(len(event_data["result"]), 2)
|
||||
self.assertEqual(event_data["previous_count"], 1)
|
||||
self.assertEqual(event_data["max_count"], 2)
|
||||
|
||||
def test_assigned_descendants(self):
|
||||
"""
|
||||
Test the "assigned" event emitted includes descendant block information.
|
||||
"""
|
||||
# Replace the blocks in the library with a block that has descendants:
|
||||
with self.store.bulk_operations(self.library.location.library_key):
|
||||
self.library.children = []
|
||||
main_vertical = self.make_block("vertical", self.library)
|
||||
inner_vertical = self.make_block("vertical", main_vertical)
|
||||
html_block = self.make_block("html", inner_vertical)
|
||||
problem_block = self.make_block("problem", inner_vertical)
|
||||
self.lc_block.refresh_children()
|
||||
|
||||
# Reload lc_block and set it up for a student:
|
||||
self.lc_block = self.store.get_item(self.lc_block.location)
|
||||
self._bind_course_module(self.lc_block)
|
||||
self.lc_block.xmodule_runtime.publish = self.publisher
|
||||
|
||||
# Get the keys of each of our blocks, as they appear in the course:
|
||||
course_usage_main_vertical = self.lc_block.children[0]
|
||||
course_usage_inner_vertical = self.store.get_item(course_usage_main_vertical).children[0]
|
||||
inner_vertical_in_course = self.store.get_item(course_usage_inner_vertical)
|
||||
course_usage_html = inner_vertical_in_course.children[0]
|
||||
course_usage_problem = inner_vertical_in_course.children[1]
|
||||
|
||||
# Trigger a publish event:
|
||||
self.lc_block.get_child_descriptors()
|
||||
event_data = self._assert_event_was_published("assigned")
|
||||
|
||||
for block_list in (event_data["added"], event_data["result"]):
|
||||
self.assertEqual(len(block_list), 1) # main_vertical is the only root block added, and is the only result.
|
||||
self.assertEqual(block_list[0]["usage_key"], unicode(course_usage_main_vertical))
|
||||
|
||||
# Check that "descendants" is a flat, unordered list of all of main_vertical's descendants:
|
||||
descendants_expected = (
|
||||
(inner_vertical.location, course_usage_inner_vertical),
|
||||
(html_block.location, course_usage_html),
|
||||
(problem_block.location, course_usage_problem),
|
||||
)
|
||||
descendant_data_expected = {}
|
||||
for lib_key, course_usage_key in descendants_expected:
|
||||
descendant_data_expected[unicode(course_usage_key)] = {
|
||||
"usage_key": unicode(course_usage_key),
|
||||
"original_usage_key": unicode(lib_key),
|
||||
"original_usage_version": unicode(self.store.get_block_original_usage(course_usage_key)[1]),
|
||||
}
|
||||
self.assertEqual(len(block_list[0]["descendants"]), len(descendant_data_expected))
|
||||
for descendant in block_list[0]["descendants"]:
|
||||
self.assertEqual(descendant, descendant_data_expected.get(descendant["usage_key"]))
|
||||
|
||||
def test_removed_overlimit(self):
|
||||
"""
|
||||
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
|
||||
We go from one blocks assigned to none because max_count has been decreased.
|
||||
"""
|
||||
# Decrease max_count to 1, causing the block to be overlimit:
|
||||
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
|
||||
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
|
||||
self.lc_block.max_count = 0
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
|
||||
# Check that the event says that one block was removed, leaving no blocks left:
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 0)
|
||||
event_data = self._assert_event_was_published("removed")
|
||||
self.assertEqual(len(event_data["removed"]), 1)
|
||||
self.assertEqual(event_data["result"], [])
|
||||
self.assertEqual(event_data["reason"], "overlimit")
|
||||
|
||||
def test_removed_invalid(self):
|
||||
"""
|
||||
Test the "removed" event emitted when we un-assign blocks previously assigned to a student.
|
||||
We go from two blocks assigned, to one because the others have been deleted from the library.
|
||||
"""
|
||||
# Start by assigning two blocks to the student:
|
||||
self.lc_block.get_child_descriptors() # This line is needed in the test environment or the change has no effect
|
||||
self.lc_block.max_count = 2
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
initial_blocks_assigned = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(initial_blocks_assigned), 2)
|
||||
self.publisher.reset_mock() # Clear the "assigned" event that was just published.
|
||||
# Now make sure that one of the assigned blocks will have to be un-assigned.
|
||||
# To cause an "invalid" event, we delete all blocks from the content library
|
||||
# except for one of the two already assigned to the student:
|
||||
keep_block_key = initial_blocks_assigned[0].location
|
||||
keep_block_lib_usage_key, keep_block_lib_version = self.store.get_block_original_usage(keep_block_key)
|
||||
deleted_block_key = initial_blocks_assigned[1].location
|
||||
self.library.children = [keep_block_lib_usage_key]
|
||||
self.store.update_item(self.library, self.user_id)
|
||||
self.lc_block.refresh_children()
|
||||
# Clear the cache (only needed because we skip saving/re-loading the block) pylint: disable=protected-access
|
||||
del self.lc_block._xmodule._selected_set
|
||||
|
||||
# Check that the event says that one block was removed, leaving one block left:
|
||||
children = self.lc_block.get_child_descriptors()
|
||||
self.assertEqual(len(children), 1)
|
||||
event_data = self._assert_event_was_published("removed")
|
||||
self.assertEqual(event_data["removed"], [{
|
||||
"usage_key": unicode(deleted_block_key),
|
||||
"original_usage_key": None, # Note: original_usage_key info is sadly unavailable because the block has been
|
||||
# deleted so that info can no longer be retrieved
|
||||
"original_usage_version": None,
|
||||
"descendants": [],
|
||||
}])
|
||||
self.assertEqual(event_data["result"], [{
|
||||
"usage_key": unicode(keep_block_key),
|
||||
"original_usage_key": unicode(keep_block_lib_usage_key),
|
||||
"original_usage_version": unicode(keep_block_lib_version),
|
||||
"descendants": [],
|
||||
}])
|
||||
self.assertEqual(event_data["reason"], "invalid")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user