Studio support for creating and editing libraries (PR 6046)
SOL-1, SOL-2, SOL-3
This commit is contained in:
committed by
E. Kolpakov
parent
80e0d56afd
commit
3e0f08ebc2
@@ -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,
|
||||
|
||||
185
cms/djangoapps/contentstore/views/library.py
Normal file
185
cms/djangoapps/contentstore/views/library.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
185
cms/djangoapps/contentstore/views/tests/test_library.py
Normal file
185
cms/djangoapps/contentstore/views/tests/test_library.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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
|
||||
|
||||
23
cms/static/js/factories/library.js
Normal file
23
cms/static/js/factories/library.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -1,16 +1,17 @@
|
||||
define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils",
|
||||
"js/views/utils/view_utils"],
|
||||
function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) {
|
||||
"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,
|
||||
|
||||
129
cms/static/js/views/utils/create_library_utils.js
Normal file
129
cms/static/js/views/utils/create_library_utils.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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 @@
|
||||
|
||||
// ====================
|
||||
|
||||
// ELEM: course listings
|
||||
.courses {
|
||||
margin: $baseline 0;
|
||||
// Course/Library tabs
|
||||
#course-index-tabs {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
line-height: $baseline*2;
|
||||
margin: 0 10px;
|
||||
|
||||
&.active, &:hover {
|
||||
border-bottom: 4px solid $blue;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $blue;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.active a {
|
||||
color: $gray-d2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ELEM: course listings
|
||||
.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">
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
% if course_creator_status=='granted':
|
||||
<a href="#" class="button new-button new-course-button"><i class="icon fa fa-plus icon-inline"></i>
|
||||
${_("New Course")}</a>
|
||||
% if libraries_enabled:
|
||||
<a href="#" class="button new-button new-library-button"><i class="icon-plus icon-inline"></i>
|
||||
${_("New Library")}</a>
|
||||
% endif
|
||||
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
|
||||
<a href="mailto:${settings.FEATURES.get('STUDIO_REQUEST_EMAIL','')}">${_("Email staff to create course")}</a>
|
||||
% endif
|
||||
@@ -42,14 +46,26 @@
|
||||
<div class="introduction">
|
||||
<h2 class="title">${_("Welcome, {0}!").format(user.username)}</h2>
|
||||
|
||||
%if len(courses) > 0:
|
||||
%if len(courses) > 0 or len(libraries) > 0:
|
||||
<div class="copy">
|
||||
<p>${_("Here are all of the courses you currently have access to in {studio_name}:").format(studio_name=settings.STUDIO_SHORT_NAME)}</p>
|
||||
<p>
|
||||
%if libraries_enabled:
|
||||
${_("Here are all of the courses and librariess you currently have access to in {studio_name}:").format(studio_name=settings.STUDIO_SHORT_NAME)}
|
||||
%else:
|
||||
${_("Here are all of the courses you currently have access to in {studio_name}:").format(studio_name=settings.STUDIO_SHORT_NAME)}
|
||||
%endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="copy">
|
||||
<p>${_("You currently aren't associated with any {studio_name} Courses.").format(studio_name=settings.STUDIO_SHORT_NAME)}</p>
|
||||
<p>
|
||||
%if libraries_enabled:
|
||||
${_("You currently aren't associated with any {studio_name} Courses and Libraries").format(studio_name=settings.STUDIO_SHORT_NAME)}
|
||||
%else:
|
||||
${_("You currently aren't associated with any {studio_name} Courses.").format(studio_name=settings.STUDIO_SHORT_NAME)}
|
||||
%endif
|
||||
</p>
|
||||
</div>
|
||||
%endif
|
||||
</div>
|
||||
@@ -72,12 +88,15 @@
|
||||
<ol class="list-input">
|
||||
<li class="field text required" id="field-course-name">
|
||||
<label for="new-course-name">${_("Course Name")}</label>
|
||||
## Translators: This is an example name for a new course, seen when filling out the form to create a new course.
|
||||
<input class="new-course-name" id="new-course-name" type="text" name="new-course-name" aria-required="true" placeholder="${_('e.g. Introduction to Computer Science')}" />
|
||||
<span class="tip">${_("The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
<li class="field text required" id="field-organization">
|
||||
<label for="new-course-org">${_("Organization")}</label>
|
||||
## Translators: This is an example for the name of the organization sponsoring a course, seen when filling out the form to create a new course. The organization name cannot contain spaces.
|
||||
## Translators: "e.g. UniversityX or OrganizationX" is a placeholder displayed when user put no data into this field.
|
||||
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" aria-required="true" placeholder="${_('e.g. UniversityX or OrganizationX')}" />
|
||||
<span class="tip">${_("The name of the organization sponsoring the course.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
@@ -85,6 +104,7 @@
|
||||
|
||||
<li class="field text required" id="field-course-number">
|
||||
<label for="new-course-number">${_("Course Number")}</label>
|
||||
## Translators: This is an example for the number used to identify a course, seen when filling out the form to create a new course. The number here is short for "Computer Science 101". It can contain letters but cannot contain spaces.
|
||||
<input class="new-course-number" id="new-course-number" type="text" name="new-course-number" aria-required="true" placeholder="${_('e.g. CS101')}" />
|
||||
<span class="tip">${_("The unique number that identifies your course within your organization.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
@@ -92,6 +112,7 @@
|
||||
|
||||
<li class="field text required" id="field-course-run">
|
||||
<label for="new-course-run">${_("Course Run")}</label>
|
||||
## Translators: This is an example for the "run" used to identify different instances of a course, seen when filling out the form to create a new course.
|
||||
<input class="new-course-run" id="new-course-run" type="text" name="new-course-run" aria-required="true"placeholder="${_('e.g. 2014_T1')}" />
|
||||
<span class="tip">${_("The term in which your course will run.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed.")}</strong></span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
@@ -108,6 +129,58 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
%if libraries_enabled:
|
||||
<div class="wrapper-create-element wrapper-create-library">
|
||||
<form class="form-create create-library library-info" id="create-library-form" name="create-library-form">
|
||||
<div class="wrap-error">
|
||||
<div id="library_creation_error" name="library_creation_error" class="message message-status message-status error" role="alert">
|
||||
<p>${_("Please correct the highlighted fields below.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-form">
|
||||
<h3 class="title">${_("Create a New Library")}</h3>
|
||||
|
||||
<fieldset>
|
||||
<legend class="sr">${_("Required Information to Create a New Library")}</legend>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text required" id="field-library-name">
|
||||
<label for="new-library-name">${_("Library Name")}</label>
|
||||
## Translators: This is an example name for a new content library, seen when filling out the form to create a new library. (A library is a collection of content or problems.)
|
||||
<input class="new-library-name" id="new-library-name" type="text" name="new-library-name" 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>
|
||||
## Translators: This is an example for the "number" used to identify a library, seen when filling out the form to create a new library. This example is short for "Computer Science Problems". The example number may contain letters but must not contain spaces.
|
||||
<input class="new-library-number" id="new-library-number" type="text" name="new-library-number" 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.")}</strong> ${_("This cannot be changed.")}</span>
|
||||
<span class="tip tip-error is-hiding"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input type="hidden" value="${allow_unicode_course_id}" class="allow-unicode-course-id" />
|
||||
<input type="submit" value="${_('Create')}" class="action action-primary new-library-save" />
|
||||
<input type="button" value="${_('Cancel')}" class="action action-secondary action-cancel new-library-cancel" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
% endif
|
||||
|
||||
<!-- STATE: processing courses -->
|
||||
@@ -208,8 +281,15 @@
|
||||
</div>
|
||||
%endif
|
||||
|
||||
%if libraries_enabled:
|
||||
<ul id="course-index-tabs">
|
||||
<li class="courses-tab active"><a>${_("Courses")}</a></li>
|
||||
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
|
||||
</ul>
|
||||
%endif
|
||||
|
||||
%if len(courses) > 0:
|
||||
<div class="courses">
|
||||
<div class="courses courses-tab active">
|
||||
<ul class="list-courses">
|
||||
%for course_info in sorted(courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
|
||||
<li class="course-item" data-course-key="${course_info['course_key'] | h}">
|
||||
@@ -246,10 +326,7 @@
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="courses">
|
||||
</div>
|
||||
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices">
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices courses-tab active">
|
||||
<div class="notice-item">
|
||||
<div class="msg">
|
||||
<h3 class="title">${_("Are you staff on an existing {studio_name} course?").format(studio_name=settings.STUDIO_SHORT_NAME)}</h3>
|
||||
@@ -363,6 +440,41 @@
|
||||
</div>
|
||||
% endif
|
||||
|
||||
%if len(libraries) > 0:
|
||||
<div class="libraries libraries-tab">
|
||||
<ul class="list-courses">
|
||||
%for library_info in sorted(libraries, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
|
||||
<li class="course-item">
|
||||
<a class="library-link" href="${library_info['url']}">
|
||||
<h3 class="course-title">${library_info['display_name']}</h3>
|
||||
|
||||
<div class="course-metadata">
|
||||
<span class="course-org metadata-item">
|
||||
<span class="label">${_("Organization:")}</span> <span class="value">${library_info['org']}</span>
|
||||
</span>
|
||||
<span class="course-num metadata-item">
|
||||
<span class="label">${_("Course Number:")}</span>
|
||||
<span class="value">${library_info['number']}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
%endfor
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
%else:
|
||||
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices libraries-tab">
|
||||
<div class="notice-item">
|
||||
<div class="msg">
|
||||
<div class="copy">
|
||||
<p>${_("You don't have any content libraries yet.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
%endif
|
||||
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
cms/templates/library.html
Normal file
66
cms/templates/library.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<%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;
|
||||
}
|
||||
}
|
||||
|
||||
196
common/test/acceptance/fixtures/base.py
Normal file
196
common/test/acceptance/fixtures/base.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
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'})
|
||||
@@ -4,77 +4,17 @@ Fixture to create a course and course components (XBlocks).
|
||||
|
||||
import mimetypes
|
||||
import json
|
||||
import re
|
||||
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
from textwrap import dedent
|
||||
from collections import namedtuple
|
||||
from path import path
|
||||
from lazy import lazy
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
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.
|
||||
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('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
|
||||
'(?P<username>\S+)', '(?P<email>[^\)]+)', '(?P<password>\S+)', '(?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', '')
|
||||
}
|
||||
from .base import XBlockContainerFixture, FixtureError
|
||||
|
||||
|
||||
class XBlockFixtureDesc(object):
|
||||
@@ -105,7 +45,7 @@ class XBlockFixtureDesc(object):
|
||||
def add_children(self, *args):
|
||||
"""
|
||||
Add child XBlocks to this XBlock.
|
||||
Each item in `args` is an `XBlockFixtureDescriptor` object.
|
||||
Each item in `args` is an `XBlockFixtureDesc` object.
|
||||
|
||||
Returns the `xblock_desc` instance to allow chaining.
|
||||
"""
|
||||
@@ -154,14 +94,7 @@ class XBlockFixtureDesc(object):
|
||||
CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content'])
|
||||
|
||||
|
||||
class CourseFixtureError(Exception):
|
||||
"""
|
||||
Error occurred while installing a course fixture.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CourseFixture(StudioApiFixture):
|
||||
class CourseFixture(XBlockContainerFixture):
|
||||
"""
|
||||
Fixture for ensuring that a course exists.
|
||||
|
||||
@@ -181,6 +114,7 @@ class CourseFixture(StudioApiFixture):
|
||||
|
||||
These have the same meaning as in the Studio restful API /course end-point.
|
||||
"""
|
||||
super(CourseFixture, self).__init__()
|
||||
self._course_dict = {
|
||||
'org': org,
|
||||
'number': number,
|
||||
@@ -202,7 +136,6 @@ class CourseFixture(StudioApiFixture):
|
||||
|
||||
self._updates = []
|
||||
self._handouts = []
|
||||
self.children = []
|
||||
self._assets = []
|
||||
self._advanced_settings = {}
|
||||
self._course_key = None
|
||||
@@ -213,16 +146,6 @@ class CourseFixture(StudioApiFixture):
|
||||
"""
|
||||
return "<CourseFixture: org='{org}', number='{number}', run='{run}'>".format(**self._course_dict)
|
||||
|
||||
def add_children(self, *args):
|
||||
"""
|
||||
Add children XBlock to the course.
|
||||
Each item in `args` is an `XBlockFixtureDescriptor` object.
|
||||
|
||||
Returns the course fixture to allow chaining.
|
||||
"""
|
||||
self.children.extend(args)
|
||||
return self
|
||||
|
||||
def add_update(self, update):
|
||||
"""
|
||||
Add an update to the course. `update` should be a `CourseUpdateDesc`.
|
||||
@@ -252,7 +175,7 @@ class CourseFixture(StudioApiFixture):
|
||||
"""
|
||||
Create the course and XBlocks within the course.
|
||||
This is NOT an idempotent method; if the course already exists, this will
|
||||
raise a `CourseFixtureError`. You should use unique course identifiers to avoid
|
||||
raise a `FixtureError`. You should use unique course identifiers to avoid
|
||||
conflicts between tests.
|
||||
"""
|
||||
self._create_course()
|
||||
@@ -308,18 +231,18 @@ class CourseFixture(StudioApiFixture):
|
||||
err = response.json().get('ErrMsg')
|
||||
|
||||
except ValueError:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not parse response from course request as JSON: '{0}'".format(
|
||||
response.content))
|
||||
|
||||
# This will occur if the course identifier is not unique
|
||||
if err is not None:
|
||||
raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
|
||||
raise FixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
|
||||
|
||||
if response.ok:
|
||||
self._course_key = response.json()['course_key']
|
||||
else:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not create course {0}. Status was {1}".format(
|
||||
self._course_dict, response.status_code))
|
||||
|
||||
@@ -333,14 +256,14 @@ class CourseFixture(StudioApiFixture):
|
||||
response = self.session.get(url, headers=self.headers)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not retrieve course details. Status was {0}".format(
|
||||
response.status_code))
|
||||
|
||||
try:
|
||||
details = response.json()
|
||||
except ValueError:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not decode course details as JSON: '{0}'".format(details)
|
||||
)
|
||||
|
||||
@@ -354,7 +277,7 @@ class CourseFixture(StudioApiFixture):
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not update course details to '{0}' with {1}: Status was {2}.".format(
|
||||
self._course_details, url, response.status_code))
|
||||
|
||||
@@ -382,7 +305,7 @@ class CourseFixture(StudioApiFixture):
|
||||
response = self.session.post(url, data=payload, headers=self.headers)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not update course handouts with {0}. Status was {1}".format(url, response.status_code))
|
||||
|
||||
def _install_course_updates(self):
|
||||
@@ -399,14 +322,14 @@ class CourseFixture(StudioApiFixture):
|
||||
response = self.session.post(url, headers=self.headers, data=payload)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not add update to course: {0} with {1}. Status was {2}".format(
|
||||
update, url, response.status_code))
|
||||
|
||||
def _upload_assets(self):
|
||||
"""
|
||||
Upload assets
|
||||
:raise CourseFixtureError:
|
||||
:raise FixtureError:
|
||||
"""
|
||||
url = STUDIO_BASE_URL + self._assets_url
|
||||
|
||||
@@ -426,7 +349,7 @@ class CourseFixture(StudioApiFixture):
|
||||
upload_response = self.session.post(url, files=files, headers=headers)
|
||||
|
||||
if not upload_response.ok:
|
||||
raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
|
||||
raise FixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
|
||||
asset_name=asset_name, url=url, code=upload_response.status_code))
|
||||
|
||||
def _add_advanced_settings(self):
|
||||
@@ -442,7 +365,7 @@ class CourseFixture(StudioApiFixture):
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise CourseFixtureError(
|
||||
raise FixtureError(
|
||||
"Could not update advanced details to '{0}' with {1}: Status was {2}.".format(
|
||||
self._advanced_settings, url, response.status_code))
|
||||
|
||||
@@ -450,101 +373,7 @@ class CourseFixture(StudioApiFixture):
|
||||
"""
|
||||
Recursively create XBlock children.
|
||||
"""
|
||||
for desc in xblock_descriptions:
|
||||
loc = self.create_xblock(parent_loc, desc)
|
||||
self._create_xblock_children(loc, desc.children)
|
||||
|
||||
super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions)
|
||||
self._publish_xblock(parent_loc)
|
||||
|
||||
def get_nested_xblocks(self, category=None):
|
||||
"""
|
||||
Return a list of nested XBlocks for the course that can be filtered by
|
||||
category.
|
||||
"""
|
||||
xblocks = self._get_nested_xblocks(self)
|
||||
if category:
|
||||
xblocks = filter(lambda x: x.category == category, xblocks)
|
||||
return xblocks
|
||||
|
||||
def _get_nested_xblocks(self, xblock_descriptor):
|
||||
"""
|
||||
Return a list of nested XBlocks for the course.
|
||||
"""
|
||||
xblocks = list(xblock_descriptor.children)
|
||||
for child in xblock_descriptor.children:
|
||||
xblocks.extend(self._get_nested_xblocks(child))
|
||||
return xblocks
|
||||
|
||||
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 CourseFixtureError(msg)
|
||||
|
||||
try:
|
||||
loc = response.json().get('locator')
|
||||
xblock_desc.locator = loc
|
||||
except ValueError:
|
||||
raise CourseFixtureError("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 CourseFixtureError(
|
||||
"Could not update {0}. Status code: {1}".format(
|
||||
xblock_desc, response.status_code))
|
||||
|
||||
def _publish_xblock(self, locator):
|
||||
"""
|
||||
Publish the xblock at `locator`.
|
||||
"""
|
||||
self._update_xblock(locator, {'publish': 'make_public'})
|
||||
|
||||
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 CourseFixtureError(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()
|
||||
})
|
||||
|
||||
92
common/test/acceptance/fixtures/library.py
Normal file
92
common/test/acceptance/fixtures/library.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
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
|
||||
|
||||
97
common/test/acceptance/pages/studio/library.py
Normal file
97
common/test/acceptance/pages/studio/library.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
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 = """
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
67
common/test/acceptance/tests/studio/test_studio_home.py
Normal file
67
common/test/acceptance/tests/studio/test_studio_home.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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))
|
||||
104
common/test/acceptance/tests/studio/test_studio_library.py
Normal file
104
common/test/acceptance/tests/studio/test_studio_library.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user