Commit 3e0f08eb by Braden MacDonald Committed by E. Kolpakov

Studio support for creating and editing libraries (PR 6046)

SOL-1, SOL-2, SOL-3
parent 80e0d56a
......@@ -296,6 +296,13 @@ def reverse_course_url(handler_name, course_key, kwargs=None):
return reverse_url(handler_name, 'course_key_string', course_key, kwargs)
def reverse_library_url(handler_name, library_key, kwargs=None):
"""
Creates the URL for handlers that use library_keys as URL parameters.
"""
return reverse_url(handler_name, 'library_key_string', library_key, kwargs)
def reverse_usage_url(handler_name, usage_key, kwargs=None):
"""
Creates the URL for handlers that use usage_keys as URL parameters.
......
......@@ -12,6 +12,7 @@ from .error import *
from .helpers import *
from .item import *
from .import_export import *
from .library import *
from .preview import *
from .public import *
from .export_git import *
......
......@@ -56,6 +56,15 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES
CONTAINER_TEMPATES = [
"basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message"
]
def _advanced_component_types():
"""
Return advanced component types which can be created.
......@@ -202,6 +211,7 @@ def container_handler(request, usage_key_string):
'xblock_info': xblock_info,
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'templates': CONTAINER_TEMPATES
})
else:
return HttpResponseBadRequest("Only supports HTML requests")
......
......@@ -38,6 +38,7 @@ from contentstore.utils import (
add_extra_panel_tab,
remove_extra_panel_tab,
reverse_course_url,
reverse_library_url,
reverse_usage_url,
reverse_url,
remove_all_instructors,
......@@ -56,6 +57,7 @@ from .component import (
ADVANCED_COMPONENT_TYPES,
)
from contentstore.tasks import rerun_course
from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils
......@@ -341,6 +343,14 @@ def _accessible_courses_list_from_groups(request):
return courses_list.values(), in_process_course_actions
def _accessible_libraries_list(user):
"""
List all libraries available to the logged in user by iterating through all libraries
"""
# No need to worry about ErrorDescriptors - split's get_libraries() never returns them.
return [lib for lib in modulestore().get_libraries() if has_course_author_access(user, lib.location)]
@login_required
@ensure_csrf_cookie
def course_listing(request):
......@@ -360,6 +370,8 @@ def course_listing(request):
# so fallback to iterating through all courses
courses, in_process_course_actions = _accessible_courses_list(request)
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
def format_course_for_view(course):
"""
Return a dict of the data which the view requires for each course
......@@ -396,6 +408,18 @@ def course_listing(request):
) if uca.state == CourseRerunUIStateManager.State.FAILED else ''
}
def format_library_for_view(library):
"""
Return a dict of the data which the view requires for each library
"""
return {
'display_name': library.display_name,
'library_key': unicode(library.location.library_key),
'url': reverse_library_url('library_handler', unicode(library.location.library_key)),
'org': library.display_org_with_default,
'number': library.display_number_with_default,
}
# remove any courses in courses that are also in the in_process_course_actions list
in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
courses = [
......@@ -409,6 +433,8 @@ def course_listing(request):
return render_to_response('index.html', {
'courses': courses,
'in_process_course_actions': in_process_course_actions,
'libraries_enabled': LIBRARIES_ENABLED,
'libraries': [format_library_for_view(lib) for lib in libraries],
'user': request.user,
'request_course_creator_url': reverse('contentstore.views.request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
......
......@@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_string, render_to_response
from xblock.core import XBlock
from xmodule.modulestore.django import modulestore
from contentstore.utils import reverse_course_url, reverse_usage_url
from contentstore.utils import reverse_course_url, reverse_library_url, reverse_usage_url
__all__ = ['edge', 'event', 'landing']
......@@ -106,6 +106,9 @@ def xblock_studio_url(xblock, parent_xblock=None):
url=reverse_course_url('course_handler', xblock.location.course_key),
usage_key=urllib.quote(unicode(xblock.location))
)
elif category == 'library':
library_key = xblock.location.course_key
return reverse_library_url('library_handler', library_key)
else:
return reverse_usage_url('container_handler', xblock.location)
......
......@@ -47,6 +47,7 @@ from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
......@@ -660,7 +661,9 @@ def _get_module_info(xblock, rewrite_static_links=True):
)
# Pre-cache has changes for the entire course because we'll need it for the ancestor info
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
# Except library blocks which don't [yet] use draft/publish
if not isinstance(xblock.location, LibraryUsageLocator):
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
# Note that children aren't being returned until we have a use case.
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
......@@ -701,12 +704,16 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
return None
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
# this should not be calculated for Sections and Subsections on Unit page
has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) else None
# this should not be calculated for Sections and Subsections on Unit page or for library blocks
has_changes = modulestore().has_changes(xblock) if (is_xblock_unit or course_outline) and not is_library_block else None
if graders is None:
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
if not is_library_block:
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
else:
graders = []
# Compute the child info first so it can be included in aggregate information for the parent
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
......@@ -726,7 +733,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
visibility_state = None
published = modulestore().has_published_version(xblock)
published = modulestore().has_published_version(xblock) if not is_library_block else None
xblock_info = {
"id": unicode(xblock.location),
......@@ -734,7 +741,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"category": xblock.category,
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
"published": published,
"published_on": get_default_time_display(xblock.published_on) if xblock.published_on else None,
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
"release_date": release_date,
......
"""
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
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 has_course_author_access
from student.roles import CourseCreatorRole
from student import auth
from util.json_request import expect_json, JsonResponse, JsonResponseBadRequest
__all__ = ['library_handler']
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_course_author_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, 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_course_author_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},
)
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 either organization or library code to be unique.'
)
})
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, 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.
"""
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.course_id),
"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],
})
xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
component_templates = get_component_templates(library)
return render_to_response('library.html', {
'context_library': library,
'component_templates': json.dumps(component_templates),
'xblock_info': xblock_info,
'templates': CONTAINER_TEMPATES
})
......@@ -6,7 +6,7 @@ import lxml
import datetime
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, add_instructor
from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor
from student.auth import has_course_author_access
from contentstore.views.course import course_outline_initial_state
from contentstore.views.item import create_xblock_info, VisibilityState
......@@ -14,7 +14,7 @@ from course_action_state.models import CourseRerunState
from util.date_utils import get_default_time_display
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
from opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory
from course_action_state.managers import CourseRerunUIStateManager
......@@ -61,6 +61,27 @@ class TestCourseIndex(CourseTestCase):
course_menu_link = outline_parsed.find_class('nav-course-courseware-outline')[0]
self.assertEqual(course_menu_link.find("a").get("href"), link.get("href"))
def test_libraries_on_course_index(self):
"""
Test getting the list of libraries from the course listing page
"""
# Add a library:
lib1 = LibraryFactory.create()
index_url = '/course/'
index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
parsed_html = lxml.html.fromstring(index_response.content)
library_link_elements = parsed_html.find_class('library-link')
self.assertEqual(len(library_link_elements), 1)
link = library_link_elements[0]
self.assertEqual(
link.get("href"),
reverse_library_url('library_handler', lib1.location.library_key),
)
# now test that url
outline_response = self.client.get(link.get("href"), {}, HTTP_ACCEPT='text/html')
self.assertEqual(outline_response.status_code, 200)
def test_is_staff_access(self):
"""
Test that people with is_staff see the courses and can navigate into them
......
......@@ -4,7 +4,7 @@ Unit tests for helpers.py.
from contentstore.tests.utils import CourseTestCase
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory
from django.utils import http
......@@ -50,6 +50,11 @@ class HelpersTestCase(CourseTestCase):
display_name="My Video")
self.assertIsNone(xblock_studio_url(video))
# Verify library URL
library = LibraryFactory.create()
expected_url = u'/library/{}'.format(unicode(library.location.library_key))
self.assertEqual(xblock_studio_url(library), expected_url)
def test_xblock_type_display_name(self):
# Verify chapter type display name
......
......@@ -24,7 +24,8 @@ from student.tests.factories import UserFactory
from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xblock.exceptions import NoSuchHandlerError
from opaque_keys.edx.keys import UsageKey, CourseKey
......@@ -1420,6 +1421,54 @@ class TestXBlockInfo(ItemTest):
self.assertIsNone(xblock_info.get('edited_by', None))
class TestLibraryXBlockInfo(ModuleStoreTestCase):
"""
Unit tests for XBlock Info for XBlocks in a content library
"""
def setUp(self):
super(TestLibraryXBlockInfo, self).setUp()
user_id = self.user.id
self.library = LibraryFactory.create()
self.top_level_html = ItemFactory.create(
parent_location=self.library.location, category='html', user_id=user_id, publish_item=False
)
self.vertical = ItemFactory.create(
parent_location=self.library.location, category='vertical', user_id=user_id, publish_item=False
)
self.child_html = ItemFactory.create(
parent_location=self.vertical.location, category='html', display_name='Test HTML Child Block', user_id=user_id, publish_item=False
)
def test_lib_xblock_info(self):
html_block = modulestore().get_item(self.top_level_html.location)
xblock_info = create_xblock_info(html_block)
self.validate_component_xblock_info(xblock_info, html_block)
self.assertIsNone(xblock_info.get('child_info', None))
def test_lib_child_xblock_info(self):
html_block = modulestore().get_item(self.child_html.location)
xblock_info = create_xblock_info(html_block, include_ancestor_info=True, include_child_info=True)
self.validate_component_xblock_info(xblock_info, html_block)
self.assertIsNone(xblock_info.get('child_info', None))
ancestors = xblock_info['ancestor_info']['ancestors']
self.assertEqual(len(ancestors), 2)
self.assertEqual(ancestors[0]['category'], 'vertical')
self.assertEqual(ancestors[0]['id'], unicode(self.vertical.location))
self.assertEqual(ancestors[1]['category'], 'library')
def validate_component_xblock_info(self, xblock_info, original_block):
"""
Validate that the xblock info is correct for the test component.
"""
self.assertEqual(xblock_info['category'], original_block.category)
self.assertEqual(xblock_info['id'], unicode(original_block.location))
self.assertEqual(xblock_info['display_name'], original_block.display_name)
self.assertIsNone(xblock_info.get('has_changes', None))
self.assertIsNone(xblock_info.get('published', None))
self.assertIsNone(xblock_info.get('published_on', None))
self.assertIsNone(xblock_info.get('graders', None))
class TestXBlockPublishingInfo(ItemTest):
"""
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 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
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)
......@@ -72,7 +72,8 @@
"SUBDOMAIN_BRANDING": false,
"SUBDOMAIN_COURSE_LISTINGS": false,
"ALLOW_ALL_ADVANCED_COMPONENTS": true,
"ALLOW_COURSE_RERUNS": true
"ALLOW_COURSE_RERUNS": true,
"ENABLE_CONTENT_LIBRARIES": true
},
"FEEDBACK_SUBMISSION_EMAIL": "",
"GITHUB_REPO_ROOT": "** OVERRIDDEN **",
......
......@@ -226,3 +226,6 @@ FEATURES['USE_MICROSITES'] = True
# For consistency in user-experience, keep the value of this setting in sync with
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
define([
'jquery', 'js/models/xblock_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson) {
var templates = new ComponentTemplates(componentTemplates, {parse: true}),
mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true});
xmoduleLoader.done(function () {
var view = new ContainerPage({
el: $('#content'),
model: mainXBlockInfo,
action: "view",
templates: templates,
isUnitPage: false
});
view.render();
});
};
});
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
"js/views/utils/view_utils"],
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) {
"js/views/utils/create_library_utils", "js/views/utils/view_utils"],
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, CreateLibraryUtilsFactory, ViewUtils) {
"use strict";
var CreateCourseUtils = CreateCourseUtilsFactory({
name: '.new-course-name',
org: '.new-course-org',
number: '.new-course-number',
run: '.new-course-run',
save: '.new-course-save',
errorWrapper: '.wrap-error',
errorWrapper: '.create-course .wrap-error',
errorMessage: '#course_creation_error',
tipError: 'span.tip-error',
error: '.error',
tipError: '.create-course span.tip-error',
error: '.create-course .error',
allowUnicode: '.allow-unicode-course-id'
}, {
shown: 'is-shown',
......@@ -20,6 +21,24 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
error: 'error'
});
var CreateLibraryUtils = CreateLibraryUtilsFactory({
name: '.new-library-name',
org: '.new-library-org',
number: '.new-library-number',
save: '.new-library-save',
errorWrapper: '.create-library .wrap-error',
errorMessage: '#library_creation_error',
tipError: '.create-library span.tip-error',
error: '.create-library .error',
allowUnicode: '.allow-unicode-library-id'
}, {
shown: 'is-shown',
showing: 'is-showing',
hiding: 'is-hiding',
disabled: 'is-disabled',
error: 'error'
});
var saveNewCourse = function (e) {
e.preventDefault();
......@@ -33,7 +52,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
var number = $newCourseForm.find('.new-course-number').val();
var run = $newCourseForm.find('.new-course-run').val();
course_info = {
var course_info = {
org: org,
number: number,
display_name: display_name,
......@@ -42,26 +61,23 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
analytics.track('Created a Course', course_info);
CreateCourseUtils.createCourse(course_info, function (errorMessage) {
$('.wrap-error').addClass('is-shown');
$('.create-course .wrap-error').addClass('is-shown');
$('#course_creation_error').html('<p>' + errorMessage + '</p>');
$('.new-course-save').addClass('is-disabled').attr('aria-disabled', true);
});
};
var cancelNewCourse = function (e) {
e.preventDefault();
$('.new-course-button').removeClass('is-disabled').attr('aria-disabled', false);
$('.wrapper-create-course').removeClass('is-shown');
// Clear out existing fields and errors
_.each(
['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'],
function (field) {
$(field).val('');
}
);
$('#course_creation_error').html('');
$('.wrap-error').removeClass('is-shown');
$('.new-course-save').off('click');
var makeCancelHandler = function (addType) {
return function(e) {
e.preventDefault();
$('.new-'+addType+'-button').removeClass('is-disabled').attr('aria-disabled', false);;
$('.wrapper-create-'+addType).removeClass('is-shown');
// Clear out existing fields and errors
$('#create-'+addType+'-form input[type=text]').val('');
$('#'+addType+'_creation_error').html('');
$('.create-'+addType+' .wrap-error').removeClass('is-shown');
$('.new-'+addType+'-save').off('click');
};
};
var addNewCourse = function (e) {
......@@ -73,18 +89,70 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
var $courseName = $('.new-course-name');
$courseName.focus().select();
$('.new-course-save').on('click', saveNewCourse);
$cancelButton.bind('click', cancelNewCourse);
$cancelButton.bind('click', makeCancelHandler('course'));
CancelOnEscape($cancelButton);
CreateCourseUtils.configureHandlers();
};
var saveNewLibrary = function (e) {
e.preventDefault();
if (CreateLibraryUtils.hasInvalidRequiredFields()) {
return;
}
var $newLibraryForm = $(this).closest('#create-library-form');
var display_name = $newLibraryForm.find('.new-library-name').val();
var org = $newLibraryForm.find('.new-library-org').val();
var number = $newLibraryForm.find('.new-library-number').val();
var lib_info = {
org: org,
number: number,
display_name: display_name,
};
analytics.track('Created a Library', lib_info);
CreateLibraryUtils.createLibrary(lib_info, function (errorMessage) {
$('.create-library .wrap-error').addClass('is-shown');
$('#library_creation_error').html('<p>' + errorMessage + '</p>');
$('.new-library-save').addClass('is-disabled');
});
};
var addNewLibrary = function (e) {
e.preventDefault();
$('.new-library-button').addClass('is-disabled');
$('.new-library-save').addClass('is-disabled');
var $newLibrary = $('.wrapper-create-library').addClass('is-shown');
var $cancelButton = $newLibrary.find('.new-library-cancel');
var $libraryName = $('.new-library-name');
$libraryName.focus().select();
$('.new-library-save').on('click', saveNewLibrary);
$cancelButton.bind('click', makeCancelHandler('library'));
CancelOnEscape($cancelButton);
CreateLibraryUtils.configureHandlers();
};
var showTab = function(tab) {
return function(e) {
e.preventDefault();
$('.courses-tab').toggleClass('active', tab === 'courses');
$('.libraries-tab').toggleClass('active', tab === 'libraries');
};
};
var onReady = function () {
$('.new-course-button').bind('click', addNewCourse);
$('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload();
}));
$('.action-reload').bind('click', ViewUtils.reload);
$('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
$('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
};
domReady(onReady);
......
......@@ -49,12 +49,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
describe("Field validation", function () {
it("returns a message for an empty string", function () {
var message = CreateCourseUtils.validateRequiredField('');
var message = ViewUtils.validateRequiredField('');
expect(message).not.toBe('');
});
it("does not return a message for a non empty string", function () {
var message = CreateCourseUtils.validateRequiredField('edX');
var message = ViewUtils.validateRequiredField('edX');
expect(message).toBe('');
});
});
......
......@@ -2,7 +2,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
"js/views/utils/view_utils"],
function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) {
describe("Course listing page", function () {
var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'), fillInFields;
var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore');
var fillInFields = function (org, number, run, name) {
$('.new-course-org').val(org);
......@@ -11,6 +11,12 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
$('.new-course-name').val(name);
};
var fillInLibraryFields = function(org, number, name) {
$('.new-library-org').val(org).keyup();
$('.new-library-number').val(number).keyup();
$('.new-library-name').val(name).keyup();
};
beforeEach(function () {
ViewHelpers.installMockAnalytics();
appendSetFixtures(mockIndexPageHTML);
......@@ -57,9 +63,83 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/spec_helpers/view_helper
AjaxHelpers.respondWithJson(requests, {
ErrMsg: 'error message'
});
expect($('.wrap-error')).toHaveClass('is-shown');
expect($('.create-course .wrap-error')).toHaveClass('is-shown');
expect($('#course_creation_error')).toContainText('error message');
expect($('.new-course-save')).toHaveClass('is-disabled');
});
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');
$('.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');
});
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');
});
});
});
......@@ -4,48 +4,21 @@
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
function ($, _, gettext, ViewUtils) {
return function (selectors, classes) {
var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr,
hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers;
var validateTotalCourseItemsLength, setNewCourseFieldInErr, hasInvalidRequiredFields,
createCourse, validateFilledFields, configureHandlers;
validateRequiredField = function (msg) {
return msg.length === 0 ? gettext('Required field.') : '';
};
var validateRequiredField = ViewUtils.validateRequiredField;
var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
// 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 '';
};
var keyLengthViolationMessage = gettext('The combined length of the organization, course number, and course run fields cannot be more than <%=limit%> characters.');
// Ensure that org/course_num/run < 65 chars.
// Ensure that org, course_num and run passes checkTotalKeyLengthViolations
validateTotalCourseItemsLength = function () {
var totalLength = _.reduce(
ViewUtils.checkTotalKeyLengthViolations(
selectors, classes,
[selectors.org, selectors.number, selectors.run],
function (sum, ele) {
return sum + $(ele).val().length;
}, 0
keyLengthViolationMessage
);
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) {
......@@ -117,7 +90,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
if (event.keyCode === 9) {
return;
}
var error = validateCourseItemEncoding($ele.val());
var error = validateURLItemEncoding($ele.val(), $(selectors.allowUnicode).val() === 'True');
setNewCourseFieldInErr($ele.parent(), error);
validateTotalCourseItemsLength();
if (!validateFilledFields()) {
......@@ -138,8 +111,6 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
};
return {
validateRequiredField: validateRequiredField,
validateCourseItemEncoding: validateCourseItemEncoding,
validateTotalCourseItemsLength: validateTotalCourseItemsLength,
setNewCourseFieldInErr: setNewCourseFieldInErr,
hasInvalidRequiredFields: hasInvalidRequiredFields,
......
/**
* Provides utilities for validating libraries during creation.
*/
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"],
function ($, _, gettext, ViewUtils) {
"use strict";
return function (selectors, classes) {
var validateTotalKeyLength, setNewLibraryFieldInErr, hasInvalidRequiredFields,
createLibrary, validateFilledFields, configureHandlers;
var validateRequiredField = ViewUtils.validateRequiredField;
var validateURLItemEncoding = ViewUtils.validateURLItemEncoding;
var keyLengthViolationMessage = gettext("The combined length of the organization and library code fields cannot be more than <%=limit%> characters.");
// Ensure that org/librarycode passes validateTotalKeyLength check
validateTotalKeyLength = function () {
ViewUtils.checkTotalKeyLengthViolations(
selectors, classes,
[selectors.org, selectors.number],
keyLengthViolationMessage
);
};
setNewLibraryFieldInErr = function (element, message) {
if (message) {
element.addClass(classes.error);
element.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(message);
$(selectors.save).addClass(classes.disabled);
}
else {
element.removeClass(classes.error);
element.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],
function (acc, element) {
var $element = $(element);
var error = validateRequiredField($element.val());
setNewLibraryFieldInErr($element.parent(), error);
return error ? true : acc;
},
false
);
};
createLibrary = 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);
});
};
// Ensure that all fields are not empty
validateFilledFields = function () {
return _.reduce(
[selectors.org, selectors.number, selectors.name],
function (acc, element) {
var $element = $(element);
return $element.val().length !== 0 ? acc : false;
},
true
);
};
// Handle validation asynchronously
configureHandlers = function () {
_.each(
[selectors.org, selectors.number],
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 = validateURLItemEncoding($element.val(), $(selectors.allowUnicode).val() === 'True');
setNewLibraryFieldInErr($element.parent(), error);
validateTotalKeyLength();
if (!validateFilledFields()) {
$(selectors.save).addClass(classes.disabled);
}
});
}
);
var $name = $(selectors.name);
$name.on('keyup', function () {
var error = validateRequiredField($name.val());
setNewLibraryFieldInErr($name.parent(), error);
validateTotalKeyLength();
if (!validateFilledFields()) {
$(selectors.save).addClass(classes.disabled);
}
});
};
return {
validateTotalKeyLength: validateTotalKeyLength,
setNewLibraryFieldInErr: setNewLibraryFieldInErr,
hasInvalidRequiredFields: hasInvalidRequiredFields,
createLibrary: createLibrary,
validateFilledFields: validateFilledFields,
configureHandlers: configureHandlers
};
};
});
......@@ -5,7 +5,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
function ($, _, gettext, NotificationView, PromptView) {
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset,
setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler;
setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler,
validateRequiredField, validateURLItemEncoding, validateTotalKeyLength, checkTotalKeyLengthViolations;
// see https://openedx.atlassian.net/browse/TNL-889 for what is it and why it's 65
var MAX_SUM_KEY_LENGTH = 65;
/**
* Toggles the expanded state of the current element.
......@@ -173,6 +177,55 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return false;
};
/**
* Helper method for course/library creation - verifies a required field is not blank.
*/
validateRequiredField = function (msg) {
return msg.length === 0 ? gettext('Required field.') : '';
};
/**
* Helper method for course/library creation.
* Check that a course (org, number, run) doesn't use any special characters
*/
validateURLItemEncoding = function (item, allowUnicode) {
var required = validateRequiredField(item);
if (required) {
return required;
}
if (allowUnicode) {
if (/\s/g.test(item)) {
return gettext('Please do not use any spaces in this field.');
}
}
else {
if (item !== encodeURIComponent(item)) {
return gettext('Please do not use any spaces or special characters in this field.');
}
}
return '';
};
// Ensure that sum length of key field values <= ${MAX_SUM_KEY_LENGTH} chars.
validateTotalKeyLength = function (key_field_selectors) {
var totalLength = _.reduce(
key_field_selectors,
function (sum, ele) { return sum + $(ele).val().length;},
0
);
return totalLength <= MAX_SUM_KEY_LENGTH;
};
checkTotalKeyLengthViolations = function(selectors, classes, key_field_selectors, message_tpl) {
if (!validateTotalKeyLength(key_field_selectors)) {
$(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
$(selectors.errorMessage).html('<p>' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '</p>');
$(selectors.save).addClass(classes.disabled);
} else {
$(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding);
}
};
return {
'toggleExpandCollapse': toggleExpandCollapse,
'showLoadingIndicator': showLoadingIndicator,
......@@ -186,6 +239,10 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
'setScrollOffset': setScrollOffset,
'redirect': redirect,
'reload': reload,
'hasChangedAttributes': hasChangedAttributes
'hasChangedAttributes': hasChangedAttributes,
'validateRequiredField': validateRequiredField,
'validateURLItemEncoding': validateURLItemEncoding,
'validateTotalKeyLength': validateTotalKeyLength,
'checkTotalKeyLengthViolations': checkTotalKeyLengthViolations
};
});
......@@ -409,7 +409,6 @@ form {
// ====================
.wrapper-create-element {
height: 0;
margin-bottom: $baseline;
opacity: 0.0;
pointer-events: none;
overflow: hidden;
......@@ -420,6 +419,7 @@ form {
&.is-shown {
height: auto; // define a specific height for the animating version of this UI to work properly
margin-bottom: $baseline;
opacity: 1.0;
pointer-events: auto;
}
......
......@@ -289,10 +289,42 @@
// ====================
// Course/Library tabs
#course-index-tabs {
margin: 0;
font-size: 1.4rem;
li {
display: inline-block;
line-height: $baseline*2;
margin: 0 10px;
&.active, &:hover {
border-bottom: 4px solid $blue;
}
a {
color: $blue;
cursor: pointer;
display: inline-block;
}
&.active a {
color: $gray-d2;
}
}
}
// ELEM: course listings
.courses {
margin: $baseline 0;
.courses-tab, .libraries-tab {
display: none;
&.active {
display: block;
}
}
.courses, .libraries {
.title {
@extend %t-title6;
margin-bottom: $baseline;
......@@ -311,7 +343,6 @@
}
.list-courses {
margin-top: $baseline;
border-radius: 3px;
border: 1px solid $gray-l2;
background: $white;
......@@ -622,7 +653,7 @@
// course listings
.create-course {
.create-course, .create-library {
.row {
@include clearfix();
......
......@@ -21,6 +21,8 @@
% if context_course:
<% ctx_loc = context_course.location %>
${context_course.display_name_with_default | h} |
% elif context_library:
${context_library.display_name_with_default | h} |
% endif
${settings.STUDIO_NAME}
</title>
......
......@@ -18,13 +18,6 @@ from django.utils.translation import ugettext as _
<%namespace name='static' file='static_content.html'/>
<%!
templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message"]
%>
<%block name="header_extras">
% for template_name in templates:
<script type="text/template" id="${template_name}-tpl">
......
......@@ -8,6 +8,10 @@
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
New Course</a>
</li>
<li class="nav-item">
<a href="#" class="button new-button new-library-button"><i class="icon-plus icon-inline"></i>
New Library</a>
</li>
</ul>
</nav>
</header>
......@@ -78,6 +82,53 @@
</form>
</div>
<div class="wrapper-create-element wrapper-create-library">
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
<div class="wrap-error">
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
<p>Please correct the highlighted fields below.</p>
</div>
</div>
<div class="wrapper-form">
<h3 class="title">Create a New Library</h3>
<fieldset>
<legend class="sr">Required Information to Create a New Library</legend>
<ol class="list-input">
<li class="field text required" id="field-library-name">
<label for="new-library-name">Library Name</label>
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" aria-required="true" placeholder="e.g. Computer Science Problems" />
<span class="tip">The public display name for your library.</span>
<span class="tip tip-error is-hiding"></span>
</li>
<li class="field text required" id="field-organization">
<label for="new-library-org">Organization</label>
<input class="new-library-org" id="new-library-org" type="text" name="new-library-org" aria-required="true" placeholder="e.g. UniversityX or OrganizationX" />
<span class="tip">The name of the organization sponsoring the library. <strong>Note: This is part of your library URL, so no spaces or special characters are allowed.</strong> This cannot be changed.</span>
<span class="tip tip-error is-hiding"></span>
</li>
<li class="field text required" id="field-library-number">
<label for="new-library-number">Library Code/Number</label>
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" aria-required="true" placeholder="e.g. CSPROB" />
<span class="tip">The unique code that identifies this library. <strong>Note: This is part of your library URL, so no spaces or special characters are allowed and it cannot be changed.</strong></span>
<span class="tip tip-error is-hiding"></span>
</li>
</ol>
</fieldset>
</div>
<div class="actions">
<input type="hidden" value="False" class="allow-unicode-course-id" />
<input type="submit" value="Create" class="action action-primary new-library-save" />
<input type="button" value="Cancel" class="action action-secondary action-cancel new-library-cancel" />
</div>
</form>
</div>
<!-- STATE: processing courses -->
<div class="courses courses-processing">
<h3 class="title">Courses Being Processed</h3>
......@@ -163,6 +214,15 @@
</li>
</ul>
</div>
<ul id="course-index-tabs">
<li class="courses-tab active"><a>${_("Courses")}</a></li>
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
</ul>
<div class="courses courses-tab active">
<div class="libraries libraries-tab"></div>
</article>
</section>
</div>
<%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}
);
});
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-navigation has-subtitle">
<div class="page-header">
<h1 class="page-header-title"><span class="title-value">${context_library.display_name_with_default | h}</span></h1>
</div>
</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-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("Adding content components")}</h3>
<p>${_("You can add components to the library. Help text here.")}</p>
</div>
<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>
......@@ -112,6 +112,13 @@ urlpatterns += patterns(
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
)
if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'):
LIBRARY_KEY_PATTERN = r'(?P<library_key_string>library-v1:[^/+]+\+[^/+]+)'
urlpatterns += (
url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN),
'contentstore.views.library_handler', name='library_handler'),
)
if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
urlpatterns += (url(
r'^export_git/{}$'.format(
......
......@@ -5,7 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import InsufficientSpecificationError
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
)
......@@ -411,7 +411,11 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
pass
def _get_head(self, xblock, branch):
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
try:
course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
except ItemNotFoundError:
# There is no published version xblock container, e.g. Library
return None
return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location))
def _get_version(self, block):
......
......@@ -206,3 +206,14 @@ class TestLibraries(MixedSplitTestCase):
with patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: []):
result = library.render(AUTHOR_VIEW, context)
self.assertIn(message, result.content)
def test_xblock_in_lib_have_published_version_returns_false(self):
library = LibraryFactory.create(modulestore=self.store)
block = ItemFactory.create(
category="html",
parent_location=library.location,
user_id=self.user_id,
publish_item=False,
modulestore=self.store,
)
self.assertFalse(self.store.has_published_version(block))
......@@ -23,10 +23,10 @@
if (runtime && version && initFnName) {
return new window[runtime]['v' + version];
} else {
if (!runtime || !version || !initFnName) {
if (runtime || version || initFnName) {
var elementTag = $('<div>').append($element.clone()).html();
console.log('Block ' + elementTag + ' is missing data-runtime, data-runtime-version or data-init, and can\'t be initialized');
}
} // else this XBlock doesn't have a JS init function.
return null;
}
}
......
"""
Common code shared by course and library fixtures.
"""
import re
import requests
import json
from lazy import lazy
from . import STUDIO_BASE_URL
class StudioApiLoginError(Exception):
"""
Error occurred while logging in to the Studio API.
"""
pass
class StudioApiFixture(object):
"""
Base class for fixtures that use the Studio restful API.
"""
def __init__(self):
# Info about the auto-auth user used to create the course/library.
self.user = {}
@lazy
def session(self):
"""
Log in as a staff user, then return a `requests` `session` object for the logged in user.
Raises a `StudioApiLoginError` if the login fails.
"""
# Use auto-auth to retrieve the session for a logged in user
session = requests.Session()
response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
# Return the session from the request
if response.ok:
# auto_auth returns information about the newly created user
# capture this so it can be used by by the testcases.
user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
r'(?P<username>\S+)', r'(?P<email>[^\)]+)', r'(?P<password>\S+)', r'(?P<user_id>\d+)'))
user_matches = re.match(user_pattern, response.text)
if user_matches:
self.user = user_matches.groupdict()
return session
else:
msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
raise StudioApiLoginError(msg)
@lazy
def session_cookies(self):
"""
Log in as a staff user, then return the cookies for the session (as a dict)
Raises a `StudioApiLoginError` if the login fails.
"""
return {key: val for key, val in self.session.cookies.items()}
@lazy
def headers(self):
"""
Default HTTP headers dict.
"""
return {
'Content-type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': self.session_cookies.get('csrftoken', '')
}
class FixtureError(Exception):
"""
Error occurred while installing a course or library fixture.
"""
pass
class XBlockContainerFixture(StudioApiFixture):
"""
Base class for course and library fixtures.
"""
def __init__(self):
self.children = []
super(XBlockContainerFixture, self).__init__()
def add_children(self, *args):
"""
Add children XBlock to the container.
Each item in `args` is an `XBlockFixtureDesc` object.
Returns the fixture to allow chaining.
"""
self.children.extend(args)
return self
def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
Recursively create XBlock children.
"""
for desc in xblock_descriptions:
loc = self.create_xblock(parent_loc, desc)
self._create_xblock_children(loc, desc.children)
def create_xblock(self, parent_loc, xblock_desc):
"""
Create an XBlock with `parent_loc` (the location of the parent block)
and `xblock_desc` (an `XBlockFixtureDesc` instance).
"""
create_payload = {
'category': xblock_desc.category,
'display_name': xblock_desc.display_name,
}
if parent_loc is not None:
create_payload['parent_locator'] = parent_loc
# Create the new XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/',
data=json.dumps(create_payload),
headers=self.headers,
)
if not response.ok:
msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
raise FixtureError(msg)
try:
loc = response.json().get('locator')
xblock_desc.locator = loc
except ValueError:
raise FixtureError("Could not decode JSON from '{0}'".format(response.content))
# Configure the XBlock
response = self.session.post(
STUDIO_BASE_URL + '/xblock/' + loc,
data=xblock_desc.serialize(),
headers=self.headers,
)
if response.ok:
return loc
else:
raise FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code))
def _update_xblock(self, locator, data):
"""
Update the xblock at `locator`.
"""
# Create the new XBlock
response = self.session.put(
"{}/xblock/{}".format(STUDIO_BASE_URL, locator),
data=json.dumps(data),
headers=self.headers,
)
if not response.ok:
msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
raise FixtureError(msg)
def _encode_post_dict(self, post_dict):
"""
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
"""
return json.dumps({
k: v.encode('utf-8') if isinstance(v, basestring) else v
for k, v in post_dict.items()
})
def get_nested_xblocks(self, category=None):
"""
Return a list of nested XBlocks for the container that can be filtered by
category.
"""
xblocks = self._get_nested_xblocks(self)
if category:
xblocks = [x for x in xblocks if x.category == category]
return xblocks
def _get_nested_xblocks(self, xblock_descriptor):
"""
Return a list of nested XBlocks for the container.
"""
xblocks = list(xblock_descriptor.children)
for child in xblock_descriptor.children:
xblocks.extend(self._get_nested_xblocks(child))
return xblocks
def _publish_xblock(self, locator):
"""
Publish the xblock at `locator`.
"""
self._update_xblock(locator, {'publish': 'make_public'})
"""
Fixture to create a Content Library
"""
from opaque_keys.edx.keys import CourseKey
from . import STUDIO_BASE_URL
from .base import XBlockContainerFixture, FixtureError
class LibraryFixture(XBlockContainerFixture):
"""
Fixture for ensuring that a library exists.
WARNING: This fixture is NOT idempotent. To avoid conflicts
between tests, you should use unique library identifiers for each fixture.
"""
def __init__(self, org, number, display_name):
"""
Configure the library fixture to create a library with
"""
super(LibraryFixture, self).__init__()
self.library_info = {
'org': org,
'number': number,
'display_name': display_name
}
self._library_key = None
super(LibraryFixture, self).__init__()
def __str__(self):
"""
String representation of the library fixture, useful for debugging.
"""
return "<LibraryFixture: org='{org}', number='{number}'>".format(**self.library_info)
def install(self):
"""
Create the library and XBlocks within the library.
This is NOT an idempotent method; if the library already exists, this will
raise a `FixtureError`. You should use unique library identifiers to avoid
conflicts between tests.
"""
self._create_library()
self._create_xblock_children(self.library_location, self.children)
return self
@property
def library_key(self):
"""
Get the LibraryLocator for this library, as a string.
"""
return self._library_key
@property
def library_location(self):
"""
Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy.
"""
lib_key = CourseKey.from_string(self._library_key)
return unicode(lib_key.make_usage_key('library', 'library'))
def _create_library(self):
"""
Create the library described in the fixture.
Will fail if the library already exists.
"""
response = self.session.post(
STUDIO_BASE_URL + '/library/',
data=self._encode_post_dict(self.library_info),
headers=self.headers
)
if response.ok:
self._library_key = response.json()['library_key']
else:
try:
err_msg = response.json().get('ErrMsg')
except ValueError:
err_msg = "Unknown Error"
raise FixtureError(
"Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg)
)
def create_xblock(self, parent_loc, xblock_desc):
# Disable publishing for library XBlocks:
xblock_desc.publish = "not-applicable"
return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc)
......@@ -6,7 +6,7 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
from utils import click_css, confirm_prompt
from .utils import click_css, confirm_prompt, type_in_codemirror
class ContainerPage(PageObject):
......@@ -365,6 +365,12 @@ class XBlockWrapper(PageObject):
"""
self._click_button('basic_tab')
def set_codemirror_text(self, text, index=0):
"""
Set the text of a CodeMirror editor that is part of this xblock's settings.
"""
type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
def save_settings(self):
"""
Click on settings Save button.
......
......@@ -28,6 +28,13 @@ class DashboardPage(PageObject):
def has_processing_courses(self):
return self.q(css='.courses-processing').present
@property
def page_subheader(self):
"""
Get the text of the introductory copy seen below the Welcome header. ("Here are all of...")
"""
return self.q(css='.content-primary .introduction .copy p').first.text[0]
def create_rerun(self, display_name):
"""
Clicks the create rerun link of the course specified by display_name.
......@@ -40,3 +47,68 @@ class DashboardPage(PageObject):
Clicks on the course with run given by run.
"""
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
def has_new_library_button(self):
"""
(bool) is the "New Library" button present?
"""
return self.q(css='.new-library-button').present
def click_new_library(self):
"""
Click on the "New Library" button
"""
self.q(css='.new-library-button').click()
def is_new_library_form_visible(self):
"""
Is the new library form visisble?
"""
return self.q(css='.wrapper-create-library').visible
def fill_new_library_form(self, display_name, org, number):
"""
Fill out the form to create a new library.
Must have called click_new_library() first.
"""
field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn))
field('name').fill(display_name)
field('org').fill(org)
field('number').fill(number)
def is_new_library_form_valid(self):
"""
IS the new library form ready to submit?
"""
return (
self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and
not self.q(css='.wrapper-create-library .wrap-error.is-shown').present
)
def submit_new_library_form(self):
"""
Submit the new library form.
"""
self.q(css='.wrapper-create-library .new-library-save').click()
def list_libraries(self):
"""
List all the libraries found on the page's list of libraries.
"""
self.q(css='#course-index-tabs .libraries-tab a').click() # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
div2info = lambda element: {
'name': element.find_element_by_css_selector('.course-title').text,
'org': element.find_element_by_css_selector('.course-org .value').text,
'number': element.find_element_by_css_selector('.course-num .value').text,
'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
}
return self.q(css='.libraries li.course-item').map(div2info).results
def has_library(self, **kwargs):
"""
Does the page's list of libraries include a library matching kwargs?
"""
for lib in self.list_libraries():
if all([lib[key] == kwargs[key] for key in kwargs]):
return True
return False
"""
Library edit page in Studio
"""
from bok_choy.page_object import PageObject
from .container import XBlockWrapper
from ...tests.helpers import disable_animations
from .utils import confirm_prompt, wait_for_notification
from . import BASE_URL
class LibraryPage(PageObject):
"""
Library page in Studio
"""
def __init__(self, browser, locator):
super(LibraryPage, self).__init__(browser)
self.locator = locator
@property
def url(self):
"""
URL to the library edit page for the given library.
"""
return "{}/library/{}".format(BASE_URL, unicode(self.locator))
def is_browser_on_page(self):
"""
Returns True iff the browser has loaded the library edit page.
"""
return self.q(css='body.view-library').present
def get_header_title(self):
"""
The text of the main heading (H1) visible on the page.
"""
return self.q(css='h1.page-header-title').text
def wait_until_ready(self):
"""
When the page first loads, there is a loading indicator and most
functionality is not yet available. This waits for that loading to
finish.
Always call this before using the page. It also disables animations
for improved test reliability.
"""
self.wait_for_ajax()
self.wait_for_element_invisibility('.ui-loading', 'Wait for the page to complete its initial loading of XBlocks via AJAX')
disable_animations(self)
@property
def xblocks(self):
"""
Return a list of xblocks loaded on the container page.
"""
return self._get_xblocks()
def click_duplicate_button(self, xblock_id):
"""
Click on the duplicate button for the given XBlock
"""
self._action_btn_for_xblock_id(xblock_id, "duplicate").click()
wait_for_notification(self)
self.wait_for_ajax()
def click_delete_button(self, xblock_id, confirm=True):
"""
Click on the delete button for the given XBlock
"""
self._action_btn_for_xblock_id(xblock_id, "delete").click()
if confirm:
confirm_prompt(self) # this will also wait_for_notification()
self.wait_for_ajax()
def _get_xblocks(self):
"""
Create an XBlockWrapper for each XBlock div found on the page.
"""
prefix = '.wrapper-xblock.level-page '
return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def _div_for_xblock_id(self, xblock_id):
"""
Given an XBlock's usage locator as a string, return the WebElement for
that block's wrapper div.
"""
return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(lambda el: el.get_attribute('data-locator') == xblock_id)
def _action_btn_for_xblock_id(self, xblock_id, action):
"""
Given an XBlock's usage locator as a string, return one of its action
buttons.
action is 'edit', 'duplicate', or 'delete'
"""
return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector('.header-actions .{action}-button.action-button'.format(action=action))
......@@ -103,6 +103,30 @@ def add_advanced_component(page, menu_index, name):
click_css(page, component_css, 0)
def add_component(page, item_type, specific_type):
"""
Click one of the "Add New Component" buttons.
item_type should be "advanced", "html", "problem", or "video"
specific_type is required for some types and should be something like
"Blank Common Problem".
"""
btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type))
multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present
btn.click()
if multiple_templates:
sub_template_menu_div_selector = '.new-component-{}'.format(item_type)
page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear')
page.wait_for_element_invisibility('.add-xblock-component .new-component', 'Wait for the add component menu to disappear')
all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
chosen_option = all_options.filter(lambda el: el.text == specific_type).first
chosen_option.click()
wait_for_notification(page)
page.wait_for_ajax()
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
......
"""
Base classes used by studio tests.
"""
from bok_choy.web_app_test import WebAppTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...fixtures.course import CourseFixture
from ...fixtures.library import LibraryFixture
from ..helpers import UniqueCourseTest
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.utils import verify_ordering
......@@ -98,3 +103,46 @@ class ContainerBase(StudioCourseTest):
# Reload the page to see that the change was persisted.
container = self.go_to_nested_container_page()
verify_ordering(self, container, expected_ordering)
class StudioLibraryTest(WebAppTest):
"""
Base class for all Studio library tests.
"""
def setUp(self, is_staff=False): # pylint: disable=arguments-differ
"""
Install a library with no content using a fixture.
"""
super(StudioLibraryTest, self).setUp()
fixture = LibraryFixture(
'test_org',
self.unique_id,
'Test Library {}'.format(self.unique_id),
)
self.populate_library_fixture(fixture)
fixture.install()
self.library_info = fixture.library_info
self.library_key = fixture.library_key
self.user = fixture.user
self.log_in(self.user, is_staff)
def populate_library_fixture(self, library_fixture):
"""
Populate the children of the test course fixture.
"""
pass
def log_in(self, user, is_staff=False):
"""
Log in as the user that created the library.
By default the user will not have staff access unless is_staff is passed as True.
"""
auth_page = AutoAuthPage(
self.browser,
staff=is_staff,
username=user.get('username'),
email=user.get('email'),
password=user.get('password')
)
auth_page.visit()
"""
Acceptance tests for Home Page (My Courses / My Libraries).
"""
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import LibraryLocator
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.library import LibraryPage
from ...pages.studio.index import DashboardPage
class CreateLibraryTest(WebAppTest):
"""
Test that we can create a new content library on the studio home page.
"""
def setUp(self):
"""
Load the helper for the home page (dashboard page)
"""
super(CreateLibraryTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPage(self.browser)
def test_subheader(self):
"""
From the home page:
Verify that subheader is correct
"""
self.auth_page.visit()
self.dashboard_page.visit()
self.assertIn("courses and libraries", self.dashboard_page.page_subheader)
def test_create_library(self):
"""
From the home page:
Click "New Library"
Fill out the form
Submit the form
We should be redirected to the edit view for the library
Return to the home page
The newly created library should now appear in the list of libraries
"""
name = "New Library Name"
org = "TestOrgX"
number = "TESTLIB"
self.auth_page.visit()
self.dashboard_page.visit()
self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number))
self.assertTrue(self.dashboard_page.has_new_library_button())
self.dashboard_page.click_new_library()
self.assertTrue(self.dashboard_page.is_new_library_form_visible())
self.dashboard_page.fill_new_library_form(name, org, number)
self.assertTrue(self.dashboard_page.is_new_library_form_valid())
self.dashboard_page.submit_new_library_form()
# The next page is the library edit view; make sure it loads:
lib_page = LibraryPage(self.browser, LibraryLocator(org, number))
lib_page.wait_for_page()
# Then go back to the home page and make sure the new library is listed there:
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
"""
Acceptance tests for Content Libraries in Studio
"""
from .base_studio_test import StudioLibraryTest
from ...pages.studio.utils import add_component
from ...pages.studio.library import LibraryPage
class LibraryEditPageTest(StudioLibraryTest):
"""
Test the functionality of the library edit page.
"""
def setUp(self): # pylint: disable=arguments-differ
"""
Ensure a library exists and navigate to the library edit page.
"""
super(LibraryEditPageTest, self).setUp(is_staff=True)
self.lib_page = LibraryPage(self.browser, self.library_key)
self.lib_page.visit()
self.lib_page.wait_until_ready()
def test_page_header(self):
"""
Scenario: Ensure that the library's name is displayed in the header and title.
Given I have a library in Studio
And I navigate to Library Page in Studio
Then I can see library name in page header title
And I can see library name in browser page title
"""
self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title())
self.assertIn(self.library_info['display_name'], self.browser.title)
def test_add_duplicate_delete_actions(self):
"""
Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original.
Given I have a library in Studio with no XBlocks
And I navigate to Library Page in Studio
Then there are no XBlocks displayed
When I add Text XBlock
Then one XBlock is displayed
When I duplicate first XBlock
Then two XBlocks are displayed
And those XBlocks locators' are different
When I delete first XBlock
Then one XBlock is displayed
And displayed XBlock are second one
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new block:
add_component(self.lib_page, "html", "Text")
self.assertEqual(len(self.lib_page.xblocks), 1)
first_block_id = self.lib_page.xblocks[0].locator
# Duplicate the block:
self.lib_page.click_duplicate_button(first_block_id)
self.assertEqual(len(self.lib_page.xblocks), 2)
second_block_id = self.lib_page.xblocks[1].locator
self.assertNotEqual(first_block_id, second_block_id)
# Delete the first block:
self.lib_page.click_delete_button(first_block_id, confirm=True)
self.assertEqual(len(self.lib_page.xblocks), 1)
self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id)
def test_add_edit_xblock(self):
"""
Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes.
Given I have a library in Studio with no XBlocks
And I navigate to Library Page in Studio
Then there are no XBlocks displayed
When I add Multiple Choice XBlock
Then one XBlock is displayed
When I edit first XBlock
And I go to basic tab
And set it's text to a fairly trivial question about Battlestar Galactica
And save XBlock
Then one XBlock is displayed
And first XBlock student content contains at least part of text I set
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
# Create a new problem block:
add_component(self.lib_page, "problem", "Multiple Choice")
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
# Edit it:
problem_block.edit()
problem_block.open_basic_tab()
problem_block.set_codemirror_text(
"""
>>Who is "Starbuck"?<<
(x) Kara Thrace
( ) William Adama
( ) Laura Roslin
( ) Lee Adama
( ) Gaius Baltar
"""
)
problem_block.save_settings()
# Check that the save worked:
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
self.assertIn("Laura Roslin", problem_block.student_content)
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