Merge pull request #6459 from edx/content-libraries

Content libraries MVP
This commit is contained in:
Braden MacDonald
2015-01-13 11:20:20 -08:00
132 changed files with 9358 additions and 1409 deletions

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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/"

View File

@@ -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')

View File

@@ -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)

View File

@@ -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'
)

View 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)

View File

@@ -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):

View File

@@ -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.

View File

@@ -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 *

View File

@@ -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

View 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':

View File

@@ -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)

View File

@@ -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,

View 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),
})

View File

@@ -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)

View File

@@ -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', {})

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 **",

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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();
});
};

View 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();
});
};
});

View File

@@ -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}

View 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', {});
});
};
});

View File

@@ -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);

View File

@@ -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() {

View 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('');
});
});
});
});
});

View File

@@ -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'
});
});

View File

@@ -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;
};

View File

@@ -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');
});
});
});

View 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();
});
});
});

View File

@@ -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');

View File

@@ -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'));

View 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();

View File

@@ -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');
},

View 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();

View File

@@ -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});
}
});

View 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;
});

View File

@@ -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();

View File

@@ -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);
}

View 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;
});

View File

@@ -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
};
};
});

View 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);
});
}
};
});

View 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
};
}
}
);

View File

@@ -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
};
});

View File

@@ -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;
}

View 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;
}
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View 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>

View File

@@ -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>

View 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">&gt; </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>

View 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>

View File

@@ -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
View 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>

View 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">&gt; </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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'
],

View File

@@ -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',

View File

@@ -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",

View File

@@ -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')

View 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

View File

@@ -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):

View 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

View File

@@ -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]

View File

@@ -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):
"""

View File

@@ -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')
)

View File

@@ -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):
"""

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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))

View File

@@ -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"]))

View File

@@ -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
)

View 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();
}
});
});
};

View File

@@ -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>

View 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