Commit 6be35e0f by Braden MacDonald

Merge pull request #6459 from edx/content-libraries

Content libraries MVP
parents 2b9b531c bce8ee69
...@@ -171,7 +171,7 @@ def log_into_studio( ...@@ -171,7 +171,7 @@ def log_into_studio(
world.log_in(username=uname, password=password, email=email, name=name) world.log_in(username=uname, password=password, email=email, name=name)
# Navigate to the studio dashboard # Navigate to the studio dashboard
world.visit('/') 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): def add_course_author(user, course):
......
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
# pylint: disable=redefined-outer-name # pylint: disable=redefined-outer-name
# pylint: disable=unused-argument
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -33,15 +34,19 @@ def i_create_a_course(step): ...@@ -33,15 +34,19 @@ def i_create_a_course(step):
create_a_course() create_a_course()
@step('I click the course link in My Courses$') @step('I click the course link in Studio Home$')
def i_click_the_course_link_in_my_courses(step): def i_click_the_course_link_in_studio_home(step): # pylint: disable=invalid-name
course_css = 'a.course-link' course_css = 'a.course-link'
world.css_click(course_css) world.css_click(course_css)
@step('I see an error about the length of the org/course/run tuple') @step('I see an error about the length of the org/course/run tuple')
def i_see_error_about_length(step): 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 ################### ############ ASSERTIONS ###################
...@@ -52,8 +57,8 @@ def courseware_page_has_loaded_in_studio(step): ...@@ -52,8 +57,8 @@ def courseware_page_has_loaded_in_studio(step):
assert world.is_css_present(course_title_css) assert world.is_css_present(course_title_css)
@step('I see the course listed in My Courses$') @step('I see the course listed in Studio Home$')
def i_see_the_course_in_my_courses(step): def i_see_the_course_in_studio_home(step):
course_css = 'h3.class-title' course_css = 'h3.class-title'
assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name)
......
...@@ -11,7 +11,7 @@ Feature: CMS.Help ...@@ -11,7 +11,7 @@ Feature: CMS.Help
Scenario: Users can access online help within a course Scenario: Users can access online help within a course
Given I have opened a new course in Studio 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" Then I should see online help for "outline"
And I go to the course updates page And I go to the course updates page
......
...@@ -26,7 +26,7 @@ Feature: CMS.Sign in ...@@ -26,7 +26,7 @@ Feature: CMS.Sign in
And I visit the url "/signin?next=http://www.google.com/" And I visit the url "/signin?next=http://www.google.com/"
When I fill in and submit the signin form When I fill in and submit the signin form
And I wait for "2" seconds 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 Scenario: Login with mistyped credentials
Given I have opened a new course in Studio Given I have opened a new course in Studio
...@@ -41,4 +41,4 @@ Feature: CMS.Sign in ...@@ -41,4 +41,4 @@ Feature: CMS.Sign in
Then I should not see a login error message Then I should not see a login error message
And I submit the signin form And I submit the signin form
And I wait for "2" seconds And I wait for "2" seconds
Then I should see that the path is "/course/" Then I should see that the path is "/home/"
...@@ -25,7 +25,7 @@ def i_press_the_button_on_the_registration_form(step): ...@@ -25,7 +25,7 @@ def i_press_the_button_on_the_registration_form(step):
@step('I should see an email verification prompt') @step('I should see an email verification prompt')
def i_should_see_an_email_verification_prompt(step): 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') world.css_has_text('div.msg h3.title', u'We need to verify your email address')
......
...@@ -1166,11 +1166,10 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1166,11 +1166,10 @@ class ContentStoreTest(ContentStoreTestCase):
def test_course_index_view_with_no_courses(self): def test_course_index_view_with_no_courses(self):
"""Test viewing the index page with no courses""" """Test viewing the index page with no courses"""
# Create a course so there is something to view resp = self.client.get_html('/home/')
resp = self.client.get_html('/course/')
self.assertContains( self.assertContains(
resp, resp,
'<h1 class="page-header">My Courses</h1>', '<h1 class="page-header">Studio Home</h1>',
status_code=200, status_code=200,
html=True html=True
) )
...@@ -1189,7 +1188,7 @@ class ContentStoreTest(ContentStoreTestCase): ...@@ -1189,7 +1188,7 @@ class ContentStoreTest(ContentStoreTestCase):
def test_course_index_view_with_course(self): def test_course_index_view_with_course(self):
"""Test viewing the index page with an existing course""" """Test viewing the index page with an existing course"""
CourseFactory.create(display_name='Robot Super Educational Course') CourseFactory.create(display_name='Robot Super Educational Course')
resp = self.client.get_html('/course/') resp = self.client.get_html('/home/')
self.assertContains( self.assertContains(
resp, resp,
'<h3 class="course-title">Robot Super Educational Course</h3>', '<h3 class="course-title">Robot Super Educational Course</h3>',
...@@ -1604,7 +1603,7 @@ class RerunCourseTest(ContentStoreTestCase): ...@@ -1604,7 +1603,7 @@ class RerunCourseTest(ContentStoreTestCase):
Asserts that the given course key is in the accessible course listing section of the html 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. 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_course_listing_elements(course_listing, course_key)), 1)
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0)
...@@ -1613,7 +1612,7 @@ class RerunCourseTest(ContentStoreTestCase): ...@@ -1613,7 +1612,7 @@ class RerunCourseTest(ContentStoreTestCase):
Asserts that the given course key is in the unsucceeded course action section of the html 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. 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_course_listing_elements(course_listing, course_key)), 0)
self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1)
......
...@@ -44,9 +44,9 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -44,9 +44,9 @@ class InternationalizationTest(ModuleStoreTestCase):
self.client = AjaxEnabledTestClient() self.client = AjaxEnabledTestClient()
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get_html('/course/') resp = self.client.get_html('/home/')
self.assertContains(resp, self.assertContains(resp,
'<h1 class="page-header">My Courses</h1>', '<h1 class="page-header">Studio Home</h1>',
status_code=200, status_code=200,
html=True) html=True)
...@@ -56,13 +56,13 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -56,13 +56,13 @@ class InternationalizationTest(ModuleStoreTestCase):
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get_html( resp = self.client.get_html(
'/course/', '/home/',
{}, {},
HTTP_ACCEPT_LANGUAGE='en', HTTP_ACCEPT_LANGUAGE='en',
) )
self.assertContains(resp, self.assertContains(resp,
'<h1 class="page-header">My Courses</h1>', '<h1 class="page-header">Studio Home</h1>',
status_code=200, status_code=200,
html=True) html=True)
...@@ -81,7 +81,7 @@ class InternationalizationTest(ModuleStoreTestCase): ...@@ -81,7 +81,7 @@ class InternationalizationTest(ModuleStoreTestCase):
self.client.login(username=self.uname, password=self.password) self.client.login(username=self.uname, password=self.password)
resp = self.client.get_html( resp = self.client.get_html(
'/course/', '/home/',
{}, {},
HTTP_ACCEPT_LANGUAGE='eo' HTTP_ACCEPT_LANGUAGE='eo'
) )
......
...@@ -234,13 +234,13 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -234,13 +234,13 @@ class AuthTestCase(ContentStoreTestCase):
def test_private_pages_auth(self): def test_private_pages_auth(self):
"""Make sure pages that do require login work.""" """Make sure pages that do require login work."""
auth_pages = ( auth_pages = (
'/course/', '/home/',
) )
# These are pages that should just load when the user is logged in # These are pages that should just load when the user is logged in
# (no data needed) # (no data needed)
simple_auth_pages = ( simple_auth_pages = (
'/course/', '/home/',
) )
# need an activated user # need an activated user
...@@ -266,7 +266,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -266,7 +266,7 @@ class AuthTestCase(ContentStoreTestCase):
def test_index_auth(self): def test_index_auth(self):
# not logged in. Should return a redirect. # 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) self.assertEqual(resp.status_code, 302)
# Logged in should work. # Logged in should work.
...@@ -283,7 +283,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -283,7 +283,7 @@ class AuthTestCase(ContentStoreTestCase):
self.login(self.email, self.pw) self.login(self.email, self.pw)
# make sure we can access courseware immediately # make sure we can access courseware immediately
course_url = '/course/' course_url = '/home/'
resp = self.client.get_html(course_url) resp = self.client.get_html(course_url)
self.assertEquals(resp.status_code, 200) self.assertEquals(resp.status_code, 200)
...@@ -293,7 +293,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -293,7 +293,7 @@ class AuthTestCase(ContentStoreTestCase):
resp = self.client.get_html(course_url) resp = self.client.get_html(course_url)
# re-request, and we should get a redirect to login page # 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): class ForumTestCase(CourseTestCase):
......
...@@ -296,6 +296,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None): ...@@ -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) 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): def reverse_usage_url(handler_name, usage_key, kwargs=None):
""" """
Creates the URL for handlers that use usage_keys as URL parameters. Creates the URL for handlers that use usage_keys as URL parameters.
......
...@@ -12,6 +12,7 @@ from .error import * ...@@ -12,6 +12,7 @@ from .error import *
from .helpers import * from .helpers import *
from .item import * from .item import *
from .import_export import * from .import_export import *
from .library import *
from .preview import * from .preview import *
from .public import * from .public import *
from .export_git import * from .export_git import *
......
...@@ -56,6 +56,15 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' ...@@ -56,6 +56,15 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES 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(): def _advanced_component_types():
""" """
Return advanced component types which can be created. Return advanced component types which can be created.
...@@ -202,14 +211,15 @@ def container_handler(request, usage_key_string): ...@@ -202,14 +211,15 @@ def container_handler(request, usage_key_string):
'xblock_info': xblock_info, 'xblock_info': xblock_info,
'draft_preview_link': preview_lms_link, 'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link, 'published_preview_link': lms_link,
'templates': CONTAINER_TEMPATES
}) })
else: else:
return HttpResponseBadRequest("Only supports HTML requests") 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): def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
""" """
...@@ -240,7 +250,13 @@ def get_component_templates(course): ...@@ -240,7 +250,13 @@ def get_component_templates(course):
categories = set() categories = set()
# The component_templates array is in the order of "advanced" (if present), followed # The component_templates array is in the order of "advanced" (if present), followed
# by the components in the order listed in COMPONENT_TYPES. # 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 = [] templates_for_category = []
component_class = _load_mixed_class(category) component_class = _load_mixed_class(category)
# add the default template with localized display name # add the default template with localized display name
...@@ -254,7 +270,7 @@ def get_component_templates(course): ...@@ -254,7 +270,7 @@ def get_component_templates(course):
if hasattr(component_class, 'templates'): if hasattr(component_class, 'templates'):
for template in component_class.templates(): for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None) 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( templates_for_category.append(
create_template_dict( create_template_dict(
_(template['metadata'].get('display_name')), _(template['metadata'].get('display_name')),
...@@ -279,11 +295,15 @@ def get_component_templates(course): ...@@ -279,11 +295,15 @@ def get_component_templates(course):
"display_name": component_display_names[category] "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. # 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 # 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 # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course. # 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_templates = {"type": "advanced", "templates": [], "display_name": _("Advanced")}
advanced_component_types = _advanced_component_types() advanced_component_types = _advanced_component_types()
# Set component types according to course policy file # Set component types according to course policy file
......
""" """
Views related to operations on course objects Views related to operations on course objects
""" """
from django.shortcuts import redirect
import json import json
import random import random
import string # pylint: disable=deprecated-module import string # pylint: disable=deprecated-module
...@@ -38,6 +39,7 @@ from contentstore.utils import ( ...@@ -38,6 +39,7 @@ from contentstore.utils import (
add_extra_panel_tab, add_extra_panel_tab,
remove_extra_panel_tab, remove_extra_panel_tab,
reverse_course_url, reverse_course_url,
reverse_library_url,
reverse_usage_url, reverse_usage_url,
reverse_url, reverse_url,
remove_all_instructors, remove_all_instructors,
...@@ -47,7 +49,7 @@ from models.settings.course_grading import CourseGradingModel ...@@ -47,7 +49,7 @@ from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata from models.settings.course_metadata import CourseMetadata
from util.json_request import expect_json from util.json_request import expect_json
from util.string_utils import _has_non_ascii_characters 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 ( from .component import (
OPEN_ENDED_COMPONENT_TYPES, OPEN_ENDED_COMPONENT_TYPES,
NOTE_COMPONENT_TYPES, NOTE_COMPONENT_TYPES,
...@@ -56,6 +58,7 @@ from .component import ( ...@@ -56,6 +58,7 @@ from .component import (
ADVANCED_COMPONENT_TYPES, ADVANCED_COMPONENT_TYPES,
) )
from contentstore.tasks import rerun_course from contentstore.tasks import rerun_course
from .library import LIBRARIES_ENABLED
from .item import create_xblock_info from .item import create_xblock_info
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils from contentstore import utils
...@@ -69,7 +72,8 @@ from microsite_configuration import microsite ...@@ -69,7 +72,8 @@ from microsite_configuration import microsite
from xmodule.course_module import CourseFields 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', 'course_rerun_handler',
'settings_handler', 'settings_handler',
'grading_handler', 'grading_handler',
...@@ -94,7 +98,7 @@ def get_course_and_check_access(course_key, user, depth=0): ...@@ -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 Internal method used to calculate and return the locator and course module
for the view functions in this file. 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() raise PermissionDenied()
course_module = modulestore().get_course(course_key, depth=depth) course_module = modulestore().get_course(course_key, depth=depth)
return course_module return course_module
...@@ -128,7 +132,7 @@ def course_notifications_handler(request, course_key_string=None, action_state_i ...@@ -128,7 +132,7 @@ def course_notifications_handler(request, course_key_string=None, action_state_i
course_key = CourseKey.from_string(course_key_string) course_key = CourseKey.from_string(course_key_string)
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): 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() raise PermissionDenied()
if request.method == 'GET': if request.method == 'GET':
return _course_notifications_json_get(action_state_id) return _course_notifications_json_get(action_state_id)
...@@ -218,7 +222,7 @@ def course_handler(request, course_key_string=None): ...@@ -218,7 +222,7 @@ def course_handler(request, course_key_string=None):
return JsonResponse(_course_outline_json(request, course_module)) 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 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) 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() raise PermissionDenied()
elif request.method == 'PUT': elif request.method == 'PUT':
raise NotImplementedError() raise NotImplementedError()
...@@ -228,7 +232,7 @@ def course_handler(request, course_key_string=None): ...@@ -228,7 +232,7 @@ def course_handler(request, course_key_string=None):
return HttpResponseBadRequest() return HttpResponseBadRequest()
elif request.method == 'GET': # assume html elif request.method == 'GET': # assume html
if course_key_string is None: if course_key_string is None:
return course_listing(request) return redirect(reverse("home"))
else: else:
return course_index(request, CourseKey.from_string(course_key_string)) return course_index(request, CourseKey.from_string(course_key_string))
else: else:
...@@ -290,7 +294,7 @@ def _accessible_courses_list(request): ...@@ -290,7 +294,7 @@ def _accessible_courses_list(request):
if course.location.course == 'templates': if course.location.course == 'templates':
return False 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()) courses = filter(course_filter, modulestore().get_courses())
in_process_course_actions = [ in_process_course_actions = [
...@@ -298,7 +302,7 @@ def _accessible_courses_list(request): ...@@ -298,7 +302,7 @@ def _accessible_courses_list(request):
CourseRerunState.objects.find_all( CourseRerunState.objects.find_all(
exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, should_display=True 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 return courses, in_process_course_actions
...@@ -341,6 +345,14 @@ def _accessible_courses_list_from_groups(request): ...@@ -341,6 +345,14 @@ def _accessible_courses_list_from_groups(request):
return courses_list.values(), in_process_course_actions 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 @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def course_listing(request): def course_listing(request):
...@@ -360,6 +372,8 @@ def course_listing(request): ...@@ -360,6 +372,8 @@ def course_listing(request):
# so fallback to iterating through all courses # so fallback to iterating through all courses
courses, in_process_course_actions = _accessible_courses_list(request) 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): def format_course_for_view(course):
""" """
Return a dict of the data which the view requires for each course Return a dict of the data which the view requires for each course
...@@ -396,6 +410,19 @@ def course_listing(request): ...@@ -396,6 +410,19 @@ def course_listing(request):
) if uca.state == CourseRerunUIStateManager.State.FAILED else '' ) 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 # 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] in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
courses = [ courses = [
...@@ -409,6 +436,8 @@ def course_listing(request): ...@@ -409,6 +436,8 @@ def course_listing(request):
return render_to_response('index.html', { return render_to_response('index.html', {
'courses': courses, 'courses': courses,
'in_process_course_actions': in_process_course_actions, '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, 'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'), 'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user), 'course_creator_status': _get_course_creator_status(request.user),
...@@ -621,7 +650,7 @@ def _rerun_course(request, org, number, run, fields): ...@@ -621,7 +650,7 @@ def _rerun_course(request, org, number, run, fields):
source_course_key = CourseKey.from_string(request.json.get('source_course_key')) source_course_key = CourseKey.from_string(request.json.get('source_course_key'))
# verify user has access to the original course # 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() raise PermissionDenied()
# create destination course key # create destination course key
...@@ -702,7 +731,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None): ...@@ -702,7 +731,7 @@ def course_info_update_handler(request, course_key_string, provided_id=None):
provided_id = None provided_id = None
# check that logged in user has permissions to this item (GET shouldn't require this level?) # 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() raise PermissionDenied()
if request.method == 'GET': if request.method == 'GET':
......
...@@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _ ...@@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_string, render_to_response from edxmako.shortcuts import render_to_string, render_to_response
from xblock.core import XBlock from xblock.core import XBlock
from xmodule.modulestore.django import modulestore 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'] __all__ = ['edge', 'event', 'landing']
...@@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None): ...@@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None):
url=reverse_course_url('course_handler', xblock.location.course_key), url=reverse_course_url('course_handler', xblock.location.course_key),
usage_key=urllib.quote(unicode(xblock.location)) 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: else:
return reverse_usage_url('container_handler', xblock.location) return reverse_usage_url('container_handler', xblock.location)
......
"""
Views related to content libraries.
A content library is a structure containing XBlocks which can be re-used in the
multiple courses.
"""
from __future__ import absolute_import
import json
import logging
from contentstore.views.item import create_xblock_info
from contentstore.utils import reverse_library_url, add_instructor
from django.http import HttpResponseNotAllowed, Http404
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.conf import settings
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie
from edxmako.shortcuts import render_to_response
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator, LibraryUsageLocator
from xmodule.modulestore.exceptions import DuplicateCourseError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from .component import get_component_templates, CONTAINER_TEMPATES
from student.auth import (
STUDIO_VIEW_USERS, STUDIO_EDIT_ROLES, get_user_permissions, has_studio_read_access, has_studio_write_access
)
from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, LibraryUserRole
from student import auth
from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest
__all__ = ['library_handler', 'manage_library_users']
log = logging.getLogger(__name__)
LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
@login_required
@ensure_csrf_cookie
@require_http_methods(('GET', 'POST'))
def library_handler(request, library_key_string=None):
"""
RESTful interface to most content library related functionality.
"""
if not LIBRARIES_ENABLED:
log.exception("Attempted to use the content library API when the libraries feature is disabled.")
raise Http404 # Should never happen because we test the feature in urls.py also
if library_key_string is not None and request.method == 'POST':
return HttpResponseNotAllowed(("POST",))
if request.method == 'POST':
return _create_library(request)
# request method is get, since only GET and POST are allowed by @require_http_methods(('GET', 'POST'))
if library_key_string:
return _display_library(library_key_string, request)
return _list_libraries(request)
def _display_library(library_key_string, request):
"""
Displays single library
"""
library_key = CourseKey.from_string(library_key_string)
if not isinstance(library_key, LibraryLocator):
log.exception("Non-library key passed to content libraries API.") # Should never happen due to url regex
raise Http404 # This is not a library
if not has_studio_read_access(request.user, library_key):
log.exception(
u"User %s tried to access library %s without permission",
request.user.username, unicode(library_key)
)
raise PermissionDenied()
library = modulestore().get_library(library_key)
if library is None:
log.exception(u"Library %s not found", unicode(library_key))
raise Http404
response_format = 'html'
if (
request.REQUEST.get('format', 'html') == 'json' or
'application/json' in request.META.get('HTTP_ACCEPT', 'text/html')
):
response_format = 'json'
return library_blocks_view(library, request.user, response_format)
def _list_libraries(request):
"""
List all accessible libraries
"""
lib_info = [
{
"display_name": lib.display_name,
"library_key": unicode(lib.location.library_key),
}
for lib in modulestore().get_libraries()
if has_studio_read_access(request.user, lib.location.library_key)
]
return JsonResponse(lib_info)
@expect_json
def _create_library(request):
"""
Helper method for creating a new library.
"""
if not auth.has_access(request.user, CourseCreatorRole()):
log.exception(u"User %s tried to create a library without permission", request.user.username)
raise PermissionDenied()
display_name = None
try:
display_name = request.json['display_name']
org = request.json['org']
library = request.json.get('number', None)
if library is None:
library = request.json['library']
store = modulestore()
with store.default_store(ModuleStoreEnum.Type.split):
new_lib = store.create_library(
org=org,
library=library,
user_id=request.user.id,
fields={"display_name": display_name},
)
# Give the user admin ("Instructor") role for this library:
add_instructor(new_lib.location.library_key, request.user, request.user)
except KeyError as error:
log.exception("Unable to create library - missing required JSON key.")
return JsonResponseBadRequest({
"ErrMsg": _("Unable to create library - missing required field '{field}'".format(field=error.message))
})
except InvalidKeyError as error:
log.exception("Unable to create library - invalid key.")
return JsonResponseBadRequest({
"ErrMsg": _("Unable to create library '{name}'.\n\n{err}").format(name=display_name, err=error.message)
})
except DuplicateCourseError:
log.exception("Unable to create library - one already exists with the same key.")
return JsonResponseBadRequest({
'ErrMsg': _(
'There is already a library defined with the same '
'organization and library code. Please '
'change your library code so that it is unique within your organization.'
)
})
lib_key_str = unicode(new_lib.location.library_key)
return JsonResponse({
'url': reverse_library_url('library_handler', lib_key_str),
'library_key': lib_key_str,
})
def library_blocks_view(library, user, response_format):
"""
The main view of a course's content library.
Shows all the XBlocks in the library, and allows adding/editing/deleting
them.
Can be called with response_format="json" to get a JSON-formatted list of
the XBlocks in the library along with library metadata.
Assumes that read permissions have been checked before calling this.
"""
assert isinstance(library.location.library_key, LibraryLocator)
assert isinstance(library.location, LibraryUsageLocator)
children = library.children
if response_format == "json":
# The JSON response for this request is short and sweet:
prev_version = library.runtime.course_entry.structure['previous_version']
return JsonResponse({
"display_name": library.display_name,
"library_id": unicode(library.location.library_key),
"version": unicode(library.runtime.course_entry.course_key.version),
"previous_version": unicode(prev_version) if prev_version else None,
"blocks": [unicode(x) for x in children],
})
can_edit = has_studio_write_access(user, library.location.library_key)
xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
component_templates = get_component_templates(library, library=True) if can_edit else []
return render_to_response('library.html', {
'can_edit': can_edit,
'context_library': library,
'component_templates': json.dumps(component_templates),
'xblock_info': xblock_info,
'templates': CONTAINER_TEMPATES,
'lib_users_url': reverse_library_url('manage_library_users', unicode(library.location.library_key)),
})
def manage_library_users(request, library_key_string):
"""
Studio UI for editing the users within a library.
Uses the /course_team/:library_key/:user_email/ REST API to make changes.
"""
library_key = CourseKey.from_string(library_key_string)
if not isinstance(library_key, LibraryLocator):
raise Http404 # This is not a library
user_perms = get_user_permissions(request.user, library_key)
if not user_perms & STUDIO_VIEW_USERS:
raise PermissionDenied()
library = modulestore().get_library(library_key)
if library is None:
raise Http404
# Segment all the users explicitly associated with this library, ensuring each user only has one role listed:
instructors = set(CourseInstructorRole(library_key).users_with_role())
staff = set(CourseStaffRole(library_key).users_with_role()) - instructors
users = set(LibraryUserRole(library_key).users_with_role()) - instructors - staff
all_users = instructors | staff | users
return render_to_response('manage_users_lib.html', {
'context_library': library,
'staff': staff,
'instructors': instructors,
'users': users,
'all_users': all_users,
'allow_actions': bool(user_perms & STUDIO_EDIT_ROLES),
'library_key': unicode(library_key),
'lib_users_url': reverse_library_url('manage_library_users', library_key_string),
})
...@@ -14,6 +14,7 @@ from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW ...@@ -14,6 +14,7 @@ from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.library_tools import LibraryToolsService
from xmodule.modulestore.django import modulestore, ModuleI18nService from xmodule.modulestore.django import modulestore, ModuleI18nService
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
...@@ -21,6 +22,7 @@ from xblock.runtime import KvsFieldData ...@@ -21,6 +22,7 @@ from xblock.runtime import KvsFieldData
from xblock.django.request import webob_to_django_response, django_to_webob_request from xblock.django.request import webob_to_django_response, django_to_webob_request
from xblock.exceptions import NoSuchHandlerError from xblock.exceptions import NoSuchHandlerError
from xblock.fragment import Fragment 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 lms.djangoapps.lms_xblock.field_data import LmsFieldData
from cms.lib.xblock.field_data import CmsFieldData from cms.lib.xblock.field_data import CmsFieldData
...@@ -123,6 +125,28 @@ class StudioUserService(object): ...@@ -123,6 +125,28 @@ class StudioUserService(object):
return self._request.user.id 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): def _preview_module_system(request, descriptor, field_data):
""" """
Returns a ModuleSystem for the specified descriptor that is specialized for Returns a ModuleSystem for the specified descriptor that is specialized for
...@@ -152,6 +176,7 @@ def _preview_module_system(request, descriptor, field_data): ...@@ -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['user'] = StudioUserService(request) # pylint: disable=protected-access
descriptor.runtime._services['studio_user_permissions'] = StudioPermissionsService(request) # pylint: disable=protected-access
return PreviewModuleSystem( return PreviewModuleSystem(
static_url=settings.STATIC_URL, static_url=settings.STATIC_URL,
...@@ -177,6 +202,7 @@ def _preview_module_system(request, descriptor, field_data): ...@@ -177,6 +202,7 @@ def _preview_module_system(request, descriptor, field_data):
services={ services={
"i18n": ModuleI18nService(), "i18n": ModuleI18nService(),
"field-data": field_data, "field-data": field_data,
"library_tools": LibraryToolsService(modulestore()),
}, },
) )
...@@ -224,6 +250,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -224,6 +250,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'content': frag.content, 'content': frag.content,
'is_root': is_root, 'is_root': is_root,
'is_reorderable': is_reorderable, 'is_reorderable': is_reorderable,
'can_edit': context.get('can_edit', True),
} }
html = render_to_string('studio_xblock_wrapper.html', template_context) html = render_to_string('studio_xblock_wrapper.html', template_context)
frag = wrap_fragment(frag, html) frag = wrap_fragment(frag, html)
......
...@@ -66,6 +66,6 @@ def login_page(request): ...@@ -66,6 +66,6 @@ def login_page(request):
def howitworks(request): def howitworks(request):
"Proxy view" "Proxy view"
if request.user.is_authenticated(): if request.user.is_authenticated():
return redirect('/course/') return redirect('/home/')
else: else:
return render_to_response('howitworks.html', {}) return render_to_response('howitworks.html', {})
...@@ -6,7 +6,7 @@ import lxml ...@@ -6,7 +6,7 @@ import lxml
import datetime import datetime
from contentstore.tests.utils import CourseTestCase 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 student.auth import has_course_author_access
from contentstore.views.course import course_outline_initial_state from contentstore.views.course import course_outline_initial_state
from contentstore.views.item import create_xblock_info, VisibilityState from contentstore.views.item import create_xblock_info, VisibilityState
...@@ -14,7 +14,7 @@ from course_action_state.models import CourseRerunState ...@@ -14,7 +14,7 @@ from course_action_state.models import CourseRerunState
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore 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 opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from course_action_state.managers import CourseRerunUIStateManager from course_action_state.managers import CourseRerunUIStateManager
...@@ -42,7 +42,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -42,7 +42,7 @@ class TestCourseIndex(CourseTestCase):
""" """
Test getting the list of courses and then pulling up their outlines 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') index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html')
parsed_html = lxml.html.fromstring(index_response.content) parsed_html = lxml.html.fromstring(index_response.content)
course_link_eles = parsed_html.find_class('course-link') course_link_eles = parsed_html.find_class('course-link')
...@@ -61,6 +61,27 @@ class TestCourseIndex(CourseTestCase): ...@@ -61,6 +61,27 @@ class TestCourseIndex(CourseTestCase):
course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0] course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0]
self.assertEqual(course_menu_link.find("a").get("href"), link.get("href")) 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): def test_is_staff_access(self):
""" """
Test that people with is_staff see the courses and can navigate into them Test that people with is_staff see the courses and can navigate into them
......
...@@ -4,7 +4,7 @@ Unit tests for helpers.py. ...@@ -4,7 +4,7 @@ Unit tests for helpers.py.
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name 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 from django.utils import http
...@@ -50,6 +50,11 @@ class HelpersTestCase(CourseTestCase): ...@@ -50,6 +50,11 @@ class HelpersTestCase(CourseTestCase):
display_name="My Video") display_name="My Video")
self.assertIsNone(xblock_studio_url(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): def test_xblock_type_display_name(self):
# Verify chapter type display name # Verify chapter type display name
......
...@@ -3,7 +3,7 @@ import json ...@@ -3,7 +3,7 @@ import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import ddt import ddt
from mock import patch from mock import patch, Mock, PropertyMock
from pytz import UTC from pytz import UTC
from webob import Response from webob import Response
...@@ -18,13 +18,15 @@ from contentstore.views.component import ( ...@@ -18,13 +18,15 @@ from contentstore.views.component import (
component_handler, get_component_templates component_handler, get_component_templates
) )
from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.capa_module import CapaDescriptor from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore 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 xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xblock.exceptions import NoSuchHandlerError from xblock.exceptions import NoSuchHandlerError
from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.keys import UsageKey, CourseKey
...@@ -85,12 +87,18 @@ class ItemTest(CourseTestCase): ...@@ -85,12 +87,18 @@ class ItemTest(CourseTestCase):
class GetItemTest(ItemTest): class GetItemTest(ItemTest):
"""Tests for '/xblock' GET url.""" """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 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._get_preview(usage_key, data)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content) resp_content = json.loads(resp.content)
html = resp_content['html'] html = resp_content['html']
...@@ -99,6 +107,14 @@ class GetItemTest(ItemTest): ...@@ -99,6 +107,14 @@ class GetItemTest(ItemTest):
self.assertIsNotNone(resources) self.assertIsNotNone(resources)
return html, 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( @ddt.data(
(1, 21, 23, 35, 37), (1, 21, 23, 35, 37),
(2, 22, 24, 38, 39), (2, 22, 24, 38, 39),
...@@ -246,6 +262,40 @@ class GetItemTest(ItemTest): ...@@ -246,6 +262,40 @@ class GetItemTest(ItemTest):
self.assertIn('New_NAME_A', html) self.assertIn('New_NAME_A', html)
self.assertIn('New_NAME_B', 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): class DeleteItem(ItemTest):
"""Tests for '/xblock' DELETE url.""" """Tests for '/xblock' DELETE url."""
...@@ -893,6 +943,29 @@ class TestEditItem(ItemTest): ...@@ -893,6 +943,29 @@ class TestEditItem(ItemTest):
self._verify_published_with_draft(unit_usage_key) self._verify_published_with_draft(unit_usage_key)
self._verify_published_with_draft(html_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): class TestEditSplitModule(ItemTest):
""" """
...@@ -1420,6 +1493,90 @@ class TestXBlockInfo(ItemTest): ...@@ -1420,6 +1493,90 @@ class TestXBlockInfo(ItemTest):
self.assertIsNone(xblock_info.get('edited_by', None)) 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): class TestXBlockPublishingInfo(ItemTest):
""" """
Unit tests for XBlock's outline handling. Unit tests for XBlock's outline handling.
......
"""
Unit tests for contentstore.views.library
More important high-level tests are in contentstore/tests/test_libraries.py
"""
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
from contentstore.utils import reverse_course_url, reverse_library_url
from contentstore.views.component import get_component_templates
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import LibraryFactory
from mock import patch
from opaque_keys.edx.locator import CourseKey, LibraryLocator
import ddt
from student.roles import LibraryUserRole
LIBRARY_REST_URL = '/library/' # URL for GET/POST requests involving libraries
def make_url_for_lib(key):
""" Get the RESTful/studio URL for testing the given library """
if isinstance(key, LibraryLocator):
key = unicode(key)
return LIBRARY_REST_URL + key
@ddt.ddt
class UnitTestLibraries(ModuleStoreTestCase):
"""
Unit tests for library views
"""
def setUp(self):
user_password = super(UnitTestLibraries, self).setUp()
self.client = AjaxEnabledTestClient()
self.client.login(username=self.user.username, password=user_password)
######################################################
# Tests for /library/ - list and create libraries:
@patch("contentstore.views.library.LIBRARIES_ENABLED", False)
def test_with_libraries_disabled(self):
"""
The library URLs should return 404 if libraries are disabled.
"""
response = self.client.get_json(LIBRARY_REST_URL)
self.assertEqual(response.status_code, 404)
def test_list_libraries(self):
"""
Test that we can GET /library/ to list all libraries visible to the current user.
"""
# Create some more libraries
libraries = [LibraryFactory.create() for _ in range(0, 3)]
lib_dict = dict([(lib.location.library_key, lib) for lib in libraries])
response = self.client.get_json(LIBRARY_REST_URL)
self.assertEqual(response.status_code, 200)
lib_list = parse_json(response)
self.assertEqual(len(lib_list), len(libraries))
for entry in lib_list:
self.assertIn("library_key", entry)
self.assertIn("display_name", entry)
key = CourseKey.from_string(entry["library_key"])
self.assertIn(key, lib_dict)
self.assertEqual(entry["display_name"], lib_dict[key].display_name)
del lib_dict[key] # To ensure no duplicates are matched
@ddt.data("delete", "put")
def test_bad_http_verb(self, verb):
"""
We should get an error if we do weird requests to /library/
"""
response = getattr(self.client, verb)(LIBRARY_REST_URL)
self.assertEqual(response.status_code, 405)
def test_create_library(self):
""" Create a library. """
response = self.client.ajax_post(LIBRARY_REST_URL, {
'org': 'org',
'library': 'lib',
'display_name': "New Library",
})
self.assertEqual(response.status_code, 200)
# That's all we check. More detailed tests are in contentstore.tests.test_libraries...
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True})
def test_lib_create_permission(self):
"""
Users who aren't given course creator roles shouldn't be able to create
libraries either.
"""
self.client.logout()
ns_user, password = self.create_non_staff_user()
self.client.login(username=ns_user.username, password=password)
response = self.client.ajax_post(LIBRARY_REST_URL, {
'org': 'org', 'library': 'lib', 'display_name': "New Library",
})
self.assertEqual(response.status_code, 403)
@ddt.data(
{},
{'org': 'org'},
{'library': 'lib'},
{'org': 'C++', 'library': 'lib', 'display_name': 'Lib with invalid characters in key'},
{'org': 'Org', 'library': 'Wh@t?', 'display_name': 'Lib with invalid characters in key'},
)
def test_create_library_invalid(self, data):
"""
Make sure we are prevented from creating libraries with invalid keys/data
"""
response = self.client.ajax_post(LIBRARY_REST_URL, data)
self.assertEqual(response.status_code, 400)
def test_no_duplicate_libraries(self):
"""
We should not be able to create multiple libraries with the same key
"""
lib = LibraryFactory.create()
lib_key = lib.location.library_key
response = self.client.ajax_post(LIBRARY_REST_URL, {
'org': lib_key.org,
'library': lib_key.library,
'display_name': "A Duplicate key, same as 'lib'",
})
self.assertIn('already a library defined', parse_json(response)['ErrMsg'])
self.assertEqual(response.status_code, 400)
######################################################
# Tests for /library/:lib_key/ - get a specific library as JSON or HTML editing view
def test_get_lib_info(self):
"""
Test that we can get data about a library (in JSON format) using /library/:key/
"""
# Create a library
lib_key = LibraryFactory.create().location.library_key
# Re-load the library from the modulestore, explicitly including version information:
lib = self.store.get_library(lib_key, remove_version=False, remove_branch=False)
version = lib.location.library_key.version_guid
self.assertNotEqual(version, None)
response = self.client.get_json(make_url_for_lib(lib_key))
self.assertEqual(response.status_code, 200)
info = parse_json(response)
self.assertEqual(info['display_name'], lib.display_name)
self.assertEqual(info['library_id'], unicode(lib_key))
self.assertEqual(info['previous_version'], None)
self.assertNotEqual(info['version'], None)
self.assertNotEqual(info['version'], '')
self.assertEqual(info['version'], unicode(version))
def test_get_lib_edit_html(self):
"""
Test that we can get the studio view for editing a library using /library/:key/
"""
lib = LibraryFactory.create()
response = self.client.get(make_url_for_lib(lib.location.library_key))
self.assertEqual(response.status_code, 200)
self.assertIn("<html", response.content)
self.assertIn(lib.display_name, response.content)
@ddt.data('library-v1:Nonexistent+library', 'course-v1:Org+Course', 'course-v1:Org+Course+Run', 'invalid')
def test_invalid_keys(self, key_str):
"""
Check that various Nonexistent/invalid keys give 404 errors
"""
response = self.client.get_json(make_url_for_lib(key_str))
self.assertEqual(response.status_code, 404)
def test_bad_http_verb_with_lib_key(self):
"""
We should get an error if we do weird requests to /library/
"""
lib = LibraryFactory.create()
for verb in ("post", "delete", "put"):
response = getattr(self.client, verb)(make_url_for_lib(lib.location.library_key))
self.assertEqual(response.status_code, 405)
def test_no_access(self):
user, password = self.create_non_staff_user()
self.client.login(username=user, password=password)
lib = LibraryFactory.create()
response = self.client.get(make_url_for_lib(lib.location.library_key))
self.assertEqual(response.status_code, 403)
def test_get_component_templates(self):
"""
Verify that templates for adding discussion and advanced components to
content libraries are not provided.
"""
lib = LibraryFactory.create()
lib.advanced_modules = ['lti']
lib.save()
templates = [template['type'] for template in get_component_templates(lib, library=True)]
self.assertIn('problem', templates)
self.assertNotIn('discussion', templates)
self.assertNotIn('advanced', templates)
def test_manage_library_users(self):
"""
Simple test that the Library "User Access" view works.
Also tests that we can use the REST API to assign a user to a library.
"""
library = LibraryFactory.create()
extra_user, _ = self.create_non_staff_user()
manage_users_url = reverse_library_url('manage_library_users', unicode(library.location.library_key))
response = self.client.get(manage_users_url)
self.assertEqual(response.status_code, 200)
# extra_user has not been assigned to the library so should not show up in the list:
self.assertNotIn(extra_user.username, response.content)
# Now add extra_user to the library:
user_details_url = reverse_course_url(
'course_team_handler',
library.location.library_key, kwargs={'email': extra_user.email}
)
edit_response = self.client.ajax_post(user_details_url, {"role": LibraryUserRole.ROLE})
self.assertIn(edit_response.status_code, (200, 204))
# Now extra_user should apear in the list:
response = self.client.get(manage_users_url)
self.assertEqual(response.status_code, 200)
self.assertIn(extra_user.username, response.content)
...@@ -70,7 +70,7 @@ class UsersTestCase(CourseTestCase): ...@@ -70,7 +70,7 @@ class UsersTestCase(CourseTestCase):
def test_detail_post(self): def test_detail_post(self):
resp = self.client.post( resp = self.client.post(
self.detail_url, self.detail_url,
data={"role": None}, data={"role": ""},
) )
self.assertEqual(resp.status_code, 204) self.assertEqual(resp.status_code, 204)
# reload user from DB # reload user from DB
...@@ -218,7 +218,7 @@ class UsersTestCase(CourseTestCase): ...@@ -218,7 +218,7 @@ class UsersTestCase(CourseTestCase):
data={"role": "instructor"}, data={"role": "instructor"},
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
) )
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 403)
result = json.loads(resp.content) result = json.loads(resp.content)
self.assertIn("error", result) self.assertIn("error", result)
...@@ -232,7 +232,7 @@ class UsersTestCase(CourseTestCase): ...@@ -232,7 +232,7 @@ class UsersTestCase(CourseTestCase):
data={"role": "instructor"}, data={"role": "instructor"},
HTTP_ACCEPT="application/json", HTTP_ACCEPT="application/json",
) )
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 403)
result = json.loads(resp.content) result = json.loads(resp.content)
self.assertIn("error", result) self.assertIn("error", result)
...@@ -255,7 +255,7 @@ class UsersTestCase(CourseTestCase): ...@@ -255,7 +255,7 @@ class UsersTestCase(CourseTestCase):
self.user.save() self.user.save()
resp = self.client.delete(self.detail_url) resp = self.client.delete(self.detail_url)
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 403)
result = json.loads(resp.content) result = json.loads(resp.content)
self.assertIn("error", result) self.assertIn("error", result)
# reload user from DB # reload user from DB
......
...@@ -9,11 +9,12 @@ from edxmako.shortcuts import render_to_response ...@@ -9,11 +9,12 @@ from edxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocator
from util.json_request import JsonResponse, expect_json 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 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 student.models import CourseEnrollment
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
...@@ -50,8 +51,7 @@ def course_team_handler(request, course_key_string=None, email=None): ...@@ -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). 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 course_key = CourseKey.from_string(course_key_string) if course_key_string else None
if not has_course_author_access(request.user, course_key): # No permissions check here - each helper method does its own check.
raise PermissionDenied()
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
return _course_team_user(request, course_key, email) return _course_team_user(request, course_key, email)
...@@ -66,7 +66,8 @@ def _manage_users(request, course_key): ...@@ -66,7 +66,8 @@ def _manage_users(request, course_key):
This view will return all CMS users who are editors for the specified course This view will return all CMS users who are editors for the specified course
""" """
# check that logged in user has permissions to this item # 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() raise PermissionDenied()
course_module = modulestore().get_course(course_key) course_module = modulestore().get_course(course_key)
...@@ -78,7 +79,7 @@ def _manage_users(request, course_key): ...@@ -78,7 +79,7 @@ def _manage_users(request, course_key):
'context_course': course_module, 'context_course': course_module,
'staff': staff, 'staff': staff,
'instructors': instructors, '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): ...@@ -88,17 +89,14 @@ def _course_team_user(request, course_key, email):
Handle the add, remove, promote, demote requests ensuring the requester has authority Handle the add, remove, promote, demote requests ensuring the requester has authority
""" """
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if has_course_author_access(request.user, course_key, role=CourseInstructorRole): requester_perms = get_user_permissions(request.user, course_key)
# instructors have full permissions permissions_error_response = JsonResponse({"error": _("Insufficient permissions")}, 403)
pass if (requester_perms & STUDIO_VIEW_USERS) or (email == request.user.email):
elif has_course_author_access(request.user, course_key, role=CourseStaffRole) and email == request.user.email: # This user has permissions to at least view the list of users or is editing themself
# staff can only affect themselves
pass pass
else: else:
msg = { # This user is not even allowed to know who the authorized users are.
"error": _("Insufficient permissions") return permissions_error_response
}
return JsonResponse(msg, 400)
try: try:
user = User.objects.get(email=email) user = User.objects.get(email=email)
...@@ -108,7 +106,13 @@ def _course_team_user(request, course_key, email): ...@@ -108,7 +106,13 @@ def _course_team_user(request, course_key, email):
} }
return JsonResponse(msg, 404) 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": if request.method == "GET":
# just return info about the user # just return info about the user
msg = { msg = {
...@@ -117,12 +121,17 @@ def _course_team_user(request, course_key, email): ...@@ -117,12 +121,17 @@ def _course_team_user(request, course_key, email):
"role": None, "role": None,
} }
# what's the highest role that this user has? (How should this report global staff?) # what's the highest role that this user has? (How should this report global staff?)
for role in [CourseInstructorRole(course_key), CourseStaffRole(course_key)]: for role in role_hierarchy:
if role.has_user(user): if role(course_key).has_user(user):
msg["role"] = role.ROLE msg["role"] = role.ROLE
break break
return JsonResponse(msg) 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 # can't modify an inactive user
if not user.is_active: if not user.is_active:
msg = { msg = {
...@@ -131,60 +140,44 @@ def _course_team_user(request, course_key, email): ...@@ -131,60 +140,44 @@ def _course_team_user(request, course_key, email):
return JsonResponse(msg, 400) return JsonResponse(msg, 400)
if request.method == "DELETE": if request.method == "DELETE":
try: new_role = None
try_remove_instructor(request, course_key, user) else:
except CannotOrphanCourse as oops: # only other operation supported is to promote/demote a user by changing their role:
return JsonResponse(oops.msg, 400) # role may be None or "" (equivalent to a DELETE request) but must be set.
# Check that the new role was specified:
auth.remove_users(request.user, CourseStaffRole(course_key), user) if "role" in request.json or "role" in request.POST:
return JsonResponse() new_role = request.json.get("role", request.POST.get("role"))
else:
# all other operations require the requesting user to specify a role return JsonResponse({"error": _("No `role` specified.")}, 400)
role = request.json.get("role", request.POST.get("role"))
if role is None: old_roles = set()
return JsonResponse({"error": _("`role` is required")}, 400) role_added = False
for role_type in role_hierarchy:
if role == "instructor": role = role_type(course_key)
if not has_course_author_access(request.user, course_key, role=CourseInstructorRole): if role_type.ROLE == new_role:
msg = { if (requester_perms & STUDIO_EDIT_ROLES) or (user.id == request.user.id and old_roles):
"error": _("Only instructors may create other instructors") # 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)
if new_role and not role_added:
return JsonResponse({"error": _("Invalid `role` specified.")}, 400)
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) return JsonResponse(msg, 400)
auth.add_users(request.user, CourseInstructorRole(course_key), user) auth.remove_users(request.user, role, user)
# auto-enroll the course creator in the course so that "View Live" will work.
CourseEnrollment.enroll(user, course_key) if new_role and not is_library:
elif role == "staff": # The user may be newly added to this course.
# add to staff regardless (can't do after removing from instructors as will no longer # auto-enroll the user in the course so that "View Live" will work.
# 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)
# auto-enroll the course creator in the course so that "View Live" will work.
CourseEnrollment.enroll(user, course_key) CourseEnrollment.enroll(user, course_key)
return JsonResponse() return JsonResponse()
class CannotOrphanCourse(Exception):
"""
Exception raised if an attempt is made to remove all responsible instructors from course.
"""
def __init__(self, msg):
self.msg = msg
Exception.__init__(self)
def try_remove_instructor(request, course_key, user):
# remove all roles in this course from this user: but fail if the user
# is the last instructor in the course team
instructors = CourseInstructorRole(course_key)
if instructors.has_user(user):
if instructors.users_with_role().count() == 1:
msg = {"error": _("You may not remove the last instructor from a course")}
raise CannotOrphanCourse(msg)
else:
auth.remove_users(request.user, instructors, user)
...@@ -72,7 +72,8 @@ ...@@ -72,7 +72,8 @@
"SUBDOMAIN_BRANDING": false, "SUBDOMAIN_BRANDING": false,
"SUBDOMAIN_COURSE_LISTINGS": false, "SUBDOMAIN_COURSE_LISTINGS": false,
"ALLOW_ALL_ADVANCED_COMPONENTS": true, "ALLOW_ALL_ADVANCED_COMPONENTS": true,
"ALLOW_COURSE_RERUNS": true "ALLOW_COURSE_RERUNS": true,
"ENABLE_CONTENT_LIBRARIES": true
}, },
"FEEDBACK_SUBMISSION_EMAIL": "", "FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **", "GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
...@@ -766,6 +766,7 @@ ADVANCED_COMPONENT_TYPES = [ ...@@ -766,6 +766,7 @@ ADVANCED_COMPONENT_TYPES = [
'word_cloud', 'word_cloud',
'graphical_slider_tool', 'graphical_slider_tool',
'lti', 'lti',
'library_content',
# XBlocks from pmitros repos are prototypes. They should not be used # XBlocks from pmitros repos are prototypes. They should not be used
# except for edX Learning Sciences experiments on edge.edx.org without # except for edX Learning Sciences experiments on edge.edx.org without
# further work to make them robust, maintainable, finalize data formats, # further work to make them robust, maintainable, finalize data formats,
......
...@@ -226,3 +226,6 @@ FEATURES['USE_MICROSITES'] = True ...@@ -226,3 +226,6 @@ FEATURES['USE_MICROSITES'] = True
# For consistency in user-experience, keep the value of this setting in sync with # For consistency in user-experience, keep the value of this setting in sync with
# the one in lms/envs/test.py # the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
...@@ -239,6 +239,7 @@ define([ ...@@ -239,6 +239,7 @@ define([
"js/spec/views/assets_spec", "js/spec/views/assets_spec",
"js/spec/views/baseview_spec", "js/spec/views/baseview_spec",
"js/spec/views/container_spec", "js/spec/views/container_spec",
"js/spec/views/paged_container_spec",
"js/spec/views/group_configuration_spec", "js/spec/views/group_configuration_spec",
"js/spec/views/paging_spec", "js/spec/views/paging_spec",
"js/spec/views/unit_outline_spec", "js/spec/views/unit_outline_spec",
...@@ -255,6 +256,7 @@ define([ ...@@ -255,6 +256,7 @@ define([
"js/spec/views/pages/course_outline_spec", "js/spec/views/pages/course_outline_spec",
"js/spec/views/pages/course_rerun_spec", "js/spec/views/pages/course_rerun_spec",
"js/spec/views/pages/index_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/base_modal_spec",
"js/spec/views/modals/edit_xblock_spec", "js/spec/views/modals/edit_xblock_spec",
......
define([ 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', 'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1' 'xblock/cms.runtime.v1'
], ],
function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict'; 'use strict';
return function (componentTemplates, XBlockInfoJson, action, isUnitPage) { return function (componentTemplates, XBlockInfoJson, action, options) {
var templates = new ComponentTemplates(componentTemplates, {parse: true}), var main_options = {
mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true}); el: $('#content'),
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
action: action,
templates: new ComponentTemplates(componentTemplates, {parse: true})
};
xmoduleLoader.done(function () { xmoduleLoader.done(function () {
var view = new ContainerPage({ var view = new ContainerPage(_.extend(main_options, options));
el: $('#content'),
model: mainXBlockInfo,
action: action,
templates: templates,
isUnitPage: isUnitPage
});
view.render(); view.render();
}); });
}; };
......
define([
'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/paged_container',
'js/views/library_container', 'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
function($, _, XBlockInfo, PagedContainerPage, LibraryContainerView, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, options) {
var main_options = {
el: $('#content'),
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
templates: new ComponentTemplates(componentTemplates, {parse: true}),
action: 'view',
viewClass: LibraryContainerView,
canEdit: true
};
xmoduleLoader.done(function () {
var view = new PagedContainerPage(_.extend(main_options, options));
view.render();
});
};
});
...@@ -32,7 +32,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function ...@@ -32,7 +32,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function
msg = new PromptView.Warning({ msg = new PromptView.Warning({
title: gettext('Already a course team member'), title: gettext('Already a course team member'),
message: _.template( 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, email: email,
course: course.escape('name') course: course.escape('name')
}, {interpolate: /\{(.+?)\}/g} }, {interpolate: /\{(.+?)\}/g}
......
/*
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', {});
});
};
});
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils", define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
"js/views/utils/view_utils"], "js/views/utils/create_library_utils", "js/views/utils/view_utils"],
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) { function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) {
var CreateCourseUtils = CreateCourseUtilsFactory({ "use strict";
var CreateCourseUtils = new CreateCourseUtilsFactory({
name: '.new-course-name', name: '.new-course-name',
org: '.new-course-org', org: '.new-course-org',
number: '.new-course-number', number: '.new-course-number',
run: '.new-course-run', run: '.new-course-run',
save: '.new-course-save', save: '.new-course-save',
errorWrapper: '.wrap-error', errorWrapper: '.create-course .wrap-error',
errorMessage: '#course_creation_error', errorMessage: '#course_creation_error',
tipError: 'span.tip-error', tipError: '.create-course span.tip-error',
error: '.error', error: '.create-course .error',
allowUnicode: '.allow-unicode-course-id' allowUnicode: '.allow-unicode-course-id'
}, { }, {
shown: 'is-shown', shown: 'is-shown',
...@@ -20,6 +21,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie ...@@ -20,6 +21,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
error: 'error' 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) { var saveNewCourse = function (e) {
e.preventDefault(); e.preventDefault();
...@@ -33,7 +52,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie ...@@ -33,7 +52,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
var number = $newCourseForm.find('.new-course-number').val(); var number = $newCourseForm.find('.new-course-number').val();
var run = $newCourseForm.find('.new-course-run').val(); var run = $newCourseForm.find('.new-course-run').val();
course_info = { var course_info = {
org: org, org: org,
number: number, number: number,
display_name: display_name, display_name: display_name,
...@@ -41,27 +60,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie ...@@ -41,27 +60,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
}; };
analytics.track('Created a Course', course_info); analytics.track('Created a Course', course_info);
CreateCourseUtils.createCourse(course_info, function (errorMessage) { CreateCourseUtils.create(course_info, function (errorMessage) {
$('.wrap-error').addClass('is-shown'); $('.create-course .wrap-error').addClass('is-shown');
$('#course_creation_error').html('<p>' + errorMessage + '</p>'); $('#course_creation_error').html('<p>' + errorMessage + '</p>');
$('.new-course-save').addClass('is-disabled').attr('aria-disabled', true); $('.new-course-save').addClass('is-disabled').attr('aria-disabled', true);
}); });
}; };
var cancelNewCourse = function (e) { var makeCancelHandler = function (addType) {
e.preventDefault(); return function(e) {
$('.new-course-button').removeClass('is-disabled').attr('aria-disabled', false); e.preventDefault();
$('.wrapper-create-course').removeClass('is-shown'); $('.new-'+addType+'-button').removeClass('is-disabled').attr('aria-disabled', false);
// Clear out existing fields and errors $('.wrapper-create-'+addType).removeClass('is-shown');
_.each( // Clear out existing fields and errors
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], $('#create-'+addType+'-form input[type=text]').val('');
function (field) { $('#'+addType+'_creation_error').html('');
$(field).val(''); $('.create-'+addType+' .wrap-error').removeClass('is-shown');
} $('.new-'+addType+'-save').off('click');
); };
$('#course_creation_error').html('');
$('.wrap-error').removeClass('is-shown');
$('.new-course-save').off('click');
}; };
var addNewCourse = function (e) { var addNewCourse = function (e) {
...@@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie ...@@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
var $courseName = $('.new-course-name'); var $courseName = $('.new-course-name');
$courseName.focus().select(); $courseName.focus().select();
$('.new-course-save').on('click', saveNewCourse); $('.new-course-save').on('click', saveNewCourse);
$cancelButton.bind('click', cancelNewCourse); $cancelButton.bind('click', makeCancelHandler('course'));
CancelOnEscape($cancelButton); CancelOnEscape($cancelButton);
CreateCourseUtils.configureHandlers(); 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 () { var onReady = function () {
$('.new-course-button').bind('click', addNewCourse); $('.new-course-button').bind('click', addNewCourse);
$('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload(); ViewUtils.reload();
})); }));
$('.action-reload').bind('click', 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); domReady(onReady);
......
...@@ -49,7 +49,7 @@ define(["jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_helpe ...@@ -49,7 +49,7 @@ define(["jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_helpe
var requests = AjaxHelpers.requests(this); var requests = AjaxHelpers.requests(this);
modal = showModal(requests, mockXBlockEditorHtml); modal = showModal(requests, mockXBlockEditorHtml);
expect(modal.$('.action-save')).not.toBeVisible(); 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() { it('shows the correct title', function() {
......
...@@ -26,7 +26,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -26,7 +26,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
}, },
mockCreateCourseRerunHTML = readFixtures('mock/mock-create-course-rerun.underscore'); 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) { var fillInFields = function (org, number, run, name) {
$(selectors.org).val(org); $(selectors.org).val(org);
...@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
describe("Field validation", function () { describe("Field validation", function () {
it("returns a message for an empty string", function () { it("returns a message for an empty string", function () {
var message = CreateCourseUtils.validateRequiredField(''); var message = ViewUtils.validateRequiredField('');
expect(message).not.toBe(''); expect(message).not.toBe('');
}); });
it("does not return a message for a non empty string", function () { 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(''); expect(message).toBe('');
}); });
}); });
...@@ -62,7 +62,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -62,7 +62,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
describe("Error messages", function () { describe("Error messages", function () {
var setErrorMessage = function(selector, message) { var setErrorMessage = function(selector, message) {
var element = $(selector).parent(); var element = $(selector).parent();
CreateCourseUtils.setNewCourseFieldInErr(element, message); CreateCourseUtils.setFieldInErr(element, message);
return element; return element;
}; };
......
...@@ -2,7 +2,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -2,7 +2,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
"js/views/utils/view_utils"], "js/views/utils/view_utils"],
function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) { function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) {
describe("Course listing page", function () { 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) { var fillInFields = function (org, number, run, name) {
$('.new-course-org').val(org); $('.new-course-org').val(org);
...@@ -11,6 +11,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -11,6 +11,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
$('.new-course-name').val(name); $('.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 () { beforeEach(function () {
ViewHelpers.installMockAnalytics(); ViewHelpers.installMockAnalytics();
appendSetFixtures(mockIndexPageHTML); appendSetFixtures(mockIndexPageHTML);
...@@ -57,9 +63,86 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper ...@@ -57,9 +63,86 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
AjaxHelpers.respondWithJson(requests, { AjaxHelpers.respondWithJson(requests, {
ErrMsg: 'error message' 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($('#course_creation_error')).toContainText('error message');
expect($('.new-course-save')).toHaveClass('is-disabled'); 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');
}); });
}); });
}); });
define([
"jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helpers",
"js/factories/manage_users_lib", "js/views/utils/view_utils"
],
function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) {
"use strict";
describe("Library Instructor Access Page", function () {
var mockHTML = readFixtures('mock/mock-manage-users-lib.underscore');
beforeEach(function () {
ViewHelpers.installMockAnalytics();
appendSetFixtures(mockHTML);
ManageUsersFactory(
"Mock Library",
["honor@example.com", "audit@example.com", "staff@example.com"],
"dummy_change_role_url"
);
});
afterEach(function () {
ViewHelpers.removeMockAnalytics();
});
it("can give a user permission to use the library", function () {
var requests = AjaxHelpers.requests(this);
var reloadSpy = spyOn(ViewUtils, 'reload');
$('.create-user-button').click();
expect($('.wrapper-create-user')).toHaveClass('is-shown');
$('.user-email-input').val('other@example.com');
$('.form-create.create-user .action-primary').click();
AjaxHelpers.expectJsonRequest(requests, 'POST', 'dummy_change_role_url', {role: 'library_user'});
AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
expect(reloadSpy).toHaveBeenCalled();
});
it("can cancel adding a user to the library", function () {
$('.create-user-button').click();
$('.form-create.create-user .action-secondary').click();
expect($('.wrapper-create-user')).not.toHaveClass('is-shown');
});
it("displays an error when the required field is blank", function () {
var requests = AjaxHelpers.requests(this);
$('.create-user-button').click();
$('.user-email-input').val('');
var errorPromptSelector = '.wrapper-prompt.is-shown .prompt.error';
expect($(errorPromptSelector).length).toEqual(0);
$('.form-create.create-user .action-primary').click();
expect($(errorPromptSelector).length).toEqual(1);
expect($(errorPromptSelector)).toContainText('You must enter a valid email address');
expect(requests.length).toEqual(0);
});
it("displays an error when the user has already been added", function () {
var requests = AjaxHelpers.requests(this);
$('.create-user-button').click();
$('.user-email-input').val('honor@example.com');
var warningPromptSelector = '.wrapper-prompt.is-shown .prompt.warning';
expect($(warningPromptSelector).length).toEqual(0);
$('.form-create.create-user .action-primary').click();
expect($(warningPromptSelector).length).toEqual(1);
expect($(warningPromptSelector)).toContainText('Already a library team member');
expect(requests.length).toEqual(0);
});
it("can remove a user's permission to access the library", function () {
var requests = AjaxHelpers.requests(this);
var reloadSpy = spyOn(ViewUtils, 'reload');
$('.user-item[data-email="honor@example.com"] .action-delete .delete').click();
expect($('.wrapper-prompt.is-shown .prompt.warning').length).toEqual(1);
$('.wrapper-prompt.is-shown .action-primary').click();
AjaxHelpers.expectJsonRequest(requests, 'DELETE', 'dummy_change_role_url', {role: null});
AjaxHelpers.respondWithJson(requests, {'result': 'ok'});
expect(reloadSpy).toHaveBeenCalled();
});
});
});
...@@ -9,6 +9,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -9,6 +9,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// child xblocks within the page. // child xblocks within the page.
requestToken: "", requestToken: "",
new_child_view: 'reorderable_container_child_preview',
xblockReady: function () { xblockReady: function () {
XBlockView.prototype.xblockReady.call(this); XBlockView.prototype.xblockReady.call(this);
var reorderableClass, reorderableContainer, var reorderableClass, reorderableContainer,
...@@ -123,6 +125,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -123,6 +125,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
}); });
}, },
acknowledgeXBlockDeletion: function(locator){
this.notifyRuntime('deleted-child', locator);
},
refresh: function() { refresh: function() {
var sortableInitializedClass = this.makeRequestSpecificSelector('.reorderable-container.ui-sortable'); var sortableInitializedClass = this.makeRequestSpecificSelector('.reorderable-container.ui-sortable');
this.$(sortableInitializedClass).sortable('refresh'); this.$(sortableInitializedClass).sortable('refresh');
......
define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"], define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"],
function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) { function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) {
var CreateCourseUtils = CreateCourseUtilsFactory({ var CreateCourseUtils = new CreateCourseUtilsFactory({
name: '.rerun-course-name', name: '.rerun-course-name',
org: '.rerun-course-org', org: '.rerun-course-org',
number: '.rerun-course-number', number: '.rerun-course-number',
...@@ -41,7 +41,7 @@ define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils" ...@@ -41,7 +41,7 @@ define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils"
}; };
analytics.track('Reran a Course', course_info); 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'); $('.wrapper-error').addClass('is-shown').removeClass('is-hidden');
$('#course_rerun_error').html('<p>' + errorMessage + '</p>'); $('#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')); $('.rerun-course-save').addClass('is-disabled').attr('aria-disabled', true).removeClass('is-processing').html(gettext('Create Re-run'));
......
define(["js/views/paged_container"],
function (PagedContainerView) {
// To be extended with Library-specific features later.
var LibraryContainerView = PagedContainerView;
return LibraryContainerView;
}); // end define();
...@@ -65,7 +65,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie ...@@ -65,7 +65,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
onDisplayXBlock: function() { onDisplayXBlock: function() {
var editorView = this.editorView, 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 // Notify the runtime that the modal has been shown
editorView.notifyRuntime('modal-shown', this); editorView.notifyRuntime('modal-shown', this);
...@@ -88,7 +89,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie ...@@ -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 the xblock is not using custom buttons then choose which buttons to show
if (!editorView.hasCustomButtons()) { if (!editorView.hasCustomButtons()) {
// If the xblock does not support save then disable the save button // If the xblock does not support save then disable the save button
if (!editorView.xblock.save) { if (readOnlyView) {
this.disableSave(); this.disableSave();
} }
this.getActionBar().show(); this.getActionBar().show();
...@@ -101,8 +102,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie ...@@ -101,8 +102,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
disableSave: function() { disableSave: function() {
var saveButton = this.getActionButton('save'), var saveButton = this.getActionButton('save'),
cancelButton = this.getActionButton('cancel'); cancelButton = this.getActionButton('cancel');
saveButton.hide(); saveButton.parent().hide();
cancelButton.text(gettext('OK')); cancelButton.text(gettext('Close'));
cancelButton.addClass('action-primary'); cancelButton.addClass('action-primary');
}, },
......
define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext",
"js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer", "js/views/paging_mixin"],
function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) {
var PagedContainerView = ContainerView.extend(PagingMixin).extend({
initialize: function(options){
var self = this;
ContainerView.prototype.initialize.call(this);
this.page_size = this.options.page_size;
// Reference to the page model
this.page = options.page;
// XBlocks are rendered via Django views and templates rather than underscore templates, and so don't
// have a Backbone model for us to manipulate in a backbone collection. Here, we emulate the interface
// of backbone.paginator so that we can use the Paging Header and Footer with this page. As a
// consequence, however, we have to manipulate its members manually.
this.collection = {
currentPage: 0,
totalPages: 0,
totalCount: 0,
sortDirection: "desc",
start: 0,
_size: 0,
// Paging header and footer expect this to be a Backbone model they can listen to for changes, but
// they cannot. Provide the bind function for them, but have it do nothing.
bind: function() {},
// size() on backbone collections shows how many objects are in the collection, or in the case
// of paginator, on the current page.
size: function() { return self.collection._size; }
};
},
new_child_view: 'container_child_preview',
render: function(options) {
options = options || {};
options.page_number = typeof options.page_number !== "undefined"
? options.page_number
: this.collection.currentPage;
return this.renderPage(options);
},
renderPage: function(options){
var self = this,
view = this.view,
xblockInfo = this.model,
xblockUrl = xblockInfo.url();
return $.ajax({
url: decodeURIComponent(xblockUrl) + "/" + view,
type: 'GET',
cache: false,
data: this.getRenderParameters(options.page_number),
headers: { Accept: 'application/json' },
success: function(fragment) {
self.handleXBlockFragment(fragment, options);
self.processPaging({ requested_page: options.page_number });
self.page.renderAddXBlockComponents();
}
});
},
getRenderParameters: function(page_number) {
return {
page_size: this.page_size,
enable_paging: true,
page_number: page_number
};
},
getPageCount: function(total_count){
if (total_count===0) return 1;
return Math.ceil(total_count / this.page_size);
},
setPage: function(page_number) {
this.render({ page_number: page_number});
},
processPaging: function(options){
// We have the Django template sneak us the pagination information,
// and we load it from a div here.
var $element = this.$el.find('.xblock-container-paging-parameters'),
total = $element.data('total'),
displayed = $element.data('displayed'),
start = $element.data('start');
this.collection.currentPage = options.requested_page;
this.collection.totalCount = total;
this.collection.totalPages = this.getPageCount(total);
this.collection.start = start;
this.collection._size = displayed;
this.processPagingHeaderAndFooter();
},
processPagingHeaderAndFooter: function(){
// Rendering the container view detaches the header and footer from the DOM.
// It's just as easy to recreate them as it is to try to shove them back into the tree.
if (this.pagingHeader)
this.pagingHeader.undelegateEvents();
if (this.pagingFooter)
this.pagingFooter.undelegateEvents();
this.pagingHeader = new PagingHeader({
view: this,
el: this.$el.find('.container-paging-header')
});
this.pagingFooter = new PagingFooter({
view: this,
el: this.$el.find('.container-paging-footer')
});
this.pagingHeader.render();
this.pagingFooter.render();
},
refresh: function(block_added) {
if (block_added) {
this.collection.totalCount += 1;
this.collection._size +=1;
if (this.collection.totalCount == 1) {
this.render();
return
}
this.collection.totalPages = this.getPageCount(this.collection.totalCount);
var new_page = this.collection.totalPages - 1;
// If we're on a new page due to overflow, or this is the first item, set the page.
if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) {
this.setPage(new_page);
} else {
this.pagingHeader.render();
this.pagingFooter.render();
}
}
},
acknowledgeXBlockDeletion: function (locator){
this.notifyRuntime('deleted-child', locator);
this.collection._size -= 1;
this.collection.totalCount -= 1;
var current_page = this.collection.currentPage;
var total_pages = this.getPageCount(this.collection.totalCount);
this.collection.totalPages = total_pages;
// Starts counting from 0
if ((current_page + 1) > total_pages) {
// The number of total pages has changed. Move down.
// Also, be mindful of the off-by-one.
this.setPage(total_pages - 1)
} else if ((current_page + 1) != total_pages) {
// Refresh page to get any blocks shifted from the next page.
this.setPage(current_page)
} else {
// We're on the last page, just need to update the numbers in the
// pagination interface.
this.pagingHeader.render();
this.pagingFooter.render();
}
},
sortDisplayName: function() {
return gettext("Date added"); // TODO add support for sorting
}
});
return PagedContainerView;
}); // end define();
...@@ -16,17 +16,27 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -16,17 +16,27 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
events: { events: {
"click .edit-button": "editXBlock", "click .edit-button": "editXBlock",
"click .duplicate-button": "duplicateXBlock", "click .duplicate-button": "duplicateXBlock",
"click .delete-button": "deleteXBlock" "click .delete-button": "deleteXBlock",
"click .new-component-button": "scrollToNewComponentButtons"
}, },
options: { options: {
collapsedClass: 'is-collapsed' collapsedClass: 'is-collapsed',
canEdit: true // If not specified, assume user has permission to make changes
}, },
view: 'container_preview', 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) { initialize: function(options) {
BasePage.prototype.initialize.call(this, options); BasePage.prototype.initialize.call(this, options);
this.viewClass = options.viewClass || this.defaultViewClass;
this.nameEditor = new XBlockStringFieldEditor({ this.nameEditor = new XBlockStringFieldEditor({
el: this.$('.wrapper-xblock-field'), el: this.$('.wrapper-xblock-field'),
model: this.model model: this.model
...@@ -35,11 +45,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -35,11 +45,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
if (this.options.action === 'new') { if (this.options.action === 'new') {
this.nameEditor.$('.xblock-field-value-edit').click(); this.nameEditor.$('.xblock-field-value-edit').click();
} }
this.xblockView = new ContainerView({ this.xblockView = this.getXBlockView();
el: this.$('.wrapper-xblock'),
model: this.model,
view: this.view
});
this.messageView = new ContainerSubviews.MessageView({ this.messageView = new ContainerSubviews.MessageView({
el: this.$('.container-message'), el: this.$('.container-message'),
model: this.model model: this.model
...@@ -75,6 +81,18 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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) { render: function(options) {
var self = this, var self = this,
xblockView = this.xblockView, xblockView = this.xblockView,
...@@ -97,8 +115,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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 // Notify the runtime that the page has been successfully shown
xblockView.notifyRuntime('page-shown', self); xblockView.notifyRuntime('page-shown', self);
// Render the add buttons if (self.components_on_init) {
self.renderAddXBlockComponents(); // Render the add buttons. Paged containers should do this on their own.
self.renderAddXBlockComponents();
}
// Refresh the views now that the xblock is visible // Refresh the views now that the xblock is visible
self.onXBlockRefresh(xblockView); self.onXBlockRefresh(xblockView);
...@@ -106,7 +126,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -106,7 +126,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
// Re-enable Backbone events for any updated DOM elements // Re-enable Backbone events for any updated DOM elements
self.delegateEvents(); self.delegateEvents();
} },
block_added: options && options.block_added
}); });
}, },
...@@ -118,22 +139,26 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -118,22 +139,26 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
return this.xblockView.model.urlRoot; return this.xblockView.model.urlRoot;
}, },
onXBlockRefresh: function(xblockView) { onXBlockRefresh: function(xblockView, block_added) {
this.xblockView.refresh(); this.xblockView.refresh(block_added);
// Update publish and last modified information from the server. // Update publish and last modified information from the server.
this.model.fetch(); this.model.fetch();
}, },
renderAddXBlockComponents: function() { renderAddXBlockComponents: function() {
var self = this; var self = this;
this.$('.add-xblock-component').each(function(index, element) { if (self.options.canEdit) {
var component = new AddXBlockComponent({ this.$('.add-xblock-component').each(function(index, element) {
el: element, var component = new AddXBlockComponent({
createComponent: _.bind(self.createComponent, self), el: element,
collection: self.options.templates createComponent: _.bind(self.createComponent, self),
collection: self.options.templates
});
component.render();
}); });
component.render(); } else {
}); this.$('.add-xblock-component').remove();
}
}, },
editXBlock: function(event) { editXBlock: function(event) {
...@@ -143,8 +168,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -143,8 +168,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
event.preventDefault(); event.preventDefault();
modal.edit(xblockElement, this.model, { modal.edit(xblockElement, this.model, {
readOnlyView: !this.options.canEdit,
refresh: function() { refresh: function() {
self.refreshXBlock(xblockElement); self.refreshXBlock(xblockElement, false);
} }
}); });
}, },
...@@ -226,7 +252,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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 // Inform the runtime that the child has been deleted in case
// other views are listening to deletion events. // 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. // Update publish and last modified information from the server.
this.model.fetch(); this.model.fetch();
...@@ -235,7 +261,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -235,7 +261,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
onNewXBlock: function(xblockElement, scrollOffset, data) { onNewXBlock: function(xblockElement, scrollOffset, data) {
ViewUtils.setScrollOffset(xblockElement, scrollOffset); ViewUtils.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator); 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 ...@@ -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 * reorderable container then the element will be refreshed inline. If not, then the
* parent container will be refreshed instead. * parent container will be refreshed instead.
* @param element An element representing the xblock to be refreshed. * @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), var xblockElement = this.findXBlockElement(element),
parentElement = xblockElement.parent(), parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id; rootLocator = this.xblockView.model.id;
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { 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')) { } else if (parentElement.hasClass('reorderable-container')) {
this.refreshChildXBlock(xblockElement); this.refreshChildXBlock(xblockElement, block_added);
} else { } else {
this.refreshXBlock(this.findXBlockElement(parentElement)); this.refreshXBlock(this.findXBlockElement(parentElement));
} }
...@@ -261,9 +288,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -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. * 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. * Note that the element is removed and replaced with the newly rendered xblock.
* @param xblockElement The xblock element to be refreshed. * @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. * @returns {jQuery promise} A promise representing the complete operation.
*/ */
refreshChildXBlock: function(xblockElement) { refreshChildXBlock: function(xblockElement, block_added) {
var self = this, var self = this,
xblockInfo, xblockInfo,
TemporaryXBlockView, TemporaryXBlockView,
...@@ -284,15 +313,20 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -284,15 +313,20 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}); });
temporaryView = new TemporaryXBlockView({ temporaryView = new TemporaryXBlockView({
model: xblockInfo, model: xblockInfo,
view: 'reorderable_container_child_preview', view: self.xblockView.new_child_view,
el: xblockElement el: xblockElement
}); });
return temporaryView.render({ return temporaryView.render({
success: function() { success: function() {
self.onXBlockRefresh(temporaryView); self.onXBlockRefresh(temporaryView, block_added);
temporaryView.unbind(); // Remove the temporary view temporaryView.unbind(); // Remove the temporary view
} }
}); });
},
scrollToNewComponentButtons: function(event) {
event.preventDefault();
$.scrollTo(this.$('.add-xblock-component'), {duration: 250});
} }
}); });
......
/**
* 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;
});
define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"], define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", "js/views/paging_mixin"],
function(_, BaseView, AlertView, gettext) { function(_, BaseView, AlertView, gettext, PagingMixin) {
var PagingView = BaseView.extend({ var PagingView = BaseView.extend(PagingMixin).extend({
// takes a Backbone Paginator as a model // takes a Backbone Paginator as a model
sortableColumns: {}, sortableColumns: {},
...@@ -21,43 +21,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"] ...@@ -21,43 +21,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
this.$('#' + sortColumn).addClass('current-sort'); 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() { onError: function() {
// Do nothing by default // 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. * Registers information about a column that can be sorted.
* @param columnName The element name of the column. * @param columnName The element name of the column.
...@@ -110,6 +77,5 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"] ...@@ -110,6 +77,5 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
this.setPage(0); this.setPage(0);
} }
}); });
return PagingView; return PagingView;
}); // end define(); }); // end define();
...@@ -38,6 +38,14 @@ define(["underscore", "js/views/baseview"], function(_, BaseView) { ...@@ -38,6 +38,14 @@ define(["underscore", "js/views/baseview"], function(_, BaseView) {
currentPage = collection.currentPage + 1, currentPage = collection.currentPage + 1,
pageInput = this.$("#page-number-input"), pageInput = this.$("#page-number-input"),
pageNumber = parseInt(pageInput.val(), 10); 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) { if (pageNumber && pageNumber !== currentPage) {
view.setPage(pageNumber - 1); view.setPage(pageNumber - 1);
} }
......
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;
});
/** /**
* Provides utilities for validating courses during creation, for both new courses and reruns. * Provides utilities for validating courses during creation, for both new courses and reruns.
*/ */
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], define(["jquery", "gettext", "js/views/utils/view_utils", "js/views/utils/create_utils_base"],
function ($, _, gettext, ViewUtils) { function ($, gettext, ViewUtils, CreateUtilsFactory) {
"use strict";
return function (selectors, classes) { return function (selectors, classes) {
var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr, var keyLengthViolationMessage = gettext("The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters.");
hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers; var keyFieldSelectors = [selectors.org, selectors.number, selectors.run];
var nonEmptyCheckFieldSelectors = [selectors.name, selectors.org, selectors.number, selectors.run];
validateRequiredField = function (msg) { CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors);
return msg.length === 0 ? gettext('Required field.') : '';
};
// 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( $.postJSON(
'/course/', '/course/',
courseInfo, courseInfo,
...@@ -91,61 +24,5 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], ...@@ -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
};
}; };
}); });
/**
* 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);
});
}
};
});
/**
* Mixin class for creation of things like courses and libraries.
*/
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
function ($, _, gettext, ViewUtils) {
return function (selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors) {
var self = this;
this.selectors = selectors;
this.classes = classes;
this.validateRequiredField = ViewUtils.validateRequiredField;
this.validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
this.keyLengthViolationMessage = keyLengthViolationMessage;
// Key fields for your model, like [selectors.org, selectors.number]
this.keyFieldSelectors = keyFieldSelectors;
// Fields that must not be empty on your model.
this.nonEmptyCheckFieldSelectors = nonEmptyCheckFieldSelectors;
this.create = function (courseInfo, errorHandler) {
// Replace this with a function that will make a request to create the object.
};
// Ensure that key fields passes checkTotalKeyLengthViolations check
this.validateTotalKeyLength = function () {
ViewUtils.checkTotalKeyLengthViolations(
self.selectors, self.classes,
self.keyFieldSelectors,
self.keyLengthViolationMessage
);
};
this.toggleSaveButton = function (is_enabled) {
var is_disabled = !is_enabled;
$(self.selectors.save).toggleClass(self.classes.disabled, is_disabled).attr('aria-disabled', is_disabled);
};
this.setFieldInErr = function (element, message) {
if (message) {
element.addClass(self.classes.error);
element.children(self.selectors.tipError).addClass(self.classes.showing).removeClass(self.classes.hiding).text(message);
self.toggleSaveButton(false);
}
else {
element.removeClass(self.classes.error);
element.children(self.selectors.tipError).addClass(self.classes.hiding).removeClass(self.classes.showing);
// One "error" div is always present, but hidden or shown
if ($(self.selectors.error).length === 1) {
self.toggleSaveButton(true);
}
}
};
// One final check for empty values
this.hasInvalidRequiredFields = function () {
return _.reduce(
self.nonEmptyCheckFieldSelectors,
function (acc, element) {
var $element = $(element);
var error = self.validateRequiredField($element.val());
self.setFieldInErr($element.parent(), error);
return error ? true : acc;
},
false
);
};
// Ensure that all fields are not empty
this.validateFilledFields = function () {
return _.reduce(
self.nonEmptyCheckFieldSelectors,
function (acc, element) {
var $element = $(element);
return $element.val().length !== 0 ? acc : false;
},
true
);
};
// Handle validation asynchronously
this.configureHandlers = function () {
_.each(
self.keyFieldSelectors,
function (element) {
var $element = $(element);
$element.on('keyup', function (event) {
// Don't bother showing "required field" error when
// the user tabs into a new field; this is distracting
// and unnecessary
if (event.keyCode === $.ui.keyCode.TAB) {
return;
}
var error = self.validateURLItemEncoding($element.val(), $(self.selectors.allowUnicode).val() === 'True');
self.setFieldInErr($element.parent(), error);
self.validateTotalKeyLength();
if (!self.validateFilledFields()) {
self.toggleSaveButton(false);
}
});
}
);
var $name = $(self.selectors.name);
$name.on('keyup', function () {
var error = self.validateRequiredField($name.val());
self.setFieldInErr($name.parent(), error);
self.validateTotalKeyLength();
if (!self.validateFilledFields()) {
self.toggleSaveButton(false);
}
});
};
return {
validateTotalKeyLength: self.validateTotalKeyLength,
setFieldInErr: self.setFieldInErr,
hasInvalidRequiredFields: self.hasInvalidRequiredFields,
create: self.create,
validateFilledFields: self.validateFilledFields,
configureHandlers: self.configureHandlers
};
}
}
);
...@@ -5,7 +5,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -5,7 +5,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
function ($, _, gettext, NotificationView, PromptView) { function ($, _, gettext, NotificationView, PromptView) {
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation, var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset, 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. * Toggles the expanded state of the current element.
...@@ -173,6 +177,55 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -173,6 +177,55 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return false; 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 { return {
'toggleExpandCollapse': toggleExpandCollapse, 'toggleExpandCollapse': toggleExpandCollapse,
'showLoadingIndicator': showLoadingIndicator, 'showLoadingIndicator': showLoadingIndicator,
...@@ -186,6 +239,10 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -186,6 +239,10 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
'setScrollOffset': setScrollOffset, 'setScrollOffset': setScrollOffset,
'redirect': redirect, 'redirect': redirect,
'reload': reload, 'reload': reload,
'hasChangedAttributes': hasChangedAttributes 'hasChangedAttributes': hasChangedAttributes,
'validateRequiredField': validateRequiredField,
'validateURLItemEncoding': validateURLItemEncoding,
'validateTotalKeyLength': validateTotalKeyLength,
'checkTotalKeyLengthViolations': checkTotalKeyLengthViolations
}; };
}); });
...@@ -409,7 +409,6 @@ form { ...@@ -409,7 +409,6 @@ form {
// ==================== // ====================
.wrapper-create-element { .wrapper-create-element {
height: 0; height: 0;
margin-bottom: $baseline;
opacity: 0.0; opacity: 0.0;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;
...@@ -420,6 +419,7 @@ form { ...@@ -420,6 +419,7 @@ form {
&.is-shown { &.is-shown {
height: auto; // define a specific height for the animating version of this UI to work properly height: auto; // define a specific height for the animating version of this UI to work properly
margin-bottom: $baseline;
opacity: 1.0; opacity: 1.0;
pointer-events: auto; pointer-events: auto;
} }
......
// studio - elements - pagination
// ==========================
%pagination {
@include clearfix();
display: inline-block;
width: flex-grid(3, 12);
&.pagination-compact {
@include text-align(right);
}
&.pagination-full {
display: block;
width: flex-grid(4, 12);
margin: $baseline auto;
}
.nav-item {
position: relative;
display: inline-block;
}
.nav-link {
@include transition(all $tmg-f2 ease-in-out 0s);
display: block;
padding: ($baseline/4) ($baseline*0.75);
&.previous {
margin-right: ($baseline/2);
}
&.next {
margin-left: ($baseline/2);
}
&:hover {
background-color: $blue;
border-radius: 3px;
color: $white;
}
&.is-disabled {
background-color: transparent;
color: $gray-l2;
pointer-events: none;
}
}
.nav-label {
@extend %cont-text-sr;
}
.pagination-form,
.current-page,
.page-divider,
.total-pages {
display: inline-block;
}
.current-page,
.page-number-input,
.total-pages {
@extend %t-copy-base;
@extend %t-strong;
width: ($baseline*2.5);
margin: 0 ($baseline*0.75);
padding: ($baseline/4);
text-align: center;
color: $gray;
}
.current-page {
@extend %ui-depth1;
position: absolute;
@include left(-($baseline/4));
}
.page-divider {
@extend %t-title4;
@extend %t-regular;
vertical-align: middle;
color: $gray-l2;
}
.pagination-form {
@extend %ui-depth2;
position: relative;
.page-number-label,
.submit-pagination-form {
@extend %cont-text-sr;
}
.page-number-input {
@include transition(all $tmg-f2 ease-in-out 0s);
border: 1px solid transparent;
border-bottom: 1px dotted $gray-l2;
border-radius: 0;
box-shadow: none;
background: none;
&:hover {
background-color: $white;
opacity: 0.6;
}
&:focus {
// borrowing the base input focus styles to match overall app
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
opacity: 1.0;
box-shadow: 0 0 3px $shadow-d1 inset;
background-color: $white;
border: 1px solid transparent;
border-radius: 3px;
}
}
}
}
...@@ -28,120 +28,7 @@ ...@@ -28,120 +28,7 @@
} }
.pagination { .pagination {
@include clearfix; @extend %pagination;
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;
}
}
}
} }
.assets-table { .assets-table {
......
...@@ -103,6 +103,37 @@ ...@@ -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 //UI: default internal xblock content styles
......
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
// +Base - Elements // +Base - Elements
// ==================== // ====================
@import 'elements/typography'; @import 'elements/typography';
@import 'elements/pagination'; // pagination
@import 'elements/icons'; // references to icons used @import 'elements/icons'; // references to icons used
@import 'elements/controls'; // buttons, link styles, sliders, etc. @import 'elements/controls'; // buttons, link styles, sliders, etc.
@import 'elements/xblocks'; // studio rendering chrome for xblocks @import 'elements/xblocks'; // studio rendering chrome for xblocks
......
...@@ -40,6 +40,7 @@ ...@@ -40,6 +40,7 @@
// +Base - Elements // +Base - Elements
// ==================== // ====================
@import 'elements/typography'; @import 'elements/typography';
@import 'elements/pagination'; // pagination
@import 'elements/icons'; // references to icons used @import 'elements/icons'; // references to icons used
@import 'elements/controls'; // buttons, link styles, sliders, etc. @import 'elements/controls'; // buttons, link styles, sliders, etc.
@import 'elements/xblocks'; // studio rendering chrome for xblocks @import 'elements/xblocks'; // studio rendering chrome for xblocks
......
...@@ -237,13 +237,13 @@ ...@@ -237,13 +237,13 @@
} }
// location widget // location widget
.unit-location { .unit-location, .library-location {
@extend %bar-module; @extend %bar-module;
border-top: none; 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 %cont-text-wrap;
@extend %t-copy-sub1; @extend %t-copy-sub1;
display: inline-block; display: inline-block;
......
...@@ -289,10 +289,44 @@ ...@@ -289,10 +289,44 @@
// ==================== // ====================
// 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 // ELEM: course listings
.courses { .courses-tab, .libraries-tab {
margin: $baseline 0; display: none;
&.active {
display: block;
}
}
.courses, .libraries {
.title { .title {
@extend %t-title6; @extend %t-title6;
margin-bottom: $baseline; margin-bottom: $baseline;
...@@ -311,7 +345,6 @@ ...@@ -311,7 +345,6 @@
} }
.list-courses { .list-courses {
margin-top: $baseline;
border-radius: 3px; border-radius: 3px;
border: 1px solid $gray-l2; border: 1px solid $gray-l2;
background: $white; background: $white;
...@@ -464,26 +497,21 @@ ...@@ -464,26 +497,21 @@
.metadata-item { .metadata-item {
display: inline-block; display: inline-block;
&:after { & + .metadata-item:before {
content: "/"; content: "/";
margin-left: ($baseline/10); margin-left: ($baseline/10);
margin-right: ($baseline/10); margin-right: ($baseline/10);
color: $gray-l4; color: $gray-l4;
} }
&:last-child {
&:after {
content: "";
margin-left: 0;
margin-right: 0;
}
}
.label { .label {
@extend %cont-text-sr; @extend %cont-text-sr;
} }
} }
.extra-metadata {
margin-left: ($baseline/10);
}
} }
.course-actions { .course-actions {
...@@ -622,7 +650,7 @@ ...@@ -622,7 +650,7 @@
// course listings // course listings
.create-course { .create-course, .create-library {
.row { .row {
@include clearfix(); @include clearfix();
......
...@@ -116,11 +116,16 @@ ...@@ -116,11 +116,16 @@
&.flag-role-admin { &.flag-role-admin {
background: $pink; background: $pink;
} }
&.flag-role-user {
background: $yellow-d1;
.msg-you { color: $yellow-l1; }
}
} }
// ELEM: item - metadata // ELEM: item - metadata
.item-metadata { .item-metadata {
width: flex-grid(5, 9); width: flex-grid(4, 9);
@include margin-right(flex-gutter()); @include margin-right(flex-gutter());
.user-username, .user-email { .user-username, .user-email {
...@@ -143,7 +148,7 @@ ...@@ -143,7 +148,7 @@
// ELEM: item - actions // ELEM: item - actions
.item-actions { .item-actions {
width: flex-grid(4, 9); width: flex-grid(5, 9);
position: static; // nasty reset needed due to base.scss position: static; // nasty reset needed due to base.scss
text-align: right; text-align: right;
...@@ -153,12 +158,34 @@ ...@@ -153,12 +158,34 @@
} }
.action-role { .action-role {
width: flex-grid(3, 4); width: flex-grid(7, 8);
margin-right: flex-gutter(); 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 { .action-delete {
width: flex-grid(1, 4); width: flex-grid(1, 8);
// STATE: disabled // STATE: disabled
&.is-disabled { &.is-disabled {
...@@ -178,33 +205,6 @@ ...@@ -178,33 +205,6 @@
float: none; float: none;
color: inherit; 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 // STATE: hover
......
...@@ -21,6 +21,8 @@ ...@@ -21,6 +21,8 @@
% if context_course: % if context_course:
<% ctx_loc = context_course.location %> <% ctx_loc = context_course.location %>
${context_course.display_name_with_default | h} | ${context_course.display_name_with_default | h} |
% elif context_library:
${context_library.display_name_with_default | h} |
% endif % endif
${settings.STUDIO_NAME} ${settings.STUDIO_NAME}
</title> </title>
......
...@@ -18,13 +18,6 @@ from django.utils.translation import ugettext as _ ...@@ -18,13 +18,6 @@ from django.utils.translation import ugettext as _
<%namespace name='static' file='static_content.html'/> <%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"> <%block name="header_extras">
% for template_name in templates: % for template_name in templates:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
...@@ -38,7 +31,11 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal", ...@@ -38,7 +31,11 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
require(["js/factories/container"], function(ContainerFactory) { require(["js/factories/container"], function(ContainerFactory) {
ContainerFactory( ContainerFactory(
${component_templates | n}, ${json.dumps(xblock_info) | n}, ${component_templates | n}, ${json.dumps(xblock_info) | n},
"${action}", ${json.dumps(is_unit_page)} "${action}",
{
isUnitPage: ${json.dumps(is_unit_page)},
canEdit: true
}
); );
}); });
</%block> </%block>
...@@ -107,7 +104,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal", ...@@ -107,7 +104,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
</div> </div>
</article> </article>
<aside class="content-supplementary" role="complementary"> <aside class="content-supplementary" role="complementary">
% if not is_unit_page: % if xblock.category == 'split_test':
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("Adding components")}</h3> <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> <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", ...@@ -123,8 +120,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
<div class="bit external-help"> <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> <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> </div>
% endif % elif is_unit_page:
% if is_unit_page:
<div id="publish-unit"></div> <div id="publish-unit"></div>
<div id="publish-history" class="unit-publish-history"></div> <div id="publish-history" class="unit-publish-history"></div>
<div class="unit-location is-hidden"> <div class="unit-location is-hidden">
......
...@@ -33,6 +33,12 @@ ...@@ -33,6 +33,12 @@
</a> </a>
</li> </li>
% endif % 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> </ul>
</nav> </nav>
</header> </header>
......
<div class="wrapper-mast wrapper"> <div class="wrapper-mast wrapper">
<header class="mast has-actions"> <header class="mast has-actions">
<h1 class="page-header">My Courses</h1> <h1 class="page-header">Studio Home</h1>
<nav class="nav-actions"> <nav class="nav-actions">
<h3 class="sr">Page Actions</h3> <h3 class="sr">Page Actions</h3>
<ul> <ul>
...@@ -8,6 +8,10 @@ ...@@ -8,6 +8,10 @@
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i> <a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
New Course</a> New Course</a>
</li> </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> </ul>
</nav> </nav>
</header> </header>
...@@ -17,13 +21,6 @@ ...@@ -17,13 +21,6 @@
<section class="content"> <section class="content">
<article class="content-primary" role="main"> <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"> <div class="wrapper-create-element wrapper-create-course">
<form class="form-create create-course course-info" id="create-course-form" name="create-course-form"> <form class="form-create create-course course-info" id="create-course-form" name="create-course-form">
<div class="wrap-error"> <div class="wrap-error">
...@@ -78,6 +75,53 @@ ...@@ -78,6 +75,53 @@
</form> </form>
</div> </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 --> <!-- STATE: processing courses -->
<div class="courses courses-processing"> <div class="courses courses-processing">
<h3 class="title">Courses Being Processed</h3> <h3 class="title">Courses Being Processed</h3>
...@@ -163,6 +207,15 @@ ...@@ -163,6 +207,15 @@
</li> </li>
</ul> </ul>
</div> </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> </article>
</section> </section>
</div> </div>
<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>
<div class="studio-xblock-wrapper">
<header class="xblock-header">
<div class="header-details">
<span class="xblock-display-name">Mock XBlock</span>
</div>
<div class="header-actions">
<ul class="actions-list">
</ul>
<a href="#" class="button action-button notification-action-button" data-notification-action="add-missing-groups">
<span class="action-button-text">Add Missing Groups</span>
</a>
</div>
</header>
<article class="xblock-render">
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
data-type="None">
<p>Mock XBlock</p>
</div>
</article>
</div>
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
id="<%= type %>-<%= intent %>" id="<%= type %>-<%= intent %>"
aria-hidden="<% if(obj.shown) { %>false<% } else { %>true<% } %>" aria-hidden="<% if(obj.shown) { %>false<% } else { %>true<% } %>"
aria-labelledby="<%= type %>-<%= intent %>-title" aria-labelledby="<%= type %>-<%= intent %>-title"
tabindex="-1"
<% if (obj.message) { %>aria-describedby="<%= type %>-<%= intent %>-description" <% } %> <% if (obj.message) { %>aria-describedby="<%= type %>-<%= intent %>-description" <% } %>
<% if (obj.actions) { %>role="dialog"<% } %> <% if (obj.actions) { %>role="dialog"<% } %>
> >
......
<%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>
<%! 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>
...@@ -55,31 +55,39 @@ messages = json.dumps(xblock.validate().to_json()) ...@@ -55,31 +55,39 @@ messages = json.dumps(xblock.validate().to_json())
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
% if not is_root: % if not is_root:
% if not show_inline: % if can_edit:
<li class="action-item action-edit"> % if not show_inline:
<a href="#" class="edit-button action-button"> <li class="action-item action-edit">
<i class="icon fa fa-pencil"></i> <a href="#" class="edit-button action-button">
<span class="action-button-text">${_("Edit")}</span> <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> </a>
</li> </li>
<li class="action-item action-duplicate"> % if is_reorderable:
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button"> <li class="action-item action-drag">
<i class="icon fa fa-copy"></i> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
<span class="sr">${_("Duplicate")}</span> </li>
% endif
% elif not show_inline:
<li class="action-item action-edit action-edit-view-only">
<a href="#" class="edit-button action-button">
<span class="action-button-text">${_("Details")}</span>
</a> </a>
</li> </li>
% endif % 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 % endif
</ul> </ul>
</div> </div>
......
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
</h2> </h2>
<nav class="nav-course nav-dd ui-left"> <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> <ol>
<li class="nav-item nav-course-courseware"> <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> <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 @@ ...@@ -132,6 +132,38 @@
</li> </li>
</ol> </ol>
</nav> </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 % endif
</div> </div>
...@@ -151,7 +183,7 @@ ...@@ -151,7 +183,7 @@
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-account-dashboard"> <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>
<li class="nav-item nav-account-signout"> <li class="nav-item nav-account-signout">
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a> <a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
......
...@@ -5,6 +5,13 @@ from django.conf.urls import patterns, include, url ...@@ -5,6 +5,13 @@ from django.conf.urls import patterns, include, url
from ratelimitbackend import admin from ratelimitbackend import admin
admin.autodiscover() 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 urlpatterns = patterns('', # nopep8
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'), url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
...@@ -66,12 +73,13 @@ urlpatterns += patterns( ...@@ -66,12 +73,13 @@ urlpatterns += patterns(
url(r'^signin$', 'login_page', name='login'), url(r'^signin$', 'login_page', name='login'),
url(r'^request_course_creator$', 'request_course_creator'), 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/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_info_handler'),
url( url(
r'^course_info_update/{}/(?P<provided_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN), r'^course_info_update/{}/(?P<provided_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN),
'course_info_update_handler' '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/{}?$'.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_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'), url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
...@@ -112,6 +120,14 @@ urlpatterns += patterns( ...@@ -112,6 +120,14 @@ urlpatterns += patterns(
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict), 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'): if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
urlpatterns += (url( urlpatterns += (url(
r'^export_git/{}$'.format( r'^export_git/{}$'.format(
......
...@@ -6,9 +6,18 @@ to decide whether to check course creator role, and other such functions. ...@@ -6,9 +6,18 @@ to decide whether to check course creator role, and other such functions.
""" """
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.conf import settings from django.conf import settings
from opaque_keys.edx.locator import LibraryLocator
from student.roles import GlobalStaff, CourseCreatorRole, CourseStaffRole, CourseInstructorRole, CourseRole, \ 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): def has_access(user, role):
...@@ -40,9 +49,36 @@ def has_access(user, role): ...@@ -40,9 +49,36 @@ def has_access(user, role):
return False return False
def has_course_author_access(user, course_key, role=CourseStaffRole): def get_user_permissions(user, course_key, org=None):
"""
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.
""" """
Return True if user has studio (write) access to the given course. 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. Note that the CMS permissions model is with respect to courses.
There is a super-admin permissions if user.is_staff is set. There is a super-admin permissions if user.is_staff is set.
Also, since we're unifying the user database between LMS and CAS, 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): ...@@ -52,16 +88,26 @@ def has_course_author_access(user, course_key, role=CourseStaffRole):
:param user: :param user:
:param course_key: a CourseKey :param course_key: a CourseKey
:param role: an AccessRole
""" """
if GlobalStaff().has_user(user): return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key))
return True
if OrgInstructorRole(org=course_key.org).has_user(user):
return True def has_course_author_access(user, course_key):
if OrgStaffRole(org=course_key.org).has_user(user): """
return True Old name for has_studio_write_access
# 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 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): def add_users(caller, role, *users):
......
...@@ -227,6 +227,17 @@ class CourseBetaTesterRole(CourseRole): ...@@ -227,6 +227,17 @@ class CourseBetaTesterRole(CourseRole):
super(CourseBetaTesterRole, self).__init__(self.ROLE, *args, **kwargs) 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): class OrgStaffRole(OrgRole):
"""An organization staff member""" """An organization staff member"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
...@@ -239,6 +250,17 @@ class OrgInstructorRole(OrgRole): ...@@ -239,6 +250,17 @@ class OrgInstructorRole(OrgRole):
super(OrgInstructorRole, self).__init__('instructor', *args, **kwargs) 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): class CourseCreatorRole(RoleBase):
""" """
This is the group of people who have permission to create new courses (we may want to eventually This is the group of people who have permission to create new courses (we may want to eventually
......
...@@ -482,7 +482,7 @@ class LoginOAuthTokenMixin(object): ...@@ -482,7 +482,7 @@ class LoginOAuthTokenMixin(object):
self._setup_user_response(success=True) self._setup_user_response(success=True)
response = self.client.post(self.url, {"access_token": "dummy"}) response = self.client.post(self.url, {"access_token": "dummy"})
self.assertEqual(response.status_code, 204) 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): def test_invalid_token(self):
self._setup_user_response(success=False) self._setup_user_response(success=False)
......
...@@ -1745,6 +1745,7 @@ def auto_auth(request): ...@@ -1745,6 +1745,7 @@ def auto_auth(request):
* `staff`: Set to "true" to make the user global staff. * `staff`: Set to "true" to make the user global staff.
* `course_id`: Enroll the student in the course with `course_id` * `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` * `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 If username, email, or password are not provided, use
randomly generated credentials. randomly generated credentials.
...@@ -1764,6 +1765,7 @@ def auto_auth(request): ...@@ -1764,6 +1765,7 @@ def auto_auth(request):
if course_id: if course_id:
course_key = CourseLocator.from_string(course_id) course_key = CourseLocator.from_string(course_id)
role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()] 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 # Get or create the user object
post_data = { post_data = {
...@@ -1807,14 +1809,16 @@ def auto_auth(request): ...@@ -1807,14 +1809,16 @@ def auto_auth(request):
user.roles.add(role) user.roles.add(role)
# Log in as the user # Log in as the user
user = authenticate(username=username, password=password) if login_when_done:
login(request, user) user = authenticate(username=username, password=password)
login(request, user)
create_comments_service_user(user) create_comments_service_user(user)
# Provide the user with a valid CSRF token # Provide the user with a valid CSRF token
# then return a 200 response # 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 username, email, password, user.id
) )
response = HttpResponse(success_msg) response = HttpResponse(success_msg)
......
...@@ -28,27 +28,27 @@ GLOBAL_WAIT_FOR_TIMEOUT = 60 ...@@ -28,27 +28,27 @@ GLOBAL_WAIT_FOR_TIMEOUT = 60
REQUIREJS_WAIT = { REQUIREJS_WAIT = {
# Settings - Schedule & Details # Settings - Schedule & Details
re.compile('^Schedule & Details Settings \|'): [ re.compile(r'^Schedule & Details Settings \|'): [
"jquery", "js/base", "js/models/course", "jquery", "js/base", "js/models/course",
"js/models/settings/course_details", "js/views/settings/main"], "js/models/settings/course_details", "js/views/settings/main"],
# Settings - Advanced Settings # Settings - Advanced Settings
re.compile('^Advanced Settings \|'): [ re.compile(r'^Advanced Settings \|'): [
"jquery", "js/base", "js/models/course", "js/models/settings/advanced", "jquery", "js/base", "js/models/course", "js/models/settings/advanced",
"js/views/settings/advanced", "codemirror"], "js/views/settings/advanced", "codemirror"],
# Unit page # Unit page
re.compile('^Unit \|'): [ re.compile(r'^Unit \|'): [
"jquery", "js/base", "js/models/xblock_info", "js/views/pages/container", "jquery", "js/base", "js/models/xblock_info", "js/views/pages/container",
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
# Content - Outline # Content - Outline
# Note that calling your org, course number, or display name, 'course' will mess this up # 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"], "js/base", "js/models/course", "js/models/location", "js/models/section"],
# Dashboard # Dashboard
re.compile('^My Courses \|'): [ re.compile(r'^Studio Home \|'): [
"js/sock", "gettext", "js/base", "js/sock", "gettext", "js/base",
"jquery.ui", "coffee/src/main", "underscore"], "jquery.ui", "coffee/src/main", "underscore"],
...@@ -59,7 +59,7 @@ REQUIREJS_WAIT = { ...@@ -59,7 +59,7 @@ REQUIREJS_WAIT = {
], ],
# Pages # Pages
re.compile('^Pages \|'): [ re.compile(r'^Pages \|'): [
'js/models/explicit_url', 'coffee/src/views/tabs', 'js/models/explicit_url', 'coffee/src/views/tabs',
'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1'
], ],
......
...@@ -58,6 +58,8 @@ registry = TagRegistry() ...@@ -58,6 +58,8 @@ registry = TagRegistry()
CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name CorrectMap = correctmap.CorrectMap # pylint: disable=invalid-name
CORRECTMAP_PY = None CORRECTMAP_PY = None
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Exceptions # Exceptions
...@@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse): ...@@ -439,6 +441,7 @@ class JavascriptResponse(LoncapaResponse):
Javascript using Node.js. Javascript using Node.js.
""" """
human_name = _('JavaScript Input')
tags = ['javascriptresponse'] tags = ['javascriptresponse']
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['javascriptinput'] allowed_inputfields = ['javascriptinput']
...@@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse): ...@@ -684,6 +687,7 @@ class ChoiceResponse(LoncapaResponse):
""" """
human_name = _('Checkboxes')
tags = ['choiceresponse'] tags = ['choiceresponse']
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['checkboxgroup', 'radiogroup'] allowed_inputfields = ['checkboxgroup', 'radiogroup']
...@@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -754,6 +758,7 @@ class MultipleChoiceResponse(LoncapaResponse):
""" """
# TODO: handle direction and randomize # TODO: handle direction and randomize
human_name = _('Multiple Choice')
tags = ['multiplechoiceresponse'] tags = ['multiplechoiceresponse']
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['choicegroup'] allowed_inputfields = ['choicegroup']
...@@ -1042,6 +1047,7 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -1042,6 +1047,7 @@ class MultipleChoiceResponse(LoncapaResponse):
@registry.register @registry.register
class TrueFalseResponse(MultipleChoiceResponse): class TrueFalseResponse(MultipleChoiceResponse):
human_name = _('True/False Choice')
tags = ['truefalseresponse'] tags = ['truefalseresponse']
def mc_setup_response(self): def mc_setup_response(self):
...@@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse): ...@@ -1073,6 +1079,7 @@ class OptionResponse(LoncapaResponse):
TODO: handle direction and randomize TODO: handle direction and randomize
""" """
human_name = _('Dropdown')
tags = ['optionresponse'] tags = ['optionresponse']
hint_tag = 'optionhint' hint_tag = 'optionhint'
allowed_inputfields = ['optioninput'] allowed_inputfields = ['optioninput']
...@@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse): ...@@ -1108,6 +1115,7 @@ class NumericalResponse(LoncapaResponse):
to a number (e.g. `4+5/2^2`), and accepts with a tolerance. to a number (e.g. `4+5/2^2`), and accepts with a tolerance.
""" """
human_name = _('Numerical Input')
tags = ['numericalresponse'] tags = ['numericalresponse']
hint_tag = 'numericalhint' hint_tag = 'numericalhint'
allowed_inputfields = ['textline', 'formulaequationinput'] allowed_inputfields = ['textline', 'formulaequationinput']
...@@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse): ...@@ -1308,6 +1316,7 @@ class StringResponse(LoncapaResponse):
</hintgroup> </hintgroup>
</stringresponse> </stringresponse>
""" """
human_name = _('Text Input')
tags = ['stringresponse'] tags = ['stringresponse']
hint_tag = 'stringhint' hint_tag = 'stringhint'
allowed_inputfields = ['textline'] allowed_inputfields = ['textline']
...@@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse): ...@@ -1426,6 +1435,7 @@ class CustomResponse(LoncapaResponse):
or in a <script>...</script> or in a <script>...</script>
""" """
human_name = _('Custom Evaluated Script')
tags = ['customresponse'] tags = ['customresponse']
allowed_inputfields = ['textline', 'textbox', 'crystallography', allowed_inputfields = ['textline', 'textbox', 'crystallography',
...@@ -1800,6 +1810,7 @@ class SymbolicResponse(CustomResponse): ...@@ -1800,6 +1810,7 @@ class SymbolicResponse(CustomResponse):
Symbolic math response checking, using symmath library. Symbolic math response checking, using symmath library.
""" """
human_name = _('Symbolic Math Input')
tags = ['symbolicresponse'] tags = ['symbolicresponse']
max_inputfields = 1 max_inputfields = 1
...@@ -1868,6 +1879,7 @@ class CodeResponse(LoncapaResponse): ...@@ -1868,6 +1879,7 @@ class CodeResponse(LoncapaResponse):
""" """
human_name = _('Code Input')
tags = ['coderesponse'] tags = ['coderesponse']
allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput'] allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
max_inputfields = 1 max_inputfields = 1
...@@ -2145,6 +2157,7 @@ class ExternalResponse(LoncapaResponse): ...@@ -2145,6 +2157,7 @@ class ExternalResponse(LoncapaResponse):
""" """
human_name = _('External Grader')
tags = ['externalresponse'] tags = ['externalresponse']
allowed_inputfields = ['textline', 'textbox'] allowed_inputfields = ['textline', 'textbox']
awdmap = { awdmap = {
...@@ -2302,6 +2315,7 @@ class FormulaResponse(LoncapaResponse): ...@@ -2302,6 +2315,7 @@ class FormulaResponse(LoncapaResponse):
Checking of symbolic math response using numerical sampling. Checking of symbolic math response using numerical sampling.
""" """
human_name = _('Math Expression Input')
tags = ['formularesponse'] tags = ['formularesponse']
hint_tag = 'formulahint' hint_tag = 'formulahint'
allowed_inputfields = ['textline', 'formulaequationinput'] allowed_inputfields = ['textline', 'formulaequationinput']
...@@ -2514,6 +2528,7 @@ class SchematicResponse(LoncapaResponse): ...@@ -2514,6 +2528,7 @@ class SchematicResponse(LoncapaResponse):
""" """
Circuit schematic response type. Circuit schematic response type.
""" """
human_name = _('Circuit Schematic Builder')
tags = ['schematicresponse'] tags = ['schematicresponse']
allowed_inputfields = ['schematic'] allowed_inputfields = ['schematic']
...@@ -2592,6 +2607,7 @@ class ImageResponse(LoncapaResponse): ...@@ -2592,6 +2607,7 @@ class ImageResponse(LoncapaResponse):
True, if click is inside any region or rectangle. Otherwise False. True, if click is inside any region or rectangle. Otherwise False.
""" """
human_name = _('Image Mapped Input')
tags = ['imageresponse'] tags = ['imageresponse']
allowed_inputfields = ['imageinput'] allowed_inputfields = ['imageinput']
...@@ -2710,6 +2726,7 @@ class AnnotationResponse(LoncapaResponse): ...@@ -2710,6 +2726,7 @@ class AnnotationResponse(LoncapaResponse):
The response contains both a comment (student commentary) and an option (student tag). 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. Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
""" """
human_name = _('Annotation Input')
tags = ['annotationresponse'] tags = ['annotationresponse']
allowed_inputfields = ['annotationinput'] allowed_inputfields = ['annotationinput']
max_inputfields = 1 max_inputfields = 1
...@@ -2834,6 +2851,7 @@ class ChoiceTextResponse(LoncapaResponse): ...@@ -2834,6 +2851,7 @@ class ChoiceTextResponse(LoncapaResponse):
ChoiceResponse. ChoiceResponse.
""" """
human_name = _('Checkboxes With Text Input')
tags = ['choicetextresponse'] tags = ['choicetextresponse']
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['choicetextgroup', allowed_inputfields = ['choicetextgroup',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment