Commit bdfd81d2 by Han Su Kim

Merge remote-tracking branch 'origin/master' into release, conflicts resolved

Conflicts:
	cms/envs/common.py
	common/lib/xmodule/xmodule/seq_module.py
	lms/envs/common.py
	requirements/edx/edx-private.txt
parents d2c147bf 07cec318
...@@ -121,7 +121,7 @@ Carson Gee <x@carsongee.com> ...@@ -121,7 +121,7 @@ Carson Gee <x@carsongee.com>
Oleg Marshev <oleh.marshev@gmail.com> Oleg Marshev <oleh.marshev@gmail.com>
Sylvia Pearce <spearce@edx.org> Sylvia Pearce <spearce@edx.org>
Olga Stroilova <olga@edx.org> Olga Stroilova <olga@edx.org>
Paul-Olivier Dehaye <pdehaye@gmail.com> Paul-Olivier Dehaye <paulolivier@gmail.com>
Feanil Patel <feanil@edx.org> Feanil Patel <feanil@edx.org>
Zubair Afzal <zubair.afzal@arbisoft.com> Zubair Afzal <zubair.afzal@arbisoft.com>
Juho Kim <juhokim@edx.org> Juho Kim <juhokim@edx.org>
...@@ -134,3 +134,4 @@ Avinash Sajjanshetty <avinashsajjan@gmail.com> ...@@ -134,3 +134,4 @@ Avinash Sajjanshetty <avinashsajjan@gmail.com>
David Glance <david.glance@gmail.com> David Glance <david.glance@gmail.com>
Nimisha Asthagiri <nasthagiri@edx.org> Nimisha Asthagiri <nasthagiri@edx.org>
Martyn James <mjames@edx.org> Martyn James <mjames@edx.org>
Han Su Kim <hkim823@gmail.com>
...@@ -5,6 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Blades: Show start time or starting position on slider and VCR. BLD-823.
Common: Upgraded CodeMirror to 3.21.0 with an accessibility patch applied.
LMS-1802
Studio: Add new container page that can display nested xblocks. STUD-1244. Studio: Add new container page that can display nested xblocks. STUD-1244.
Blades: Allow multiple transcripts with video. BLD-642. Blades: Allow multiple transcripts with video. BLD-642.
......
...@@ -3,10 +3,9 @@ ...@@ -3,10 +3,9 @@
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches # pylint: disable=E0611 from nose.tools import assert_false, assert_equal, assert_regexp_matches # pylint: disable=E0611
from common import type_in_codemirror, press_the_notification_button from common import type_in_codemirror, press_the_notification_button, get_codemirror_value
KEY_CSS = '.key input.policy-key' KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json'
DISPLAY_NAME_KEY = "display_name" DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"' DISPLAY_NAME_VALUE = '"Robot Super Course"'
...@@ -101,7 +100,7 @@ def assert_policy_entries(expected_keys, expected_values): ...@@ -101,7 +100,7 @@ def assert_policy_entries(expected_keys, expected_values):
for key, value in zip(expected_keys, expected_values): for key, value in zip(expected_keys, expected_values):
index = get_index_of(key) index = get_index_of(key)
assert_false(index == -1, "Could not find key: {key}".format(key=key)) assert_false(index == -1, "Could not find key: {key}".format(key=key))
found_value = world.css_find(VALUE_CSS)[index].value found_value = get_codemirror_value(index)
assert_equal( assert_equal(
value, found_value, value, found_value,
"Expected {} to have value {} but found {}".format(key, value, found_value) "Expected {} to have value {} but found {}".format(key, value, found_value)
...@@ -120,15 +119,13 @@ def get_index_of(expected_key): ...@@ -120,15 +119,13 @@ def get_index_of(expected_key):
def get_display_name_value(): def get_display_name_value():
index = get_index_of(DISPLAY_NAME_KEY) index = get_index_of(DISPLAY_NAME_KEY)
return world.css_value(VALUE_CSS, index=index) return get_codemirror_value(index)
def change_display_name_value(step, new_value): def change_display_name_value(step, new_value):
change_value(step, DISPLAY_NAME_KEY, new_value) change_value(step, DISPLAY_NAME_KEY, new_value)
def change_value(step, key, new_value): def change_value(step, key, new_value):
type_in_codemirror(get_index_of(key), new_value) index = get_index_of(key)
world.wait(0.5) type_in_codemirror(index, new_value)
press_the_notification_button(step, "Save") press_the_notification_button(step, "Save")
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
...@@ -319,20 +319,18 @@ def i_am_shown_a_notification(step): ...@@ -319,20 +319,18 @@ def i_am_shown_a_notification(step):
def type_in_codemirror(index, text): def type_in_codemirror(index, text):
world.wait(1) # For now, slow this down so that it works. TODO: fix it. script = """
world.css_click("div.CodeMirror-lines", index=index) var cm = $('div.CodeMirror:eq({})').get(0).CodeMirror;
world.browser.execute_script("$('div.CodeMirror.CodeMirror-focused > div').css('overflow', '')") cm.getInputField().focus();
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") cm.setValue(arguments[0]);
if world.is_mac(): cm.getInputField().blur();""".format(index)
g._element.send_keys(Keys.COMMAND + 'a') world.browser.driver.execute_script(script, str(text))
else:
g._element.send_keys(Keys.CONTROL + 'a')
g._element.send_keys(Keys.DELETE)
g._element.send_keys(text)
if world.is_firefox():
world.trigger_event('div.CodeMirror', index=index, event='blur')
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
def get_codemirror_value(index=0):
return world.browser.driver.execute_script("""
return $('div.CodeMirror:eq({})').get(0).CodeMirror.getValue();
""".format(index))
def upload_file(filename): def upload_file(filename):
path = os.path.join(TEST_ROOT, filename) path = os.path.join(TEST_ROOT, filename)
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from lettuce import world, step from lettuce import world, step
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from common import type_in_codemirror from common import type_in_codemirror, get_codemirror_value
from nose.tools import assert_in # pylint: disable=E0611 from nose.tools import assert_in # pylint: disable=E0611
...@@ -74,7 +74,7 @@ def change_date(_step, new_date): ...@@ -74,7 +74,7 @@ def change_date(_step, new_date):
@step(u'I should see the date "([^"]*)"$') @step(u'I should see the date "([^"]*)"$')
def check_date(_step, date): def check_date(_step, date):
date_css = 'span.date-display' date_css = 'span.date-display'
assert date == world.css_html(date_css) assert_in(date, world.css_html(date_css))
@step(u'I modify the handout to "([^"]*)"$') @step(u'I modify the handout to "([^"]*)"$')
...@@ -87,7 +87,7 @@ def edit_handouts(_step, text): ...@@ -87,7 +87,7 @@ def edit_handouts(_step, text):
@step(u'I see the handout "([^"]*)"$') @step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout): def check_handout(_step, handout):
handout_css = 'div.handouts-content' handout_css = 'div.handouts-content'
assert handout in world.css_html(handout_css) assert_in(handout, world.css_html(handout_css))
@step(u'I see the handout error text') @step(u'I see the handout error text')
...@@ -127,6 +127,6 @@ def change_text(text): ...@@ -127,6 +127,6 @@ def change_text(text):
def verify_text_in_editor_and_update(button_css, before, after): def verify_text_in_editor_and_update(button_css, before, after):
world.css_click(button_css) world.css_click(button_css)
text = world.css_find(".cm-string").html text = get_codemirror_value()
assert before in text assert_in(before, text)
change_text(after) change_text(after)
...@@ -19,3 +19,9 @@ Feature: CMS.HTML Editor ...@@ -19,3 +19,9 @@ Feature: CMS.HTML Editor
Given I have created an E-text Written in LaTeX Given I have created an E-text Written in LaTeX
When I edit and select Settings When I edit and select Settings
Then Edit High Level Source is visible Then Edit High Level Source is visible
Scenario: TinyMCE image plugin sets urls correctly
Given I have created a Blank HTML Page
When I edit the page and select the Visual Editor
And I add an image with a static link via the Image Plugin Icon
Then the image static link is rewritten to translate the path
\ No newline at end of file
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
#pylint: disable=C0111 #pylint: disable=C0111
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_in # pylint: disable=no-name-in-module
@step('I have created a Blank HTML Page$') @step('I have created a Blank HTML Page$')
...@@ -28,3 +29,43 @@ def i_created_etext_in_latex(step): ...@@ -28,3 +29,43 @@ def i_created_etext_in_latex(step):
category='html', category='html',
component_type='E-text Written in LaTeX' component_type='E-text Written in LaTeX'
) )
@step('I edit the page and select the Visual Editor')
def i_click_on_edit_icon(step):
world.edit_component()
world.wait_for(lambda _driver: world.css_visible('a.visual-tab'))
world.css_click('a.visual-tab')
@step('I add an image with a static link via the Image Plugin Icon')
def i_click_on_image_plugin_icon(step):
# Click on image plugin button
world.wait_for(lambda _driver: world.css_visible('a.mce_image'))
world.css_click('a.mce_image')
# Change to the non-modal TinyMCE Image window
# keeping parent window so we can go back to it.
parent_window = world.browser.current_window
for window in world.browser.windows:
world.browser.switch_to_window(window) # Switch to a different window
if world.browser.title == 'Insert/Edit Image':
# This is the Image window so find the url text box,
# enter text in it then hit Insert button.
url_elem = world.browser.find_by_id("src")
url_elem.fill('/static/image.jpg')
world.browser.find_by_id('insert').click()
world.browser.switch_to_window(parent_window) # Switch back to the main window
@step('the image static link is rewritten to translate the path')
def image_static_link_is_rewritten(step):
# Find the TinyMCE iframe within the main window
with world.browser.get_iframe('mce_0_ifr') as tinymce:
image = tinymce.find_by_tag('img').first
# Test onExecCommandHandler set the url to absolute.
assert_in('c4x/MITx/999/asset/image.jpg', image['src'])
...@@ -173,7 +173,7 @@ def cancel_does_not_save_changes(step): ...@@ -173,7 +173,7 @@ def cancel_does_not_save_changes(step):
def enable_latex_compiler(step): def enable_latex_compiler(step):
url = world.browser.url url = world.browser.url
step.given("I select the Advanced Settings") step.given("I select the Advanced Settings")
change_value(step, 'use_latex_compiler', True) change_value(step, 'use_latex_compiler', 'true')
world.visit(url) world.visit(url)
world.wait_for_xmodule() world.wait_for_xmodule()
......
...@@ -1003,7 +1003,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -1003,7 +1003,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case. # We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case.
vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references')) vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
draft_store.save_xmodule(vertical) draft_store.update_item(vertical, allow_not_found=True)
orphan_vertical = draft_store.get_item(vertical.location) orphan_vertical = draft_store.get_item(vertical.location)
self.assertEqual(orphan_vertical.location.name, 'no_references') self.assertEqual(orphan_vertical.location.name, 'no_references')
...@@ -1020,13 +1020,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -1020,13 +1020,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# now create a new/different private (draft only) vertical # now create a new/different private (draft only) vertical
vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None])) vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
draft_store.save_xmodule(vertical) draft_store.update_item(vertical, allow_not_found=True)
private_vertical = draft_store.get_item(vertical.location) private_vertical = draft_store.get_item(vertical.location)
vertical = None # blank out b/c i destructively manipulated its location 2 lines above vertical = None # blank out b/c i destructively manipulated its location 2 lines above
# add the new private to list of children # add the new private to list of children
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy', sequential = module_store.get_item(
'sequential', 'vertical_sequential', None])) Location('i4x', 'edX', 'toy', 'sequential', 'vertical_sequential', None)
)
private_location_no_draft = private_vertical.location.replace(revision=None) private_location_no_draft = private_vertical.location.replace(revision=None)
sequential.children.append(private_location_no_draft.url()) sequential.children.append(private_location_no_draft.url())
module_store.update_item(sequential, self.user.id) module_store.update_item(sequential, self.user.id)
......
...@@ -17,6 +17,7 @@ from .utils import CourseTestCase ...@@ -17,6 +17,7 @@ from .utils import CourseTestCase
import contentstore.git_export_utils as git_export_utils import contentstore.git_export_utils as git_export_utils
from xmodule.contentstore.django import _CONTENTSTORE from xmodule.contentstore.django import _CONTENTSTORE
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from contentstore.utils import get_modulestore
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
...@@ -70,7 +71,7 @@ class TestExportGit(CourseTestCase): ...@@ -70,7 +71,7 @@ class TestExportGit(CourseTestCase):
Test failed course export response. Test failed course export response.
""" """
self.course_module.giturl = 'foobar' self.course_module.giturl = 'foobar'
modulestore().save_xmodule(self.course_module) get_modulestore(self.course_module.location).update_item(self.course_module)
response = self.client.get('{}?action=push'.format(self.test_url)) response = self.client.get('{}?action=push'.format(self.test_url))
self.assertIn('Export Failed:', response.content) self.assertIn('Export Failed:', response.content)
...@@ -93,7 +94,7 @@ class TestExportGit(CourseTestCase): ...@@ -93,7 +94,7 @@ class TestExportGit(CourseTestCase):
self.populateCourse() self.populateCourse()
self.course_module.giturl = 'file://{}'.format(bare_repo_dir) self.course_module.giturl = 'file://{}'.format(bare_repo_dir)
modulestore().save_xmodule(self.course_module) get_modulestore(self.course_module.location).update_item(self.course_module)
response = self.client.get('{}?action=push'.format(self.test_url)) response = self.client.get('{}?action=push'.format(self.test_url))
self.assertIn('Export Succeeded', response.content) self.assertIn('Export Succeeded', response.content)
...@@ -23,11 +23,10 @@ from xblock.exceptions import NoSuchHandlerError ...@@ -23,11 +23,10 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.fields import Scope from xblock.fields import Scope
from xblock.plugin import PluginMissingError from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist from xblock.runtime import Mixologist
from xmodule.x_module import prefer_xmodules
from lms.lib.xblock.runtime import unquote_slashes from lms.lib.xblock.runtime import unquote_slashes
from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState, get_modulestore
from contentstore.views.helpers import get_parent_xblock from contentstore.views.helpers import get_parent_xblock
from models.settings.course_grading import CourseGradingModel from models.settings.course_grading import CourseGradingModel
...@@ -310,13 +309,20 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g ...@@ -310,13 +309,20 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
old_location, course, xblock, __ = _get_item_in_course(request, locator) old_location, course, xblock, __ = _get_item_in_course(request, locator)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
parent_xblock = get_parent_xblock(xblock)
ancestor_xblocks = []
parent = get_parent_xblock(xblock)
while parent and parent.category != 'sequential':
ancestor_xblocks.append(parent)
parent = get_parent_xblock(parent)
ancestor_xblocks.reverse()
return render_to_response('container.html', { return render_to_response('container.html', {
'context_course': course, 'context_course': course,
'xblock': xblock, 'xblock': xblock,
'xblock_locator': locator, 'xblock_locator': locator,
'parent_xblock': parent_xblock, 'ancestor_xblocks': ancestor_xblocks,
}) })
else: else:
return HttpResponseBadRequest("Only supports html requests") return HttpResponseBadRequest("Only supports html requests")
...@@ -359,7 +365,7 @@ def component_handler(request, usage_id, handler, suffix=''): ...@@ -359,7 +365,7 @@ def component_handler(request, usage_id, handler, suffix=''):
location = unquote_slashes(usage_id) location = unquote_slashes(usage_id)
descriptor = modulestore().get_item(location) descriptor = get_modulestore(location).get_item(location)
# Let the module handle the AJAX # Let the module handle the AJAX
req = django_to_webob_request(request) req = django_to_webob_request(request)
...@@ -370,6 +376,8 @@ def component_handler(request, usage_id, handler, suffix=''): ...@@ -370,6 +376,8 @@ def component_handler(request, usage_id, handler, suffix=''):
log.info("XBlock %s attempted to access missing handler %r", descriptor, handler, exc_info=True) log.info("XBlock %s attempted to access missing handler %r", descriptor, handler, exc_info=True)
raise Http404 raise Http404
modulestore().save_xmodule(descriptor) # unintentional update to handle any side effects of handle call; so, request user didn't author
# the change
get_modulestore(location).update_item(descriptor, None)
return webob_to_django_response(resp) return webob_to_django_response(resp)
...@@ -127,6 +127,12 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid ...@@ -127,6 +127,12 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
return _delete_item_at_location(old_location, delete_children, delete_all_versions, request.user) return _delete_item_at_location(old_location, delete_children, delete_all_versions, request.user)
else: # Since we have a package_id, we are updating an existing xblock. else: # Since we have a package_id, we are updating an existing xblock.
if block == 'handouts' and old_location is None:
# update handouts location in loc_mapper
course_location = loc_mapper().translate_locator_to_location(locator, get_course=True)
old_location = course_location.replace(category='course_info', name=block)
locator = loc_mapper().translate_location(course_location.course_id, old_location)
return _save_item( return _save_item(
request, request,
locator, locator,
...@@ -202,16 +208,16 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -202,16 +208,16 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
log.debug("unable to render studio_view for %r", component, exc_info=True) log.debug("unable to render studio_view for %r", component, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
store.save_xmodule(component) # change not authored by requestor but by xblocks.
store.update_item(component, None)
elif view_name == 'student_view' and component.has_children: elif view_name == 'student_view' and component.has_children:
# For non-leaf xblocks on the unit page, show the special rendering # For non-leaf xblocks on the unit page, show the special rendering
# which links to the new container page. # which links to the new container page.
course_location = loc_mapper().translate_locator_to_location(locator, True) html = render_to_string('container_xblock_component.html', {
course = store.get_item(course_location)
html = render_to_string('unit_container_xblock_component.html', {
'course': course,
'xblock': component, 'xblock': component,
'locator': locator 'locator': locator,
'reordering_enabled': True,
}) })
return JsonResponse({ return JsonResponse({
'html': html, 'html': html,
...@@ -521,8 +527,8 @@ def orphan_handler(request, tag=None, package_id=None, branch=None, version_guid ...@@ -521,8 +527,8 @@ def orphan_handler(request, tag=None, package_id=None, branch=None, version_guid
if request.method == 'DELETE': if request.method == 'DELETE':
if request.user.is_staff: if request.user.is_staff:
items = modulestore().get_orphans(old_location, 'draft') items = modulestore().get_orphans(old_location, 'draft')
for item in items: for itemloc in items:
modulestore('draft').delete_item(item, delete_all_versions=True) modulestore('draft').delete_item(itemloc, delete_all_versions=True)
return JsonResponse({'deleted': items}) return JsonResponse({'deleted': items})
else: else:
raise PermissionDenied() raise PermissionDenied()
......
...@@ -179,6 +179,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -179,6 +179,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
} }
if xblock.category == 'vertical': if xblock.category == 'vertical':
template = 'studio_vertical_wrapper.html' template = 'studio_vertical_wrapper.html'
elif xblock.location != context.get('root_xblock').location and xblock.has_children:
template = 'container_xblock_component.html'
else: else:
template = 'studio_xblock_wrapper.html' template = 'studio_xblock_wrapper.html'
html = render_to_string(template, template_context) html = render_to_string(template, template_context)
......
...@@ -26,8 +26,44 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -26,8 +26,44 @@ class ContainerViewTestCase(CourseTestCase):
category="video", display_name="My Video") category="video", display_name="My Video")
def test_container_html(self): def test_container_html(self):
url = xblock_studio_url(self.child_vertical) self._test_html_content(
self.child_vertical,
expected_section_tag='<section class="wrapper-xblock level-page" data-locator="MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical"/>',
expected_breadcrumbs=(
r'<a href="/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Child Vertical</a>'),
)
def test_container_on_container_html(self):
"""
Create the scenario of an xblock with children (non-vertical) on the container page.
This should create a container page that is a child of another container page.
"""
xblock_with_child = ItemFactory.create(parent_location=self.child_vertical.location,
category="wrapper", display_name="Wrapper")
ItemFactory.create(parent_location=xblock_with_child.location,
category="html", display_name="Child HTML")
self._test_html_content(
xblock_with_child,
expected_section_tag='<section class="wrapper-xblock level-page" data-locator="MITx.999.Robot_Super_Course/branch/published/block/Wrapper"/>',
expected_breadcrumbs=(
r'<a href="/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="/container/MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical"\s*'
r'class="navigation-link navigation-parent">Child Vertical</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'),
)
def _test_html_content(self, xblock, expected_section_tag, expected_breadcrumbs):
"""
Get the HTML for a container page and verify the section tag is correct
and the breadcrumbs trail is correct.
"""
url = xblock_studio_url(xblock, self.course)
resp = self.client.get_html(url) resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
html = resp.content html = resp.content
self.assertIn('<section class="wrapper-xblock level-page" data-locator="MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical"/>', html) self.assertIn(expected_section_tag, html)
# Verify the navigation link at the top of the page is correct.
self.assertRegexpMatches(html, expected_breadcrumbs)
...@@ -230,7 +230,8 @@ class CourseUpdateTest(CourseTestCase): ...@@ -230,7 +230,8 @@ class CourseUpdateTest(CourseTestCase):
def test_post_course_update(self): def test_post_course_update(self):
""" """
Test that a user can successfully post on course updates of a course whose location in not in loc_mapper Test that a user can successfully post on course updates and handouts of a course
whose location in not in loc_mapper
""" """
# create a course via the view handler # create a course via the view handler
course_location = Location(['i4x', 'Org_1', 'Course_1', 'course', 'Run_1']) course_location = Location(['i4x', 'Org_1', 'Course_1', 'course', 'Run_1'])
...@@ -270,3 +271,19 @@ class CourseUpdateTest(CourseTestCase): ...@@ -270,3 +271,19 @@ class CourseUpdateTest(CourseTestCase):
updates_locator = loc_mapper().translate_location(course_location.course_id, updates_location) updates_locator = loc_mapper().translate_location(course_location.course_id, updates_location)
self.assertTrue(isinstance(updates_locator, BlockUsageLocator)) self.assertTrue(isinstance(updates_locator, BlockUsageLocator))
self.assertEqual(updates_locator.block_id, block) self.assertEqual(updates_locator.block_id, block)
# check posting on handouts
block = u'handouts'
handouts_locator = BlockUsageLocator(
package_id=updates_locator.package_id, branch=updates_locator.branch, version_guid=version, block_id=block
)
course_handouts_url = handouts_locator.url_reverse('xblock')
content = u"Sample handout"
payload = {"data": content}
resp = self.client.ajax_post(course_handouts_url, payload)
# check that response status is 200 not 500
self.assertEqual(resp.status_code, 200)
payload = json.loads(resp.content)
self.assertHTMLEqual(payload['data'], content)
...@@ -132,6 +132,31 @@ class GetItem(ItemTest): ...@@ -132,6 +132,31 @@ class GetItem(ItemTest):
# Verify that the Studio element wrapper has been added # Verify that the Studio element wrapper has been added
self.assertIn('level-element', html) self.assertIn('level-element', html)
def test_get_container_nested_container_fragment(self):
"""
Test the case of the container page containing a link to another container page.
"""
# Add a wrapper with child beneath a child vertical
root_locator = self._create_vertical()
resp = self.create_xblock(parent_locator=root_locator, category="wrapper")
self.assertEqual(resp.status_code, 200)
wrapper_locator = self.response_locator(resp)
resp = self.create_xblock(parent_locator=wrapper_locator, category='problem', boilerplate='multiplechoice.yaml')
self.assertEqual(resp.status_code, 200)
# Get the preview HTML and verify the View -> link is present.
html, __ = self._get_container_preview(root_locator)
self.assertIn('wrapper-xblock', html)
self.assertRegexpMatches(
html,
# The instance of the wrapper class will have an auto-generated ID (wrapperxxx). Allow anything
# for the 3 characters after wrapper.
(r'"/container/MITx.999.Robot_Super_Course/branch/published/block/wrapper.{3}" class="action-button">\s*'
'<span class="action-button-text">View</span>')
)
class DeleteItem(ItemTest): class DeleteItem(ItemTest):
"""Tests for '/xblock' DELETE url.""" """Tests for '/xblock' DELETE url."""
...@@ -636,11 +661,11 @@ class TestComponentHandler(TestCase): ...@@ -636,11 +661,11 @@ class TestComponentHandler(TestCase):
def setUp(self): def setUp(self):
self.request_factory = RequestFactory() self.request_factory = RequestFactory()
patcher = patch('contentstore.views.component.modulestore') patcher = patch('contentstore.views.component.get_modulestore')
self.modulestore = patcher.start() self.get_modulestore = patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.descriptor = self.modulestore.return_value.get_item.return_value self.descriptor = self.get_modulestore.return_value.get_item.return_value
self.usage_id = 'dummy_usage_id' self.usage_id = 'dummy_usage_id'
......
...@@ -194,7 +194,7 @@ class CourseDetails(object): ...@@ -194,7 +194,7 @@ class CourseDetails(object):
result = None result = None
if video_key: if video_key:
result = '<iframe width="560" height="315" src="//www.youtube.com/embed/' + \ result = '<iframe width="560" height="315" src="//www.youtube.com/embed/' + \
video_key + '?autoplay=1&rel=0" frameborder="0" allowfullscreen=""></iframe>' video_key + '?rel=0" frameborder="0" allowfullscreen=""></iframe>'
return result return result
......
...@@ -16,7 +16,7 @@ os.environ['SERVICE_VARIANT'] = 'bok_choy' ...@@ -16,7 +16,7 @@ os.environ['SERVICE_VARIANT'] = 'bok_choy'
os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() #pylint: disable=E1120 os.environ['CONFIG_ROOT'] = path(__file__).abspath().dirname() #pylint: disable=E1120
from .aws import * # pylint: disable=W0401, W0614 from .aws import * # pylint: disable=W0401, W0614
from xmodule.x_module import prefer_xmodules from xmodule.modulestore import prefer_xmodules
######################### Testing overrides #################################### ######################### Testing overrides ####################################
......
...@@ -296,7 +296,7 @@ PIPELINE_CSS = { ...@@ -296,7 +296,7 @@ PIPELINE_CSS = {
'css/vendor/normalize.css', 'css/vendor/normalize.css',
'css/vendor/font-awesome.css', 'css/vendor/font-awesome.css',
'css/vendor/html5-input-polyfills/number-polyfill.css', 'css/vendor/html5-input-polyfills/number-polyfill.css',
'js/vendor/CodeMirror/codemirror.css', 'js/vendor/CodeMirror/codemirror-3.21.0.css',
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
'css/vendor/jquery.qtip.min.css', 'css/vendor/jquery.qtip.min.css',
'js/vendor/markitup/skins/simple/style.css', 'js/vendor/markitup/skins/simple/style.css',
......
...@@ -16,6 +16,7 @@ from .common import * ...@@ -16,6 +16,7 @@ from .common import *
import os import os
from path import path from path import path
from warnings import filterwarnings from warnings import filterwarnings
from xmodule.modulestore import prefer_xmodules
# Nose Test Runner # Nose Test Runner
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
...@@ -158,7 +159,6 @@ filterwarnings('ignore', message='No request passed to the backend, unable to ra ...@@ -158,7 +159,6 @@ filterwarnings('ignore', message='No request passed to the backend, unable to ra
################################# XBLOCK ###################################### ################################# XBLOCK ######################################
from xmodule.x_module import prefer_xmodules
XBLOCK_SELECT_FUNCTION = prefer_xmodules XBLOCK_SELECT_FUNCTION = prefer_xmodules
......
...@@ -20,6 +20,10 @@ define [ ...@@ -20,6 +20,10 @@ define [
super() super()
@savingNotification = new NotificationView.Mini @savingNotification = new NotificationView.Mini
title: gettext('Saving&hellip;') title: gettext('Saving&hellip;')
@alert = new NotificationView.Error
title: "OpenAssessment Save Error",
closeIcon: false,
shown: false
handlerUrl: (element, handlerName, suffix, query, thirdparty) -> handlerUrl: (element, handlerName, suffix, query, thirdparty) ->
uri = URI("/xblock").segment($(element).data('usage-id')) uri = URI("/xblock").segment($(element).data('usage-id'))
...@@ -41,11 +45,15 @@ define [ ...@@ -41,11 +45,15 @@ define [
# Starting to save, so show the "Saving..." notification # Starting to save, so show the "Saving..." notification
if data.state == 'start' if data.state == 'start'
@_hideEditor()
@savingNotification.show() @savingNotification.show()
# Finished saving, so hide the "Saving..." notification # Finished saving, so hide the "Saving..." notification
else if data.state == 'end' else if data.state == 'end'
# Hide the editor *after* we finish saving in case there are validation
# errors that the user needs to correct.
@_hideEditor()
$('.component.editing').removeClass('editing') $('.component.editing').removeClass('editing')
@savingNotification.hide() @savingNotification.hide()
...@@ -54,7 +62,8 @@ define [ ...@@ -54,7 +62,8 @@ define [
else if name == 'error' else if name == 'error'
if 'msg' of data if 'msg' of data
@_showAlert(data.msg) @alert.options.message = data.msg
@alert.show()
_hideEditor: () -> _hideEditor: () ->
# This will close all open component editors, which works # This will close all open component editors, which works
...@@ -64,9 +73,6 @@ define [ ...@@ -64,9 +73,6 @@ define [
el.find('.component-editor').slideUp(150) el.find('.component-editor').slideUp(150)
ModalUtils.hideModalCover() ModalUtils.hideModalCover()
_showAlert: (msg) -> # Hide any alerts that are being shown
new NotificationView.Error({ if @alert.options.shown
title: "OpenAssessment Save Error", @alert.hide()
message: msg,
closeIcon: false
}).show()
...@@ -7,9 +7,10 @@ define(["codemirror", 'js/utils/handle_iframe_binding', "utility"], ...@@ -7,9 +7,10 @@ define(["codemirror", 'js/utils/handle_iframe_binding', "utility"],
mode: "text/html", mode: "text/html",
lineNumbers: true, lineNumbers: true,
lineWrapping: true, lineWrapping: true,
onChange: function () { autoCloseTags: true
});
$codeMirror.on('change', function () {
$('.save-button').removeClass('is-disabled'); $('.save-button').removeClass('is-disabled');
}
}); });
$codeMirror.setValue(content); $codeMirror.setValue(content);
$codeMirror.clearHistory(); $codeMirror.clearHistory();
......
...@@ -47,9 +47,11 @@ var AdvancedView = ValidatingView.extend({ ...@@ -47,9 +47,11 @@ var AdvancedView = ValidatingView.extend({
var self = this; var self = this;
var oldValue = $(textarea).val(); var oldValue = $(textarea).val();
CodeMirror.fromTextArea(textarea, { var cm = CodeMirror.fromTextArea(textarea, {
mode: "application/json", lineNumbers: false, lineWrapping: false, mode: "application/json",
onChange: function(instance, changeobj) { lineNumbers: false,
lineWrapping: false});
cm.on('change', function(instance, changeobj) {
instance.save(); instance.save();
// this event's being called even when there's no change :-( // this event's being called even when there's no change :-(
if (instance.getValue() !== oldValue) { if (instance.getValue() !== oldValue) {
...@@ -58,11 +60,11 @@ var AdvancedView = ValidatingView.extend({ ...@@ -58,11 +60,11 @@ var AdvancedView = ValidatingView.extend({
_.bind(self.saveView, self), _.bind(self.saveView, self),
_.bind(self.revertView, self)); _.bind(self.revertView, self));
} }
}, });
onFocus : function(mirror) { cm.on('focus', function(mirror) {
$(textarea).parent().children('label').addClass("is-focused"); $(textarea).parent().children('label').addClass("is-focused");
}, });
onBlur: function (mirror) { cm.on('blur', function (mirror) {
$(textarea).parent().children('label').removeClass("is-focused"); $(textarea).parent().children('label').removeClass("is-focused");
var key = $(mirror.getWrapperElement()).closest('.field-group').children('.key').attr('id'); var key = $(mirror.getWrapperElement()).closest('.field-group').children('.key').attr('id');
var stringValue = $.trim(mirror.getValue()); var stringValue = $.trim(mirror.getValue());
...@@ -91,8 +93,7 @@ var AdvancedView = ValidatingView.extend({ ...@@ -91,8 +93,7 @@ var AdvancedView = ValidatingView.extend({
if (JSONValue !== undefined) { if (JSONValue !== undefined) {
self.model.set(key, JSONValue); self.model.set(key, JSONValue);
} }
} });
});
}, },
saveView : function() { saveView : function() {
// TODO one last verification scan: // TODO one last verification scan:
......
...@@ -206,15 +206,14 @@ var DetailsView = ValidatingView.extend({ ...@@ -206,15 +206,14 @@ var DetailsView = ValidatingView.extend({
var cachethis = this; var cachethis = this;
var field = this.selectorToField[thisTarget.id]; var field = this.selectorToField[thisTarget.id];
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, { this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
mode: "text/html", lineNumbers: true, lineWrapping: true, mode: "text/html", lineNumbers: true, lineWrapping: true});
onChange: function (mirror) { this.codeMirrors[thisTarget.id].on('change', function (mirror) {
mirror.save(); mirror.save();
cachethis.clearValidationErrors(); cachethis.clearValidationErrors();
var newVal = mirror.getValue(); var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) { if (cachethis.model.get(field) != newVal) {
cachethis.setAndValidate(field, newVal); cachethis.setAndValidate(field, newVal);
} }
}
}); });
} }
}, },
......
...@@ -334,11 +334,12 @@ p, ul, ol, dl { ...@@ -334,11 +334,12 @@ p, ul, ol, dl {
.navigation-link { .navigation-link {
@extend %cont-truncated; @extend %cont-truncated;
display: inline-block; display: inline-block;
max-width: 150px; max-width: 250px;
&.navigation-current { &.navigation-current {
@extend %ui-disabled; @extend %ui-disabled;
color: $gray; color: $gray;
max-width: 250px;
&:before { &:before {
color: $gray; color: $gray;
......
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
} }
// UI: xblock is collapsible // UI: xblock is collapsible
.wrapper-xblock.is-collapsible { .wrapper-xblock.is-collapsible, .wrapper-xblock.xblock-type-container {
[class^="icon-"] { [class^="icon-"] {
font-style: normal; font-style: normal;
......
...@@ -831,19 +831,15 @@ ...@@ -831,19 +831,15 @@
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
color: $baseFontColor; color: $baseFontColor;
outline: 0; outline: 0;
height: auto;
min-height: ($baseline*2.25);
max-height: ($baseline*10);
&.CodeMirror-focused { &.CodeMirror-focused {
@include linear-gradient($paleYellow, tint($paleYellow, 90%)); @include linear-gradient($paleYellow, tint($paleYellow, 90%));
outline: 0; outline: 0;
} }
.CodeMirror-scroll {
overflow: hidden;
height: auto;
min-height: ($baseline*1.5);
max-height: ($baseline*10);
}
// editor color changes just for JSON // editor color changes just for JSON
.CodeMirror-lines { .CodeMirror-lines {
......
...@@ -16,7 +16,7 @@ from django.utils.translation import ugettext as _ ...@@ -16,7 +16,7 @@ from django.utils.translation import ugettext as _
<% <%
xblock_info = { xblock_info = {
'id': str(xblock_locator), 'id': str(xblock_locator),
'display-name': xblock.display_name, 'display-name': xblock.display_name_with_default,
'category': xblock.category, 'category': xblock.category,
}; };
%> %>
...@@ -47,14 +47,16 @@ xblock_info = { ...@@ -47,14 +47,16 @@ xblock_info = {
<header class="mast has-actions has-navigation"> <header class="mast has-actions has-navigation">
<h1 class="page-header"> <h1 class="page-header">
<small class="navigation navigation-parents"> <small class="navigation navigation-parents">
<% % for ancestor in ancestor_xblocks:
parent_url = xblock_studio_url(parent_xblock, context_course) <%
%> ancestor_url = xblock_studio_url(ancestor, context_course)
% if parent_url: %>
<a href="${parent_url}" % if ancestor_url:
class="navigation-link navigation-parent">${parent_xblock.display_name | h}</a> <a href="${ancestor_url}"
% endif class="navigation-link navigation-parent">${ancestor.display_name_with_default | h}</a>
<a href="#" class="navigation-link navigation-current">${xblock.display_name | h}</a> % endif
% endfor
<a href="#" class="navigation-link navigation-current">${xblock.display_name_with_default | h}</a>
</small> </small>
</h1> </h1>
......
...@@ -7,12 +7,12 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -7,12 +7,12 @@ from contentstore.views.helpers import xblock_studio_url
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${locator}"> <section class="wrapper-xblock xblock-type-container level-element" data-locator="${locator}">
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
${xblock.display_name} ${xblock.display_name_with_default}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-view"> <li class="action-item action-view">
<a href="${xblock_studio_url(xblock, course)}" class="action-button"> <a href="${xblock_studio_url(xblock)}" class="action-button">
## Translators: this is a verb describing the action of viewing more details ## Translators: this is a verb describing the action of viewing more details
<span class="action-button-text">${_('View')}</span> <span class="action-button-text">${_('View')}</span>
<i class="icon-arrow-right"></i> <i class="icon-arrow-right"></i>
...@@ -21,5 +21,8 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -21,5 +21,8 @@ from contentstore.views.helpers import xblock_studio_url
</ul> </ul>
</div> </div>
</header> </header>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span> ## We currently support reordering only on the unit page.
% if reordering_enabled:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
</section> </section>
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">${_('Expand or Collapse')}</span> <span class="sr">${_('Expand or Collapse')}</span>
</a> </a>
<span>${xblock.display_name | h}</span> <span>${xblock.display_name_with_default | h}</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
% endif % endif
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
${xblock.display_name | h} ${xblock.display_name_with_default | h}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
...@@ -97,7 +97,7 @@ require(["jquery", "jquery.leanModal", "codemirror/stex"], function($) { ...@@ -97,7 +97,7 @@ require(["jquery", "jquery.leanModal", "codemirror/stex"], function($) {
}); });
// resize the codemirror box // resize the codemirror box
var h = el.height(); var h = el.height();
el.find('.CodeMirror-scroll').height(h - 100); el.find('.CodeMirror').height(h - 160);
} }
// compile & save button // compile & save button
......
...@@ -29,6 +29,7 @@ from django.dispatch import receiver, Signal ...@@ -29,6 +29,7 @@ from django.dispatch import receiver, Signal
import django.dispatch import django.dispatch
from django.forms import ModelForm, forms from django.forms import ModelForm, forms
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop
from django_countries import CountryField from django_countries import CountryField
from track import contexts from track import contexts
from track.views import server_track from track.views import server_track
...@@ -189,7 +190,12 @@ class UserProfile(models.Model): ...@@ -189,7 +190,12 @@ class UserProfile(models.Model):
this_year = datetime.now(UTC).year this_year = datetime.now(UTC).year
VALID_YEARS = range(this_year, this_year - 120, -1) VALID_YEARS = range(this_year, this_year - 120, -1)
year_of_birth = models.IntegerField(blank=True, null=True, db_index=True) year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other')) GENDER_CHOICES = (
('m', ugettext_noop('Male')),
('f', ugettext_noop('Female')),
# Translators: 'Other' refers to the student's gender
('o', ugettext_noop('Other'))
)
gender = models.CharField( gender = models.CharField(
blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES
) )
...@@ -199,15 +205,17 @@ class UserProfile(models.Model): ...@@ -199,15 +205,17 @@ class UserProfile(models.Model):
# ('p_se', 'Doctorate in science or engineering'), # ('p_se', 'Doctorate in science or engineering'),
# ('p_oth', 'Doctorate in another field'), # ('p_oth', 'Doctorate in another field'),
LEVEL_OF_EDUCATION_CHOICES = ( LEVEL_OF_EDUCATION_CHOICES = (
('p', 'Doctorate'), ('p', ugettext_noop('Doctorate')),
('m', "Master's or professional degree"), ('m', ugettext_noop("Master's or professional degree")),
('b', "Bachelor's degree"), ('b', ugettext_noop("Bachelor's degree")),
('a', "Associate's degree"), ('a', ugettext_noop("Associate's degree")),
('hs', "Secondary/high school"), ('hs', ugettext_noop("Secondary/high school")),
('jhs', "Junior secondary/junior high/middle school"), ('jhs', ugettext_noop("Junior secondary/junior high/middle school")),
('el', "Elementary/primary school"), ('el', ugettext_noop("Elementary/primary school")),
('none', "None"), # Translators: 'None' refers to the student's level of education
('other', "Other") ('none', ugettext_noop("None")),
# Translators: 'Other' refers to the student's level of education
('other', ugettext_noop("Other"))
) )
level_of_education = models.CharField( level_of_education = models.CharField(
blank=True, null=True, max_length=6, db_index=True, blank=True, null=True, max_length=6, db_index=True,
......
<%! from django.utils.translation import ugettext as _ %>
<section id="textbox_${id}" class="textbox"> <section id="textbox_${id}" class="textbox">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" <textarea rows="${rows}" cols="${cols}" name="input_${id}"
aria-label="${_("{programming_language} editor").format(programming_language=mode)}"
aria-describedby="answer_${id}"
id="input_${id}"
tabindex="0"
% if hidden: % if hidden:
style="display:none;" style="display:none;"
% endif % endif
>${value|h}</textarea> >${value|h}</textarea>
<div class="grader-status"> <div class="grader-status" tabindex="1">
% if status == 'unsubmitted': % if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Unanswered</span> <span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Unanswered</span>
% elif status == 'correct': % elif status == 'correct':
...@@ -44,13 +49,16 @@ ...@@ -44,13 +49,16 @@
tabSize: "${tabsize}", tabSize: "${tabsize}",
indentWithTabs: false, indentWithTabs: false,
extraKeys: { extraKeys: {
"Esc": function(cm) {
$('.grader-status').focus();
return false;
},
"Tab": function(cm) { "Tab": function(cm) {
cm.replaceSelection("${' '*tabsize}", "end"); cm.replaceSelection("${' '*tabsize}", "end");
} }
}, },
smartIndent: false smartIndent: false
}); });
$("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))});
}); });
</script> </script>
</section> </section>
...@@ -31,3 +31,10 @@ class SerializationError(Exception): ...@@ -31,3 +31,10 @@ class SerializationError(Exception):
def __init__(self, location, msg): def __init__(self, location, msg):
super(SerializationError, self).__init__(msg) super(SerializationError, self).__init__(msg)
self.location = location self.location = location
class UndefinedContext(Exception):
"""
Tried to access an xmodule field which needs a different context (runtime) to have a value.
"""
pass
...@@ -183,7 +183,7 @@ class WeightedSubsectionsGrader(CourseGrader): ...@@ -183,7 +183,7 @@ class WeightedSubsectionsGrader(CourseGrader):
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores) subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
weighted_percent = subgrade_result['percent'] * weight weighted_percent = subgrade_result['percent'] * weight
section_detail = u"{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight) section_detail = u"{0} = {1:.2%} of a possible {2:.2%}".format(category, weighted_percent, weight)
total_percent += weighted_percent total_percent += weighted_percent
section_breakdown += subgrade_result['section_breakdown'] section_breakdown += subgrade_result['section_breakdown']
......
...@@ -253,8 +253,8 @@ ...@@ -253,8 +253,8 @@
return state; return state;
}; };
jasmine.initializePlayerYouTube = function () { jasmine.initializePlayerYouTube = function (params) {
// "video.html" contains HTML template for a YouTube video. // "video.html" contains HTML template for a YouTube video.
return jasmine.initializePlayer('video.html'); return jasmine.initializePlayer('video.html', params);
}; };
}).call(this, window.jQuery); }).call(this, window.jQuery);
...@@ -89,17 +89,15 @@ describe 'HTMLEditingDescriptor', -> ...@@ -89,17 +89,15 @@ describe 'HTMLEditingDescriptor', ->
@descriptor.showingVisualEditor = false @descriptor.showingVisualEditor = false
visualEditorStub = visualEditorStub =
isNotDirty: false
content: 'not set' content: 'not set'
startContent: 'not set', startContent: 'not set',
focus: () -> true focus: () -> true
isDirty: () -> not @isNotDirty isDirty: () -> false
setContent: (x) -> @content = x setContent: (x) -> @content = x
getContent: -> @content getContent: -> @content
@descriptor.showVisualEditor(visualEditorStub) @descriptor.showVisualEditor(visualEditorStub)
expect(@descriptor.showingVisualEditor).toEqual(true) expect(@descriptor.showingVisualEditor).toEqual(true)
expect(visualEditorStub.isDirty()).toEqual(false)
expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text') expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text')
expect(visualEditorStub.startContent).toEqual('Advanced Editor Text') expect(visualEditorStub.startContent).toEqual('Advanced Editor Text')
it 'When switching to visual editor links are rewritten to c4x format', -> it 'When switching to visual editor links are rewritten to c4x format', ->
...@@ -109,16 +107,14 @@ describe 'HTMLEditingDescriptor', -> ...@@ -109,16 +107,14 @@ describe 'HTMLEditingDescriptor', ->
@descriptor.showingVisualEditor = false @descriptor.showingVisualEditor = false
visualEditorStub = visualEditorStub =
isNotDirty: false
content: 'not set' content: 'not set'
startContent: 'not set', startContent: 'not set',
focus: () -> true focus: () -> true
isDirty: () -> not @isNotDirty isDirty: () -> false
setContent: (x) -> @content = x setContent: (x) -> @content = x
getContent: -> @content getContent: -> @content
@descriptor.showVisualEditor(visualEditorStub) @descriptor.showVisualEditor(visualEditorStub)
expect(@descriptor.showingVisualEditor).toEqual(true) expect(@descriptor.showingVisualEditor).toEqual(true)
expect(visualEditorStub.isDirty()).toEqual(false)
expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg') expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg')
expect(visualEditorStub.startContent).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg') expect(visualEditorStub.startContent).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg')
...@@ -538,10 +538,8 @@ function (VideoPlayer) { ...@@ -538,10 +538,8 @@ function (VideoPlayer) {
describe('updatePlayTime', function () { describe('updatePlayTime', function () {
beforeEach(function () { beforeEach(function () {
state = jasmine.initializePlayer(); state = jasmine.initializePlayerYouTube();
state.videoEl = $('video, iframe'); state.videoEl = $('video, iframe');
spyOn(state.videoCaption, 'updatePlayTime').andCallThrough(); spyOn(state.videoCaption, 'updatePlayTime').andCallThrough();
spyOn(state.videoProgressSlider, 'updatePlayTime').andCallThrough(); spyOn(state.videoProgressSlider, 'updatePlayTime').andCallThrough();
}); });
...@@ -560,27 +558,10 @@ function (VideoPlayer) { ...@@ -560,27 +558,10 @@ function (VideoPlayer) {
}, 'Video is fully loaded.', WAIT_TIMEOUT); }, 'Video is fully loaded.', WAIT_TIMEOUT);
runs(function () { runs(function () {
var htmlStr;
state.videoPlayer.goToStartTime = false; state.videoPlayer.goToStartTime = false;
state.videoPlayer.updatePlayTime(60); state.videoPlayer.updatePlayTime(60);
htmlStr = $('.vidtime').html(); expect($('.vidtime')).toHaveHtml('1:00 / 1:00');
// We resort to this trickery because Firefox and Chrome
// round the total time a bit differently.
if (
htmlStr.match('1:00 / 1:01') ||
htmlStr.match('1:00 / 1:00')
) {
expect(true).toBe(true);
} else {
expect(true).toBe(false);
}
// The below test has been replaced by above trickery:
//
// expect($('.vidtime')).toHaveHtml('1:00 / 1:01');
}); });
}); });
...@@ -691,7 +672,9 @@ function (VideoPlayer) { ...@@ -691,7 +672,9 @@ function (VideoPlayer) {
endTime: undefined, endTime: undefined,
player: { player: {
seekTo: function () {} seekTo: function () {}
} },
figureOutStartEndTime: jasmine.createSpy(),
figureOutStartingTime: jasmine.createSpy().andReturn(0)
}, },
config: { config: {
savedVideoPosition: 0, savedVideoPosition: 0,
...@@ -712,6 +695,11 @@ function (VideoPlayer) { ...@@ -712,6 +695,11 @@ function (VideoPlayer) {
it('invalid endTime is reset to null', function () { it('invalid endTime is reset to null', function () {
VideoPlayer.prototype.updatePlayTime.call(state, 0); VideoPlayer.prototype.updatePlayTime.call(state, 0);
expect(state.videoPlayer.figureOutStartingTime).toHaveBeenCalled();
VideoPlayer.prototype.figureOutStartEndTime.call(state, 60);
VideoPlayer.prototype.figureOutStartingTime.call(state, 60);
expect(state.videoPlayer.endTime).toBe(null); expect(state.videoPlayer.endTime).toBe(null);
}); });
}); });
......
...@@ -42,7 +42,7 @@ class @HTMLEditingDescriptor ...@@ -42,7 +42,7 @@ class @HTMLEditingDescriptor
# Disable visual aid on borderless table. # Disable visual aid on borderless table.
visual:false, visual:false,
# We may want to add "styleselect" when we collect all styles used throughout the LMS # We may want to add "styleselect" when we collect all styles used throughout the LMS
theme_advanced_buttons1 : "formatselect,fontselect,bold,italic,underline,forecolor,|,bullist,numlist,outdent,indent,|,blockquote,wrapAsCode,|,link,unlink,|,image,", theme_advanced_buttons1 : "formatselect,fontselect,bold,italic,underline,forecolor,|,bullist,numlist,outdent,indent,|,link,unlink,image,|,blockquote,wrapAsCode",
theme_advanced_toolbar_location : "top", theme_advanced_toolbar_location : "top",
theme_advanced_toolbar_align : "left", theme_advanced_toolbar_align : "left",
theme_advanced_statusbar_location : "none", theme_advanced_statusbar_location : "none",
...@@ -80,6 +80,15 @@ class @HTMLEditingDescriptor ...@@ -80,6 +80,15 @@ class @HTMLEditingDescriptor
) )
@visualEditor = ed @visualEditor = ed
ed.onExecCommand.add(@onExecCommandHandler)
# Intended to run after the "image" plugin is used so that static urls are set
# correctly in the Visual editor immediately after command use.
onExecCommandHandler: (ed, cmd, ui, val) =>
if cmd == 'mceInsertContent' and val.match(/^<img/)
content = rewriteStaticLinks(ed.getContent(), '/static/', @base_asset_url)
ed.setContent(content)
onSwitchEditor: (e) => onSwitchEditor: (e) =>
e.preventDefault(); e.preventDefault();
...@@ -114,7 +123,7 @@ class @HTMLEditingDescriptor ...@@ -114,7 +123,7 @@ class @HTMLEditingDescriptor
# both the startContent must be sync'ed up and the dirty flag set to false. # both the startContent must be sync'ed up and the dirty flag set to false.
content = rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url) content = rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url)
visualEditor.setContent(content) visualEditor.setContent(content)
visualEditor.startContent = content visualEditor.startContent = visualEditor.getContent({format : 'raw'})
@focusVisualEditor(visualEditor) @focusVisualEditor(visualEditor)
@showingVisualEditor = true @showingVisualEditor = true
...@@ -124,8 +133,6 @@ class @HTMLEditingDescriptor ...@@ -124,8 +133,6 @@ class @HTMLEditingDescriptor
focusVisualEditor: (visualEditor) => focusVisualEditor: (visualEditor) =>
visualEditor.focus() visualEditor.focus()
# Need to mark editor as not dirty both when it is initially created and when we switch back to it.
visualEditor.isNotDirty = true
if not @$mceToolbar? if not @$mceToolbar?
@$mceToolbar = $(@element).find('table.mceToolbar') @$mceToolbar = $(@element).find('table.mceToolbar')
......
...@@ -35,6 +35,8 @@ function (HTML5Video, Resizer) { ...@@ -35,6 +35,8 @@ function (HTML5Video, Resizer) {
play: play, play: play,
setPlaybackRate: setPlaybackRate, setPlaybackRate: setPlaybackRate,
update: update, update: update,
figureOutStartEndTime: figureOutStartEndTime,
figureOutStartingTime: figureOutStartingTime,
updatePlayTime: updatePlayTime updatePlayTime: updatePlayTime
}; };
...@@ -62,7 +64,7 @@ function (HTML5Video, Resizer) { ...@@ -62,7 +64,7 @@ function (HTML5Video, Resizer) {
// via the 'state' object. Much easier to work this way - you don't // via the 'state' object. Much easier to work this way - you don't
// have to do repeated jQuery element selects. // have to do repeated jQuery element selects.
function _initialize(state) { function _initialize(state) {
var youTubeId, player, duration; var youTubeId, player;
// The function is called just once to apply pre-defined configurations // The function is called just once to apply pre-defined configurations
// by student before video starts playing. Waits until the video's // by student before video starts playing. Waits until the video's
...@@ -134,22 +136,7 @@ function (HTML5Video, Resizer) { ...@@ -134,22 +136,7 @@ function (HTML5Video, Resizer) {
_resize(state, videoWidth, videoHeight); _resize(state, videoWidth, videoHeight);
duration = state.videoPlayer.duration(); _updateVcrAndRegion(state);
state.trigger(
'videoControl.updateVcrVidTime',
{
time: 0,
duration: duration
}
);
state.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: duration
}
);
}, false); }, false);
} else { // if (state.videoType === 'youtube') { } else { // if (state.videoType === 'youtube') {
...@@ -200,22 +187,34 @@ function (HTML5Video, Resizer) { ...@@ -200,22 +187,34 @@ function (HTML5Video, Resizer) {
} }
function _updateVcrAndRegion(state) { function _updateVcrAndRegion(state) {
var duration = state.videoPlayer.duration(); var duration = state.videoPlayer.duration(),
time;
time = state.videoPlayer.figureOutStartingTime(duration);
// Update the VCR.
state.trigger( state.trigger(
'videoControl.updateVcrVidTime', 'videoControl.updateVcrVidTime',
{ {
time: 0, time: time,
duration: duration duration: duration
} }
); );
// Update the time slider.
state.trigger( state.trigger(
'videoProgressSlider.updateStartEndTimeRegion', 'videoProgressSlider.updateStartEndTimeRegion',
{ {
duration: duration duration: duration
} }
); );
state.trigger(
'videoProgressSlider.updatePlayTime',
{
time: time,
duration: duration
}
);
} }
function _resize(state, videoWidth, videoHeight) { function _resize(state, videoWidth, videoHeight) {
...@@ -642,62 +641,46 @@ function (HTML5Video, Resizer) { ...@@ -642,62 +641,46 @@ function (HTML5Video, Resizer) {
} }
} }
function updatePlayTime(time) { function figureOutStartEndTime(duration) {
var videoPlayer = this.videoPlayer, var videoPlayer = this.videoPlayer;
duration = this.videoPlayer.duration(),
savedVideoPosition = this.config.savedVideoPosition,
youTubeId, startTime, endTime;
if (duration > 0 && videoPlayer.goToStartTime) { videoPlayer.startTime = this.config.startTime;
videoPlayer.goToStartTime = false; if (videoPlayer.startTime >= duration) {
videoPlayer.startTime = 0;
} else if (this.currentPlayerMode === 'flash') {
videoPlayer.startTime /= Number(this.speed);
}
videoPlayer.startTime = this.config.startTime; videoPlayer.endTime = this.config.endTime;
if (videoPlayer.startTime >= duration) { if (
videoPlayer.startTime = 0; videoPlayer.endTime <= videoPlayer.startTime ||
} else if (this.currentPlayerMode === 'flash') { videoPlayer.endTime >= duration
videoPlayer.startTime /= Number(this.speed); ) {
} videoPlayer.stopAtEndTime = false;
videoPlayer.endTime = null;
} else if (this.currentPlayerMode === 'flash') {
videoPlayer.endTime /= Number(this.speed);
}
}
videoPlayer.endTime = this.config.endTime; function figureOutStartingTime(duration) {
if ( var savedVideoPosition = this.config.savedVideoPosition,
videoPlayer.endTime <= videoPlayer.startTime ||
videoPlayer.endTime >= duration
) {
videoPlayer.stopAtEndTime = false;
videoPlayer.endTime = null;
} else if (this.currentPlayerMode === 'flash') {
videoPlayer.endTime /= Number(this.speed);
}
this.trigger( // Default starting time is 0. This is the case when
'videoProgressSlider.updateStartEndTimeRegion', // there is not start-time, no previously saved position,
{ // or one (or both) of those values is incorrect.
duration: duration time = 0,
}
);
startTime = videoPlayer.startTime; startTime, endTime;
endTime = videoPlayer.endTime;
if (startTime) { this.videoPlayer.figureOutStartEndTime(duration);
if (
startTime < savedVideoPosition &&
(endTime > savedVideoPosition || endTime === null) &&
// We do not want to jump to the end of the video. startTime = this.videoPlayer.startTime;
// We subtract 1 from the duration for a 1 second endTime = this.videoPlayer.endTime;
// safety net.
savedVideoPosition < duration - 1
) {
time = savedVideoPosition;
// When the video finishes playing, we will start from the if (startTime > 0) {
// start-time, rather than from the remembered position if (
this.config.savedVideoPosition = 0; startTime < savedVideoPosition &&
} else {
time = startTime;
}
} else if (
(endTime > savedVideoPosition || endTime === null) && (endTime > savedVideoPosition || endTime === null) &&
// We do not want to jump to the end of the video. // We do not want to jump to the end of the video.
...@@ -706,13 +689,47 @@ function (HTML5Video, Resizer) { ...@@ -706,13 +689,47 @@ function (HTML5Video, Resizer) {
savedVideoPosition < duration - 1 savedVideoPosition < duration - 1
) { ) {
time = savedVideoPosition; time = savedVideoPosition;
// When the video finishes playing, we will start from the
// start-time, rather than from the remembered position
this.config.savedVideoPosition = 0;
} else { } else {
time = 0; time = startTime;
} }
} else if (
savedVideoPosition > 0 &&
(endTime > savedVideoPosition || endTime === null) &&
// We do not want to jump to the end of the video.
// We subtract 1 from the duration for a 1 second
// safety net.
savedVideoPosition < duration - 1
) {
time = savedVideoPosition;
}
return time;
}
function updatePlayTime(time) {
var videoPlayer = this.videoPlayer,
duration = this.videoPlayer.duration(),
youTubeId;
if (duration > 0 && videoPlayer.goToStartTime) {
videoPlayer.goToStartTime = false;
// The duration might have changed. Update the start-end time region to
// reflect this fact.
this.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
{
duration: duration
}
);
time = videoPlayer.figureOutStartingTime(duration);
// When the video finishes playing, we will start from the
// start-time, or from the beginning (rather than from the remembered
// position).
this.config.savedVideoPosition = 0;
if (time > 0) { if (time > 0) {
// After a bug came up (BLD-708: "In Firefox YouTube video with // After a bug came up (BLD-708: "In Firefox YouTube video with
......
...@@ -7,11 +7,15 @@ import logging ...@@ -7,11 +7,15 @@ import logging
import re import re
from collections import namedtuple from collections import namedtuple
import collections
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from xblock.plugin import default_select
from .exceptions import InvalidLocationError, InsufficientSpecificationError from .exceptions import InvalidLocationError, InsufficientSpecificationError
from xmodule.errortracker import make_error_tracker from xmodule.errortracker import make_error_tracker
from xblock.runtime import Mixologist
from xblock.core import XBlock
log = logging.getLogger('edx.modulestore') log = logging.getLogger('edx.modulestore')
...@@ -447,7 +451,7 @@ class ModuleStoreWrite(ModuleStoreRead): ...@@ -447,7 +451,7 @@ class ModuleStoreWrite(ModuleStoreRead):
pass pass
@abstractmethod @abstractmethod
def delete_item(self, location, user_id=None, delete_all_versions=False, delete_children=False, force=False): def delete_item(self, location, user_id=None, **kwargs):
""" """
Delete an item from persistence. Pass the user's unique id which the persistent store Delete an item from persistence. Pass the user's unique id which the persistent store
should save with the update if it has that ability. should save with the update if it has that ability.
...@@ -475,7 +479,9 @@ class ModuleStoreReadBase(ModuleStoreRead): ...@@ -475,7 +479,9 @@ class ModuleStoreReadBase(ModuleStoreRead):
metadata_inheritance_cache_subsystem=None, request_cache=None, metadata_inheritance_cache_subsystem=None, request_cache=None,
modulestore_update_signal=None, xblock_mixins=(), xblock_select=None, modulestore_update_signal=None, xblock_mixins=(), xblock_select=None,
# temporary parms to enable backward compatibility. remove once all envs migrated # temporary parms to enable backward compatibility. remove once all envs migrated
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
# allow lower level init args to pass harmlessly
** kwargs
): ):
''' '''
Set up the error-tracking logic. Set up the error-tracking logic.
...@@ -529,9 +535,75 @@ class ModuleStoreReadBase(ModuleStoreRead): ...@@ -529,9 +535,75 @@ class ModuleStoreReadBase(ModuleStoreRead):
return c return c
return None return None
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
"""
Update the given xblock's persisted repr. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param allow_not_found: whether this method should raise an exception if the given xblock
has not been persisted before.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
raise NotImplementedError
def delete_item(self, location, user_id=None, delete_all_versions=False, delete_children=False, force=False):
"""
Delete an item from persistence. Pass the user's unique id which the persistent store
should save with the update if it has that ability.
:param delete_all_versions: removes both the draft and published version of this item from
the course if using draft and old mongo. Split may or may not implement this.
:param force: fork the structure and don't update the course draftVersion if there's a version
conflict (only applicable to version tracking and conflict detecting persistence stores)
:raises VersionConflictError: if package_id and version_guid given and the current
version head != version_guid and force is not True. (only applicable to version tracking stores)
"""
raise NotImplementedError
class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
''' '''
Implement interface functionality that can be shared. Implement interface functionality that can be shared.
''' '''
pass def __init__(self, **kwargs):
super(ModuleStoreWriteBase, self).__init__(**kwargs)
# TODO: Don't have a runtime just to generate the appropriate mixin classes (cpennington)
# This is only used by partition_fields_by_scope, which is only needed because
# the split mongo store is used for item creation as well as item persistence
self.mixologist = Mixologist(self.xblock_mixins)
def partition_fields_by_scope(self, category, fields):
"""
Return dictionary of {scope: {field1: val, ..}..} for the fields of this potential xblock
:param category: the xblock category
:param fields: the dictionary of {fieldname: value}
"""
if fields is None:
return {}
cls = self.mixologist.mix(XBlock.load_class(category, select=prefer_xmodules))
result = collections.defaultdict(dict)
for field_name, value in fields.iteritems():
field = getattr(cls, field_name)
result[field.scope][field_name] = value
return result
def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package"""
from_xmodule = [entry_point for entry_point in entry_points if entry_point.dist.key == 'xmodule']
return default_select(identifier, from_xmodule)
def prefer_xmodules(identifier, entry_points):
"""Prefer entry_points from the xmodule package"""
from_xmodule = [entry_point for entry_point in entry_points if entry_point.dist.key == 'xmodule']
if from_xmodule:
return default_select(identifier, from_xmodule)
else:
return default_select(identifier, entry_points)
...@@ -119,7 +119,8 @@ class LocMapperStore(object): ...@@ -119,7 +119,8 @@ class LocMapperStore(object):
return package_id return package_id
def translate_location(self, old_style_course_id, location, published=True, add_entry_if_missing=True): def translate_location(self, old_style_course_id, location, published=True,
add_entry_if_missing=True, passed_block_id=None):
""" """
Translate the given module location to a Locator. If the mapping has the run id in it, then you Translate the given module location to a Locator. If the mapping has the run id in it, then you
should provide old_style_course_id with that run id in it to disambiguate the mapping if there exists more should provide old_style_course_id with that run id in it to disambiguate the mapping if there exists more
...@@ -137,6 +138,8 @@ class LocMapperStore(object): ...@@ -137,6 +138,8 @@ class LocMapperStore(object):
:param add_entry_if_missing: a boolean as to whether to raise ItemNotFoundError or to create an entry if :param add_entry_if_missing: a boolean as to whether to raise ItemNotFoundError or to create an entry if
the course the course
or block is not found in the map. or block is not found in the map.
:param passed_block_id: what block_id to assign and save if none is found
(only if add_entry_if_missing)
NOTE: unlike old mongo, draft branches contain the whole course; so, it applies to all category NOTE: unlike old mongo, draft branches contain the whole course; so, it applies to all category
of locations including course. of locations including course.
...@@ -158,7 +161,7 @@ class LocMapperStore(object): ...@@ -158,7 +161,7 @@ class LocMapperStore(object):
self.create_map_entry(course_location) self.create_map_entry(course_location)
entry = self.location_map.find_one(location_id) entry = self.location_map.find_one(location_id)
else: else:
raise ItemNotFoundError() raise ItemNotFoundError(location)
elif len(maps) == 1: elif len(maps) == 1:
entry = maps[0] entry = maps[0]
else: else:
...@@ -172,7 +175,9 @@ class LocMapperStore(object): ...@@ -172,7 +175,9 @@ class LocMapperStore(object):
block_id = entry['block_map'].get(self.encode_key_for_mongo(location.name)) block_id = entry['block_map'].get(self.encode_key_for_mongo(location.name))
if block_id is None: if block_id is None:
if add_entry_if_missing: if add_entry_if_missing:
block_id = self._add_to_block_map(location, location_id, entry['block_map']) block_id = self._add_to_block_map(
location, location_id, entry['block_map'], passed_block_id
)
else: else:
raise ItemNotFoundError(location) raise ItemNotFoundError(location)
elif isinstance(block_id, dict): elif isinstance(block_id, dict):
...@@ -188,7 +193,7 @@ class LocMapperStore(object): ...@@ -188,7 +193,7 @@ class LocMapperStore(object):
elif add_entry_if_missing: elif add_entry_if_missing:
block_id = self._add_to_block_map(location, location_id, entry['block_map']) block_id = self._add_to_block_map(location, location_id, entry['block_map'])
else: else:
raise ItemNotFoundError() raise ItemNotFoundError(location)
else: else:
raise InvalidLocationError() raise InvalidLocationError()
...@@ -297,7 +302,7 @@ class LocMapperStore(object): ...@@ -297,7 +302,7 @@ class LocMapperStore(object):
maps = self.location_map.find(location_id) maps = self.location_map.find(location_id)
maps = list(maps) maps = list(maps)
if len(maps) == 0: if len(maps) == 0:
raise ItemNotFoundError() raise ItemNotFoundError(location)
elif len(maps) == 1: elif len(maps) == 1:
entry = maps[0] entry = maps[0]
else: else:
...@@ -315,18 +320,19 @@ class LocMapperStore(object): ...@@ -315,18 +320,19 @@ class LocMapperStore(object):
else: else:
return draft_course_locator return draft_course_locator
def _add_to_block_map(self, location, location_id, block_map): def _add_to_block_map(self, location, location_id, block_map, block_id=None):
'''add the given location to the block_map and persist it''' '''add the given location to the block_map and persist it'''
if self._block_id_is_guid(location.name): if block_id is None:
# This makes the ids more meaningful with a small probability of name collision. if self._block_id_is_guid(location.name):
# The downside is that if there's more than one course mapped to from the same org/course root # This makes the ids more meaningful with a small probability of name collision.
# the block ids will likely be out of sync and collide from an id perspective. HOWEVER, # The downside is that if there's more than one course mapped to from the same org/course root
# if there are few == org/course roots or their content is unrelated, this will work well. # the block ids will likely be out of sync and collide from an id perspective. HOWEVER,
block_id = self._verify_uniqueness(location.category + location.name[:3], block_map) # if there are few == org/course roots or their content is unrelated, this will work well.
else: block_id = self._verify_uniqueness(location.category + location.name[:3], block_map)
# if 2 different category locations had same name, then they'll collide. Make the later else:
# mapped ones unique # if 2 different category locations had same name, then they'll collide. Make the later
block_id = self._verify_uniqueness(location.name, block_map) # mapped ones unique
block_id = self._verify_uniqueness(location.name, block_map)
encoded_location_name = self.encode_key_for_mongo(location.name) encoded_location_name = self.encode_key_for_mongo(location.name)
block_map.setdefault(encoded_location_name, {})[location.category] = block_id block_map.setdefault(encoded_location_name, {})[location.category] = block_id
self.location_map.update(location_id, {'$set': {'block_map': block_map}}) self.location_map.update(location_id, {'$set': {'block_map': block_map}})
......
...@@ -51,6 +51,12 @@ class Locator(object): ...@@ -51,6 +51,12 @@ class Locator(object):
def __eq__(self, other): def __eq__(self, other):
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
def __hash__(self):
"""
Hash on contents.
"""
return hash(unicode(self))
def __repr__(self): def __repr__(self):
''' '''
repr(self) returns something like this: CourseLocator("mit.eecs.6002x") repr(self) returns something like this: CourseLocator("mit.eecs.6002x")
...@@ -198,16 +204,14 @@ class CourseLocator(Locator): ...@@ -198,16 +204,14 @@ class CourseLocator(Locator):
""" """
Return a string representing this location. Return a string representing this location.
""" """
parts = []
if self.package_id: if self.package_id:
result = unicode(self.package_id) parts.append(unicode(self.package_id))
if self.branch: if self.branch:
result += '/' + BRANCH_PREFIX + self.branch parts.append(u"{prefix}{branch}".format(prefix=BRANCH_PREFIX, branch=self.branch))
return result if self.version_guid:
elif self.version_guid: parts.append(u"{prefix}{guid}".format(prefix=VERSION_PREFIX, guid=self.version_guid))
return u"{prefix}{guid}".format(prefix=VERSION_PREFIX, guid=self.version_guid) return u"/".join(parts)
else:
# raise InsufficientSpecificationError("missing package_id or version_guid")
return '<InsufficientSpecificationError: missing package_id or version_guid>'
def url(self): def url(self):
""" """
...@@ -432,22 +436,25 @@ class BlockUsageLocator(CourseLocator): ...@@ -432,22 +436,25 @@ class BlockUsageLocator(CourseLocator):
def version_agnostic(self): def version_agnostic(self):
""" """
Returns a copy of itself.
If both version_guid and package_id are known, use a blank package_id in the copy.
We don't care if the locator's version is not the current head; so, avoid version conflict We don't care if the locator's version is not the current head; so, avoid version conflict
by reducing info. by reducing info.
Returns a copy of itself without any version info.
:param block_locator: :raises: ValueError if the block locator has no package_id
""" """
if self.version_guid: return BlockUsageLocator(package_id=self.package_id,
return BlockUsageLocator(version_guid=self.version_guid, branch=self.branch,
branch=self.branch, block_id=self.block_id)
block_id=self.block_id)
else: def course_agnostic(self):
return BlockUsageLocator(package_id=self.package_id, """
branch=self.branch, We only care about the locator's version not its course.
block_id=self.block_id) Returns a copy of itself without any course info.
:raises: ValueError if the block locator has no version_guid
"""
return BlockUsageLocator(version_guid=self.version_guid,
block_id=self.block_id)
def set_block_id(self, new): def set_block_id(self, new):
""" """
......
...@@ -625,7 +625,22 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -625,7 +625,22 @@ class MongoModuleStore(ModuleStoreWriteBase):
modules = self._load_items(list(items), depth) modules = self._load_items(list(items), depth)
return modules return modules
def create_xmodule(self, location, definition_data=None, metadata=None, system=None): def create_course(self, course_id, definition_data=None, metadata=None, runtime=None):
"""
Create a course with the given course_id.
"""
if isinstance(course_id, Location):
location = course_id
if location.category != 'course':
raise ValueError(u"Course roots must be of category 'course': {}".format(unicode(location)))
else:
course_dict = Location.parse_course_id(course_id)
course_dict['category'] = 'course'
course_dict['tag'] = 'i4x'
location = Location(course_dict)
return self.create_and_save_xmodule(location, definition_data, metadata, runtime)
def create_xmodule(self, location, definition_data=None, metadata=None, system=None, fields={}):
""" """
Create the new xmodule but don't save it. Returns the new module. Create the new xmodule but don't save it. Returns the new module.
...@@ -672,36 +687,18 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -672,36 +687,18 @@ class MongoModuleStore(ModuleStoreWriteBase):
ScopeIds(None, location.category, location, location), ScopeIds(None, location.category, location, location),
dbmodel, dbmodel,
) )
for key, value in fields.iteritems():
setattr(xmodule, key, value)
# decache any pending field settings from init # decache any pending field settings from init
xmodule.save() xmodule.save()
return xmodule return xmodule
def save_xmodule(self, xmodule): def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None,
""" fields={}):
Save the given xmodule (will either create or update based on whether id already exists).
Pulls out the data definition v metadata v children locally but saves it all.
:param xmodule:
"""
# Save any changes to the xmodule to the MongoKeyValueStore
xmodule.save()
self.collection.save({
'_id': namedtuple_to_son(xmodule.location),
'metadata': own_metadata(xmodule),
'definition': {
'data': xmodule.get_explicitly_set_fields_by_scope(Scope.content),
'children': xmodule.children if xmodule.has_children else []
}
})
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(xmodule.location)
self.fire_updated_modulestore_signal(get_course_id_no_run(xmodule.location), xmodule.location)
def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None):
""" """
Create the new xmodule and save it. Does not return the new module because if the caller Create the new xmodule and save it. Does not return the new module because if the caller
will insert it as a child, it's inherited metadata will completely change. The difference will insert it as a child, it's inherited metadata will completely change. The difference
between this and just doing create_xmodule and save_xmodule is this ensures static_tabs get between this and just doing create_xmodule and update_item is this ensures static_tabs get
pointed to by the course. pointed to by the course.
:param location: a Location--must have a category :param location: a Location--must have a category
...@@ -711,9 +708,9 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -711,9 +708,9 @@ class MongoModuleStore(ModuleStoreWriteBase):
""" """
# differs from split mongo in that I believe most of this logic should be above the persistence # differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these. # layer but added it here to enable quick conversion. I'll need to reconcile these.
new_object = self.create_xmodule(location, definition_data, metadata, system) new_object = self.create_xmodule(location, definition_data, metadata, system, fields)
location = new_object.location location = new_object.location
self.save_xmodule(new_object) self.update_item(new_object, allow_not_found=True)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata) # if we add one then we need to also add it to the policy information (i.e. metadata)
...@@ -728,9 +725,9 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -728,9 +725,9 @@ class MongoModuleStore(ModuleStoreWriteBase):
'url_slug': new_object.location.name 'url_slug': new_object.location.name
}) })
course.tabs = existing_tabs course.tabs = existing_tabs
# Save any changes to the course to the MongoKeyValueStore self.update_item(course)
course.save()
self.update_item(course, '**replace_user**') return new_object
def fire_updated_modulestore_signal(self, course_id, location): def fire_updated_modulestore_signal(self, course_id, location):
""" """
...@@ -787,7 +784,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -787,7 +784,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
if result['n'] == 0: if result['n'] == 0:
raise ItemNotFoundError(location) raise ItemNotFoundError(location)
def update_item(self, xblock, user, allow_not_found=False): def update_item(self, xblock, user=None, allow_not_found=False):
""" """
Update the persisted version of xblock to reflect its current values. Update the persisted version of xblock to reflect its current values.
...@@ -861,7 +858,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -861,7 +858,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
location = Location.ensure_fully_specified(location) location = Location.ensure_fully_specified(location)
items = self.collection.find({'definition.children': location.url()}, items = self.collection.find({'definition.children': location.url()},
{'_id': True}) {'_id': True})
return [i['_id'] for i in items] return [Location(i['_id']) for i in items]
def get_modulestore_type(self, course_id): def get_modulestore_type(self, course_id):
""" """
......
...@@ -92,7 +92,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -92,7 +92,7 @@ class DraftModuleStore(MongoModuleStore):
except ItemNotFoundError: except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth)) return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth))
def create_xmodule(self, location, definition_data=None, metadata=None, system=None): def create_xmodule(self, location, definition_data=None, metadata=None, system=None, fields={}):
""" """
Create the new xmodule but don't save it. Returns the new module with a draft locator Create the new xmodule but don't save it. Returns the new module with a draft locator
...@@ -104,22 +104,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -104,22 +104,7 @@ class DraftModuleStore(MongoModuleStore):
draft_loc = as_draft(location) draft_loc = as_draft(location)
if draft_loc.category in DIRECT_ONLY_CATEGORIES: if draft_loc.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(location) raise InvalidVersionError(location)
return super(DraftModuleStore, self).create_xmodule(draft_loc, definition_data, metadata, system) return super(DraftModuleStore, self).create_xmodule(draft_loc, definition_data, metadata, system, fields)
def save_xmodule(self, xmodule):
"""
Save the given xmodule (will either create or update based on whether id already exists).
Pulls out the data definition v metadata v children locally but saves it all.
:param xmodule:
"""
orig_location = xmodule.location
xmodule.location = as_draft(orig_location)
try:
super(DraftModuleStore, self).save_xmodule(xmodule)
finally:
xmodule.location = orig_location
def get_items(self, location, course_id=None, depth=0, qualifiers=None): def get_items(self, location, course_id=None, depth=0, qualifiers=None):
""" """
...@@ -159,7 +144,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -159,7 +144,7 @@ class DraftModuleStore(MongoModuleStore):
if draft_location.category in DIRECT_ONLY_CATEGORIES: if draft_location.category in DIRECT_ONLY_CATEGORIES:
raise InvalidVersionError(source_location) raise InvalidVersionError(source_location)
if not original: if not original:
raise ItemNotFoundError raise ItemNotFoundError(source_location)
original['_id'] = namedtuple_to_son(draft_location) original['_id'] = namedtuple_to_son(draft_location)
try: try:
self.collection.insert(original) self.collection.insert(original)
...@@ -171,7 +156,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -171,7 +156,7 @@ class DraftModuleStore(MongoModuleStore):
return self._load_items([original])[0] return self._load_items([original])[0]
def update_item(self, xblock, user, allow_not_found=False): def update_item(self, xblock, user=None, allow_not_found=False):
""" """
Save the current values to persisted version of the xblock Save the current values to persisted version of the xblock
...@@ -187,7 +172,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -187,7 +172,7 @@ class DraftModuleStore(MongoModuleStore):
raise raise
xblock.location = draft_loc xblock.location = draft_loc
super(DraftModuleStore, self).update_item(xblock, user) super(DraftModuleStore, self).update_item(xblock, user, allow_not_found)
# don't allow locations to truly represent themselves as draft outside of this file # don't allow locations to truly represent themselves as draft outside of this file
xblock.location = as_published(xblock.location) xblock.location = as_published(xblock.location)
......
...@@ -51,14 +51,13 @@ import logging ...@@ -51,14 +51,13 @@ import logging
import re import re
from importlib import import_module from importlib import import_module
from path import path from path import path
import collections
import copy import copy
from pytz import UTC from pytz import UTC
from xmodule.errortracker import null_error_tracker from xmodule.errortracker import null_error_tracker
from xmodule.x_module import prefer_xmodules
from xmodule.modulestore.locator import ( from xmodule.modulestore.locator import (
BlockUsageLocator, DefinitionLocator, CourseLocator, VersionTree, LocalId, Locator BlockUsageLocator, DefinitionLocator, CourseLocator, VersionTree,
LocalId, Locator
) )
from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError, DuplicateItemError from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError, DuplicateItemError
from xmodule.modulestore import inheritance, ModuleStoreWriteBase, Location, SPLIT_MONGO_MODULESTORE_TYPE from xmodule.modulestore import inheritance, ModuleStoreWriteBase, Location, SPLIT_MONGO_MODULESTORE_TYPE
...@@ -67,7 +66,6 @@ from ..exceptions import ItemNotFoundError ...@@ -67,7 +66,6 @@ from ..exceptions import ItemNotFoundError
from .definition_lazy_loader import DefinitionLazyLoader from .definition_lazy_loader import DefinitionLazyLoader
from .caching_descriptor_system import CachingDescriptorSystem from .caching_descriptor_system import CachingDescriptorSystem
from xblock.fields import Scope from xblock.fields import Scope
from xblock.runtime import Mixologist
from bson.objectid import ObjectId from bson.objectid import ObjectId
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
from xblock.core import XBlock from xblock.core import XBlock
...@@ -132,11 +130,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -132,11 +130,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
self.render_template = render_template self.render_template = render_template
self.i18n_service = i18n_service self.i18n_service = i18n_service
# TODO: Don't have a runtime just to generate the appropriate mixin classes (cpennington)
# This is only used by _partition_fields_by_scope, which is only needed because
# the split mongo store is used for item creation as well as item persistence
self.mixologist = Mixologist(self.xblock_mixins)
def cache_items(self, system, base_block_ids, depth=0, lazy=True): def cache_items(self, system, base_block_ids, depth=0, lazy=True):
''' '''
Handles caching of items once inheritance and any other one time Handles caching of items once inheritance and any other one time
...@@ -281,7 +274,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -281,7 +274,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
} }
return envelope return envelope
def get_courses(self, branch='published', qualifiers=None): def get_courses(self, branch='draft', qualifiers=None):
''' '''
Returns a list of course descriptors matching any given qualifiers. Returns a list of course descriptors matching any given qualifiers.
...@@ -291,7 +284,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -291,7 +284,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
Note, this is to find the current head of the named branch type Note, this is to find the current head of the named branch type
(e.g., 'draft'). To get specific versions via guid use get_course. (e.g., 'draft'). To get specific versions via guid use get_course.
:param branch: the branch for which to return courses. Default value is 'published'. :param branch: the branch for which to return courses. Default value is 'draft'.
:param qualifiers: a optional dict restricting which elements should match :param qualifiers: a optional dict restricting which elements should match
''' '''
if qualifiers is None: if qualifiers is None:
...@@ -563,8 +556,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -563,8 +556,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
The block's history tracks its explicit changes but not the changes in its children. The block's history tracks its explicit changes but not the changes in its children.
''' '''
# version_agnostic means we don't care if the head and version don't align, trust the version # course_agnostic means we don't care if the head and version don't align, trust the version
course_struct = self._lookup_course(block_locator.version_agnostic())['structure'] course_struct = self._lookup_course(block_locator.course_agnostic())['structure']
block_id = block_locator.block_id block_id = block_locator.block_id
update_version_field = 'blocks.{}.edit_info.update_version'.format(block_id) update_version_field = 'blocks.{}.edit_info.update_version'.format(block_id)
all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'], all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'],
...@@ -759,7 +752,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -759,7 +752,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
index_entry = self._get_index_if_valid(course_or_parent_locator, force, continue_version) index_entry = self._get_index_if_valid(course_or_parent_locator, force, continue_version)
structure = self._lookup_course(course_or_parent_locator)['structure'] structure = self._lookup_course(course_or_parent_locator)['structure']
partitioned_fields = self._partition_fields_by_scope(category, fields) partitioned_fields = self.partition_fields_by_scope(category, fields)
new_def_data = partitioned_fields.get(Scope.content, {}) new_def_data = partitioned_fields.get(Scope.content, {})
# persist the definition if persisted != passed # persist the definition if persisted != passed
if (definition_locator is None or isinstance(definition_locator.definition_id, LocalId)): if (definition_locator is None or isinstance(definition_locator.definition_id, LocalId)):
...@@ -822,14 +815,19 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -822,14 +815,19 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if index_entry is not None: if index_entry is not None:
if not continue_version: if not continue_version:
self._update_head(index_entry, course_or_parent_locator.branch, new_id) self._update_head(index_entry, course_or_parent_locator.branch, new_id)
course_parent = course_or_parent_locator.as_course_locator() item_loc = BlockUsageLocator(
package_id=course_or_parent_locator.package_id,
branch=course_or_parent_locator.branch,
block_id=new_block_id,
)
else: else:
course_parent = None item_loc = BlockUsageLocator(
block_id=new_block_id,
version_guid=new_id,
)
# reconstruct the new_item from the cache # reconstruct the new_item from the cache
return self.get_item(BlockUsageLocator(package_id=course_parent, return self.get_item(item_loc)
block_id=new_block_id,
version_guid=new_id))
def create_course( def create_course(
self, org, prettyid, user_id, id_root=None, fields=None, self, org, prettyid, user_id, id_root=None, fields=None,
...@@ -867,7 +865,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -867,7 +865,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
provide any fields overrides, see above). if not provided, will create a mostly empty course provide any fields overrides, see above). if not provided, will create a mostly empty course
structure with just a category course root xblock. structure with just a category course root xblock.
""" """
partitioned_fields = self._partition_fields_by_scope(root_category, fields) partitioned_fields = self.partition_fields_by_scope(root_category, fields)
block_fields = partitioned_fields.setdefault(Scope.settings, {}) block_fields = partitioned_fields.setdefault(Scope.settings, {})
if Scope.children in partitioned_fields: if Scope.children in partitioned_fields:
block_fields.update(partitioned_fields[Scope.children]) block_fields.update(partitioned_fields[Scope.children])
...@@ -1287,7 +1285,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1287,7 +1285,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if index is None: if index is None:
raise ItemNotFoundError(package_id) raise ItemNotFoundError(package_id)
# this is the only real delete in the system. should it do something else? # this is the only real delete in the system. should it do something else?
log.info("deleting course from split-mongo: %s", package_id) log.info(u"deleting course from split-mongo: %s", package_id)
self.db_connection.delete_course_index(index['_id']) self.db_connection.delete_course_index(index['_id'])
def get_errored_courses(self): def get_errored_courses(self):
...@@ -1494,22 +1492,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -1494,22 +1492,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
index_entry['versions'][branch] = new_id index_entry['versions'][branch] = new_id
self.db_connection.update_course_index(index_entry) self.db_connection.update_course_index(index_entry)
def _partition_fields_by_scope(self, category, fields):
"""
Return dictionary of {scope: {field1: val, ..}..} for the fields of this potential xblock
:param category: the xblock category
:param fields: the dictionary of {fieldname: value}
"""
if fields is None:
return {}
cls = self.mixologist.mix(XBlock.load_class(category, select=self.xblock_select))
result = collections.defaultdict(dict)
for field_name, value in fields.iteritems():
field = getattr(cls, field_name)
result[field.scope][field_name] = value
return result
def _filter_special_fields(self, fields): def _filter_special_fields(self, fields):
""" """
Remove any fields which split or its kvs computes or adds but does not want persisted. Remove any fields which split or its kvs computes or adds but does not want persisted.
......
...@@ -33,7 +33,6 @@ def mixed_store_config(data_dir, mappings): ...@@ -33,7 +33,6 @@ def mixed_store_config(data_dir, mappings):
'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore', 'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore',
'OPTIONS': { 'OPTIONS': {
'mappings': mappings, 'mappings': mappings,
'reference_type': 'Location',
'stores': { 'stores': {
'default': mongo_config['default'], 'default': mongo_config['default'],
'xml': xml_config['default'] 'xml': xml_config['default']
...@@ -219,13 +218,21 @@ class ModuleStoreTestCase(TestCase): ...@@ -219,13 +218,21 @@ class ModuleStoreTestCase(TestCase):
# even if we're using a mixed modulestore # even if we're using a mixed modulestore
store = editable_modulestore() store = editable_modulestore()
if hasattr(store, 'collection'): if hasattr(store, 'collection'):
connection = store.collection.database.connection
store.collection.drop() store.collection.drop()
connection.close()
elif hasattr(store, 'close_all_connections'):
store.close_all_connections()
if contentstore().fs_files: if contentstore().fs_files:
db = contentstore().fs_files.database db = contentstore().fs_files.database
db.connection.drop_database(db) db.connection.drop_database(db)
db.connection.close()
location_mapper = loc_mapper() location_mapper = loc_mapper()
if location_mapper.db: if location_mapper.db:
location_mapper.location_map.drop() location_mapper.location_map.drop()
location_mapper.db.connection.close()
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
......
...@@ -2,8 +2,7 @@ from factory import Factory, lazy_attribute_sequence, lazy_attribute ...@@ -2,8 +2,7 @@ from factory import Factory, lazy_attribute_sequence, lazy_attribute
from factory.containers import CyclicDefinitionError from factory.containers import CyclicDefinitionError
from uuid import uuid4 from uuid import uuid4
from xmodule.modulestore import Location from xmodule.modulestore import Location, prefer_xmodules
from xmodule.x_module import prefer_xmodules
from xblock.core import XBlock from xblock.core import XBlock
...@@ -58,7 +57,7 @@ class CourseFactory(XModuleFactory): ...@@ -58,7 +57,7 @@ class CourseFactory(XModuleFactory):
setattr(new_course, k, v) setattr(new_course, k, v)
# Update the data in the mongo datastore # Update the data in the mongo datastore
store.save_xmodule(new_course) store.update_item(new_course)
return new_course return new_course
...@@ -159,7 +158,7 @@ class ItemFactory(XModuleFactory): ...@@ -159,7 +158,7 @@ class ItemFactory(XModuleFactory):
setattr(module, attr, val) setattr(module, attr, val)
module.save() module.save()
store.save_xmodule(module) store.update_item(module)
if 'detached' not in module._class_tags: if 'detached' not in module._class_tags:
parent.children.append(location.url()) parent.children.append(location.url())
......
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
import factory import factory
from factory.helpers import lazy_attribute
# [dhm] I'm not sure why we're using factory_boy if we're not following its pattern. If anyone class SplitFactory(factory.Factory):
# assumes they can call build, it will completely fail, for example. """
# pylint: disable=W0232 Abstracted superclass which defines modulestore so that there's no dependency on django
class PersistentCourseFactory(factory.Factory): if the caller passes modulestore in kwargs
"""
@lazy_attribute
def modulestore(self):
# Delayed import so that we only depend on django if the caller
# hasn't provided their own modulestore
from xmodule.modulestore.django import modulestore
return modulestore('split')
class PersistentCourseFactory(SplitFactory):
""" """
Create a new course (not a new version of a course, but a whole new index entry). Create a new course (not a new version of a course, but a whole new index entry).
...@@ -23,12 +33,15 @@ class PersistentCourseFactory(factory.Factory): ...@@ -23,12 +33,15 @@ class PersistentCourseFactory(factory.Factory):
# pylint: disable=W0613 # pylint: disable=W0613
@classmethod @classmethod
def _create(cls, target_class, org='testX', prettyid='999', user_id='test_user', master_branch='draft', **kwargs): def _create(cls, target_class, org='testX', prettyid='999', user_id='test_user',
master_branch='draft', id_root=None, **kwargs):
modulestore = kwargs.pop('modulestore')
root_block_id = kwargs.pop('root_block_id', 'course')
# Write the data to the mongo datastore # Write the data to the mongo datastore
new_course = modulestore('split').create_course( new_course = modulestore.create_course(
org, prettyid, user_id, fields=kwargs, id_root=prettyid, org, prettyid, user_id, fields=kwargs, id_root=id_root or prettyid,
master_branch=master_branch) master_branch=master_branch, root_block_id=root_block_id)
return new_course return new_course
...@@ -37,7 +50,7 @@ class PersistentCourseFactory(factory.Factory): ...@@ -37,7 +50,7 @@ class PersistentCourseFactory(factory.Factory):
raise NotImplementedError() raise NotImplementedError()
class ItemFactory(factory.Factory): class ItemFactory(SplitFactory):
FACTORY_FOR = XModuleDescriptor FACTORY_FOR = XModuleDescriptor
display_name = factory.LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n)) display_name = factory.LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n))
...@@ -45,7 +58,8 @@ class ItemFactory(factory.Factory): ...@@ -45,7 +58,8 @@ class ItemFactory(factory.Factory):
# pylint: disable=W0613 # pylint: disable=W0613
@classmethod @classmethod
def _create(cls, target_class, parent_location, category='chapter', def _create(cls, target_class, parent_location, category='chapter',
user_id='test_user', definition_locator=None, **kwargs): user_id='test_user', block_id=None, definition_locator=None, force=False,
continue_version=False, **kwargs):
""" """
passes *kwargs* as the new item's field values: passes *kwargs* as the new item's field values:
...@@ -55,8 +69,10 @@ class ItemFactory(factory.Factory): ...@@ -55,8 +69,10 @@ class ItemFactory(factory.Factory):
:param definition_locator (optional): the DescriptorLocator for the definition this uses or branches :param definition_locator (optional): the DescriptorLocator for the definition this uses or branches
""" """
return modulestore('split').create_item( modulestore = kwargs.pop('modulestore')
parent_location, category, user_id, definition_locator, fields=kwargs return modulestore.create_item(
parent_location, category, user_id, definition_locator=definition_locator,
block_id=block_id, force=force, continue_version=continue_version, fields=kwargs
) )
@classmethod @classmethod
......
...@@ -12,11 +12,11 @@ from xmodule.modulestore.loc_mapper_store import LocMapperStore ...@@ -12,11 +12,11 @@ from xmodule.modulestore.loc_mapper_store import LocMapperStore
from mock import Mock from mock import Mock
class TestLocationMapper(unittest.TestCase): class LocMapperSetupSansDjango(unittest.TestCase):
""" """
Test the location to locator mapper Create and destroy a loc mapper for each test
""" """
loc_store = None
def setUp(self): def setUp(self):
modulestore_options = { modulestore_options = {
'host': 'localhost', 'host': 'localhost',
...@@ -27,14 +27,19 @@ class TestLocationMapper(unittest.TestCase): ...@@ -27,14 +27,19 @@ class TestLocationMapper(unittest.TestCase):
cache_standin = TrivialCache() cache_standin = TrivialCache()
self.instrumented_cache = Mock(spec=cache_standin, wraps=cache_standin) self.instrumented_cache = Mock(spec=cache_standin, wraps=cache_standin)
# pylint: disable=W0142 # pylint: disable=W0142
TestLocationMapper.loc_store = LocMapperStore(self.instrumented_cache, **modulestore_options) LocMapperSetupSansDjango.loc_store = LocMapperStore(self.instrumented_cache, **modulestore_options)
def tearDown(self): def tearDown(self):
dbref = TestLocationMapper.loc_store.db dbref = TestLocationMapper.loc_store.db
dbref.drop_collection(TestLocationMapper.loc_store.location_map) dbref.drop_collection(TestLocationMapper.loc_store.location_map)
dbref.connection.close() dbref.connection.close()
TestLocationMapper.loc_store = None self.loc_store = None
class TestLocationMapper(LocMapperSetupSansDjango):
"""
Test the location to locator mapper
"""
def test_create_map(self): def test_create_map(self):
org = 'foo_org' org = 'foo_org'
course = 'bar_course' course = 'bar_course'
...@@ -125,7 +130,7 @@ class TestLocationMapper(unittest.TestCase): ...@@ -125,7 +130,7 @@ class TestLocationMapper(unittest.TestCase):
) )
test_problem_locn = Location('i4x', org, course, 'problem', 'abc123') test_problem_locn = Location('i4x', org, course, 'problem', 'abc123')
# only one course matches # only one course matches
self.translate_n_check(test_problem_locn, old_style_course_id, new_style_package_id, 'problem2', 'published')
# look for w/ only the Location (works b/c there's only one possible course match). Will force # look for w/ only the Location (works b/c there's only one possible course match). Will force
# cache as default translation for this problemid # cache as default translation for this problemid
self.translate_n_check(test_problem_locn, None, new_style_package_id, 'problem2', 'published') self.translate_n_check(test_problem_locn, None, new_style_package_id, 'problem2', 'published')
...@@ -389,7 +394,7 @@ def loc_mapper(): ...@@ -389,7 +394,7 @@ def loc_mapper():
""" """
Mocks the global location mapper. Mocks the global location mapper.
""" """
return TestLocationMapper.loc_store return LocMapperSetupSansDjango.loc_store
def render_to_template_mock(*_args): def render_to_template_mock(*_args):
......
...@@ -200,13 +200,14 @@ class LocatorTest(TestCase): ...@@ -200,13 +200,14 @@ class LocatorTest(TestCase):
expected_id = 'mit.eecs.6002x' expected_id = 'mit.eecs.6002x'
expected_branch = 'published' expected_branch = 'published'
expected_block_ref = 'HW3' expected_block_ref = 'HW3'
testobj = BlockUsageLocator(package_id=testurn) testobj = BlockUsageLocator(url=testurn)
self.check_block_locn_fields(testobj, 'test_block constructor', self.check_block_locn_fields(testobj, 'test_block constructor',
package_id=expected_id, package_id=expected_id,
branch=expected_branch, branch=expected_branch,
block=expected_block_ref) block=expected_block_ref)
self.assertEqual(str(testobj), testurn) self.assertEqual(str(testobj), testurn)
self.assertEqual(testobj.url(), 'edx://' + testurn) self.assertEqual(testobj.url(), 'edx://' + testurn)
testobj = BlockUsageLocator(url=testurn, version_guid=ObjectId())
agnostic = testobj.version_agnostic() agnostic = testobj.version_agnostic()
self.assertIsNone(agnostic.version_guid) self.assertIsNone(agnostic.version_guid)
self.check_block_locn_fields(agnostic, 'test_block constructor', self.check_block_locn_fields(agnostic, 'test_block constructor',
...@@ -225,7 +226,7 @@ class LocatorTest(TestCase): ...@@ -225,7 +226,7 @@ class LocatorTest(TestCase):
block='lab2', block='lab2',
version_guid=ObjectId(test_id_loc) version_guid=ObjectId(test_id_loc)
) )
agnostic = testobj.version_agnostic() agnostic = testobj.course_agnostic()
self.check_block_locn_fields( self.check_block_locn_fields(
agnostic, 'error parsing URL with version and block', agnostic, 'error parsing URL with version and block',
block='lab2', block='lab2',
......
...@@ -56,6 +56,7 @@ class TestMongoModuleStore(object): ...@@ -56,6 +56,7 @@ class TestMongoModuleStore(object):
def teardownClass(cls): def teardownClass(cls):
if cls.connection: if cls.connection:
cls.connection.drop_database(DB) cls.connection.drop_database(DB)
cls.connection.close()
@staticmethod @staticmethod
def initdb(): def initdb():
......
...@@ -163,8 +163,6 @@ class SplitModuleCourseTests(SplitModuleTest): ...@@ -163,8 +163,6 @@ class SplitModuleCourseTests(SplitModuleTest):
"children") "children")
_verify_published_course(modulestore().get_courses(branch='published')) _verify_published_course(modulestore().get_courses(branch='published'))
# default for branch is 'published'.
_verify_published_course(modulestore().get_courses())
def test_search_qualifiers(self): def test_search_qualifiers(self):
# query w/ search criteria # query w/ search criteria
......
...@@ -11,10 +11,10 @@ from mock import Mock, patch ...@@ -11,10 +11,10 @@ from mock import Mock, patch
from django.utils.timezone import UTC from django.utils.timezone import UTC
from xmodule.xml_module import is_pointer_tag from xmodule.xml_module import is_pointer_tag
from xmodule.modulestore import Location from xmodule.modulestore import Location, only_xmodules
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, LocationReader from xmodule.modulestore.xml import ImportSystem, XMLModuleStore, LocationReader
from xmodule.modulestore.inheritance import compute_inherited_metadata from xmodule.modulestore.inheritance import compute_inherited_metadata
from xmodule.x_module import XModuleMixin, only_xmodules from xmodule.x_module import XModuleMixin
from xmodule.fields import Date from xmodule.fields import Date
from xmodule.tests import DATA_DIR from xmodule.tests import DATA_DIR
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
......
...@@ -9,7 +9,7 @@ from factory import Factory, lazy_attribute, post_generation, Sequence ...@@ -9,7 +9,7 @@ from factory import Factory, lazy_attribute, post_generation, Sequence
from lxml import etree from lxml import etree
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import only_xmodules from xmodule.modulestore import only_xmodules
class XmlImportData(object): class XmlImportData(object):
......
...@@ -342,7 +342,7 @@ class VideoModule(VideoFields, XModule): ...@@ -342,7 +342,7 @@ class VideoModule(VideoFields, XModule):
try: try:
transcript = self.translation(request.GET.get('videoId')) transcript = self.translation(request.GET.get('videoId'))
except TranscriptException as ex: except (TranscriptException, NotFoundError) as ex:
log.info(ex.message) log.info(ex.message)
response = Response(status=404) response = Response(status=404)
else: else:
...@@ -414,6 +414,9 @@ class VideoModule(VideoFields, XModule): ...@@ -414,6 +414,9 @@ class VideoModule(VideoFields, XModule):
Filenames naming: Filenames naming:
en: subs_videoid.srt.sjson en: subs_videoid.srt.sjson
non_en: uk_subs_videoid.srt.sjson non_en: uk_subs_videoid.srt.sjson
Raises:
NotFoundError if for 'en' subtitles no asset is uploaded.
""" """
if self.transcript_language == 'en': if self.transcript_language == 'en':
return asset(self.location, subs_id).data return asset(self.location, subs_id).data
......
...@@ -26,6 +26,7 @@ from xmodule.errortracker import exc_info_to_str ...@@ -26,6 +26,7 @@ from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.exceptions import UndefinedContext
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -605,22 +606,6 @@ class ResourceTemplates(object): ...@@ -605,22 +606,6 @@ class ResourceTemplates(object):
return template return template
def prefer_xmodules(identifier, entry_points):
"""Prefer entry_points from the xmodule package"""
from_xmodule = [entry_point for entry_point in entry_points if entry_point.dist.key == 'xmodule']
if from_xmodule:
return default_select(identifier, from_xmodule)
else:
return default_select(identifier, entry_points)
def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package"""
from_xmodule = [entry_point for entry_point in entry_points if entry_point.dist.key == 'xmodule']
return default_select(identifier, from_xmodule)
@XBlock.needs("i18n") @XBlock.needs("i18n")
class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
""" """
...@@ -836,7 +821,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock): ...@@ -836,7 +821,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
Returns the XModule corresponding to this descriptor. Expects that the system Returns the XModule corresponding to this descriptor. Expects that the system
already supports all of the attributes needed by xmodules already supports all of the attributes needed by xmodules
""" """
assert self.xmodule_runtime is not None if self.xmodule_runtime is None:
raise UndefinedContext()
assert self.xmodule_runtime.error_descriptor_class is not None assert self.xmodule_runtime.error_descriptor_class is not None
if self.xmodule_runtime.xmodule_instance is None: if self.xmodule_runtime.xmodule_instance is None:
try: try:
......
...@@ -20,6 +20,7 @@ if Backbone? ...@@ -20,6 +20,7 @@ if Backbone?
@template(templateData) @template(templateData)
render: -> render: ->
@$el.addClass("response_" + @model.get("id"))
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
......
diff --git a/codemirror-accessible.js b/codemirror-accessible.js
index 1d0d996..bd37cfb 100644
--- a/codemirror-accessible.js
+++ b/codemirror-accessible.js
@@ -1443,7 +1443,7 @@ window.CodeMirror = (function() {
// supported or compatible enough yet to rely on.)
function readInput(cm) {
var input = cm.display.input, prevInput = cm.display.prevInput, doc = cm.doc, sel = doc.sel;
- if (!cm.state.focused || hasSelection(input) || isReadOnly(cm) || cm.options.disableInput) return false;
+ if (!cm.state.focused || hasSelection(input) || isReadOnly(cm) || cm.options.disableInput || cm.state.accessibleTextareaWaiting) return false;
var text = input.value;
if (text == prevInput && posEq(sel.from, sel.to)) return false;
if (ie && !ie_lt9 && cm.display.inputHasSelection === text) {
@@ -1480,13 +1480,13 @@ window.CodeMirror = (function() {
var minimal, selected, doc = cm.doc;
if (!posEq(doc.sel.from, doc.sel.to)) {
cm.display.prevInput = "";
- minimal = hasCopyEvent &&
+ minimal = false && hasCopyEvent &&
(doc.sel.to.line - doc.sel.from.line > 100 || (selected = cm.getSelection()).length > 1000);
var content = minimal ? "-" : selected || cm.getSelection();
cm.display.input.value = content;
if (cm.state.focused) selectInput(cm.display.input);
if (ie && !ie_lt9) cm.display.inputHasSelection = content;
- } else if (user) {
+ } else if (user && !cm.state.accessibleTextareaWaiting) {
cm.display.prevInput = cm.display.input.value = "";
if (ie && !ie_lt9) cm.display.inputHasSelection = null;
}
@@ -2069,6 +2069,12 @@ window.CodeMirror = (function() {
cm.doc.sel.shift = code == 16 || e.shiftKey;
// First give onKeyEvent option a chance to handle this.
var handled = handleKeyBinding(cm, e);
+
+ // On text input if value was temporaritly set for a screenreader, clear it out.
+ if (!handled && cm.state.accessibleTextareaWaiting) {
+ clearAccessibleTextarea(cm);
+ }
+
if (opera) {
lastStoppedKey = handled ? code : null;
// Opera has no cut event... we try to at least catch the key combo
@@ -2473,6 +2479,29 @@ window.CodeMirror = (function() {
setSelection(doc, pos, other || pos, bias);
}
if (doc.cm) doc.cm.curOp.userSelChange = true;
+
+ if (doc.cm) {
+ var from = doc.sel.from;
+ var to = doc.sel.to;
+
+ if (posEq(from, to) && doc.cm.display.input.setSelectionRange) {
+ clearTimeout(doc.cm.state.accessibleTextareaTimeout);
+ doc.cm.state.accessibleTextareaWaiting = true;
+
+ doc.cm.display.input.value = doc.getLine(from.line) + "\n";
+ doc.cm.display.input.setSelectionRange(from.ch, from.ch);
+
+ doc.cm.state.accessibleTextareaTimeout = setTimeout(function() {
+ clearAccessibleTextarea(doc.cm);
+ }, 80);
+ }
+ }
+ }
+
+ function clearAccessibleTextarea(cm) {
+ clearTimeout(cm.state.accessibleTextareaTimeout);
+ cm.state.accessibleTextareaWaiting = false;
+ resetInput(cm, true);
}
function filterSelectionChange(doc, anchor, head) {
/**
* Tag-closer extension for CodeMirror.
*
* This extension adds an "autoCloseTags" option that can be set to
* either true to get the default behavior, or an object to further
* configure its behavior.
*
* These are supported options:
*
* `whenClosing` (default true)
* Whether to autoclose when the '/' of a closing tag is typed.
* `whenOpening` (default true)
* Whether to autoclose the tag when the final '>' of an opening
* tag is typed.
* `dontCloseTags` (default is empty tags for HTML, none for XML)
* An array of tag names that should not be autoclosed.
* `indentTags` (default is block tags for HTML, none for XML)
* An array of tag names that should, when opened, cause a
* blank line to be added inside the tag, and the blank line and
* closing line to be indented.
*
* See demos/closetag.html for a usage example.
*/
(function() {
CodeMirror.defineOption("autoCloseTags", false, function(cm, val, old) {
if (old != CodeMirror.Init && old)
cm.removeKeyMap("autoCloseTags");
if (!val) return;
var map = {name: "autoCloseTags"};
if (typeof val != "object" || val.whenClosing)
map["'/'"] = function(cm) { return autoCloseSlash(cm); };
if (typeof val != "object" || val.whenOpening)
map["'>'"] = function(cm) { return autoCloseGT(cm); };
cm.addKeyMap(map);
});
var htmlDontClose = ["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param",
"source", "track", "wbr"];
var htmlIndent = ["applet", "blockquote", "body", "button", "div", "dl", "fieldset", "form", "frameset", "h1", "h2", "h3", "h4",
"h5", "h6", "head", "html", "iframe", "layer", "legend", "object", "ol", "p", "select", "table", "ul"];
function autoCloseGT(cm) {
var pos = cm.getCursor(), tok = cm.getTokenAt(pos);
var inner = CodeMirror.innerMode(cm.getMode(), tok.state), state = inner.state;
if (inner.mode.name != "xml" || !state.tagName || cm.getOption("disableInput")) return CodeMirror.Pass;
var opt = cm.getOption("autoCloseTags"), html = inner.mode.configuration == "html";
var dontCloseTags = (typeof opt == "object" && opt.dontCloseTags) || (html && htmlDontClose);
var indentTags = (typeof opt == "object" && opt.indentTags) || (html && htmlIndent);
var tagName = state.tagName;
if (tok.end > pos.ch) tagName = tagName.slice(0, tagName.length - tok.end + pos.ch);
var lowerTagName = tagName.toLowerCase();
// Don't process the '>' at the end of an end-tag or self-closing tag
if (!tagName ||
tok.type == "string" && (tok.end != pos.ch || !/[\"\']/.test(tok.string.charAt(tok.string.length - 1)) || tok.string.length == 1) ||
tok.type == "tag" && state.type == "closeTag" ||
tok.string.indexOf("/") == (tok.string.length - 1) || // match something like <someTagName />
dontCloseTags && indexOf(dontCloseTags, lowerTagName) > -1 ||
CodeMirror.scanForClosingTag && CodeMirror.scanForClosingTag(cm, pos, tagName,
Math.min(cm.lastLine() + 1, pos.line + 50)))
return CodeMirror.Pass;
var doIndent = indentTags && indexOf(indentTags, lowerTagName) > -1;
var curPos = doIndent ? CodeMirror.Pos(pos.line + 1, 0) : CodeMirror.Pos(pos.line, pos.ch + 1);
cm.replaceSelection(">" + (doIndent ? "\n\n" : "") + "</" + tagName + ">",
{head: curPos, anchor: curPos});
if (doIndent) {
cm.indentLine(pos.line + 1, null, true);
cm.indentLine(pos.line + 2, null);
}
}
function autoCloseSlash(cm) {
var pos = cm.getCursor(), tok = cm.getTokenAt(pos);
var inner = CodeMirror.innerMode(cm.getMode(), tok.state), state = inner.state;
if (tok.type == "string" || tok.string.charAt(0) != "<" ||
tok.start != pos.ch - 1 || inner.mode.name != "xml" ||
cm.getOption("disableInput"))
return CodeMirror.Pass;
var tagName = state.context && state.context.tagName;
if (tagName) cm.replaceSelection("/" + tagName + ">", "end");
}
function indexOf(collection, elt) {
if (collection.indexOf) return collection.indexOf(elt);
for (var i = 0, e = collection.length; i < e; ++i)
if (collection[i] == elt) return i;
return -1;
}
})();
(function() {
"use strict";
var noOptions = {};
var nonWS = /[^\s\u00a0]/;
var Pos = CodeMirror.Pos;
function firstNonWS(str) {
var found = str.search(nonWS);
return found == -1 ? 0 : found;
}
CodeMirror.commands.toggleComment = function(cm) {
var from = cm.getCursor("start"), to = cm.getCursor("end");
cm.uncomment(from, to) || cm.lineComment(from, to);
};
CodeMirror.defineExtension("lineComment", function(from, to, options) {
if (!options) options = noOptions;
var self = this, mode = self.getModeAt(from);
var commentString = options.lineComment || mode.lineComment;
if (!commentString) {
if (options.blockCommentStart || mode.blockCommentStart) {
options.fullLines = true;
self.blockComment(from, to, options);
}
return;
}
var firstLine = self.getLine(from.line);
if (firstLine == null) return;
var end = Math.min(to.ch != 0 || to.line == from.line ? to.line + 1 : to.line, self.lastLine() + 1);
var pad = options.padding == null ? " " : options.padding;
var blankLines = options.commentBlankLines || from.line == to.line;
self.operation(function() {
if (options.indent) {
var baseString = firstLine.slice(0, firstNonWS(firstLine));
for (var i = from.line; i < end; ++i) {
var line = self.getLine(i), cut = baseString.length;
if (!blankLines && !nonWS.test(line)) continue;
if (line.slice(0, cut) != baseString) cut = firstNonWS(line);
self.replaceRange(baseString + commentString + pad, Pos(i, 0), Pos(i, cut));
}
} else {
for (var i = from.line; i < end; ++i) {
if (blankLines || nonWS.test(self.getLine(i)))
self.replaceRange(commentString + pad, Pos(i, 0));
}
}
});
});
CodeMirror.defineExtension("blockComment", function(from, to, options) {
if (!options) options = noOptions;
var self = this, mode = self.getModeAt(from);
var startString = options.blockCommentStart || mode.blockCommentStart;
var endString = options.blockCommentEnd || mode.blockCommentEnd;
if (!startString || !endString) {
if ((options.lineComment || mode.lineComment) && options.fullLines != false)
self.lineComment(from, to, options);
return;
}
var end = Math.min(to.line, self.lastLine());
if (end != from.line && to.ch == 0 && nonWS.test(self.getLine(end))) --end;
var pad = options.padding == null ? " " : options.padding;
if (from.line > end) return;
self.operation(function() {
if (options.fullLines != false) {
var lastLineHasText = nonWS.test(self.getLine(end));
self.replaceRange(pad + endString, Pos(end));
self.replaceRange(startString + pad, Pos(from.line, 0));
var lead = options.blockCommentLead || mode.blockCommentLead;
if (lead != null) for (var i = from.line + 1; i <= end; ++i)
if (i != end || lastLineHasText)
self.replaceRange(lead + pad, Pos(i, 0));
} else {
self.replaceRange(endString, to);
self.replaceRange(startString, from);
}
});
});
CodeMirror.defineExtension("uncomment", function(from, to, options) {
if (!options) options = noOptions;
var self = this, mode = self.getModeAt(from);
var end = Math.min(to.line, self.lastLine()), start = Math.min(from.line, end);
// Try finding line comments
var lineString = options.lineComment || mode.lineComment, lines = [];
var pad = options.padding == null ? " " : options.padding, didSomething;
lineComment: {
if (!lineString) break lineComment;
for (var i = start; i <= end; ++i) {
var line = self.getLine(i);
var found = line.indexOf(lineString);
if (found > -1 && !/comment/.test(self.getTokenTypeAt(Pos(i, found + 1)))) found = -1;
if (found == -1 && (i != end || i == start) && nonWS.test(line)) break lineComment;
if (found > -1 && nonWS.test(line.slice(0, found))) break lineComment;
lines.push(line);
}
self.operation(function() {
for (var i = start; i <= end; ++i) {
var line = lines[i - start];
var pos = line.indexOf(lineString), endPos = pos + lineString.length;
if (pos < 0) continue;
if (line.slice(endPos, endPos + pad.length) == pad) endPos += pad.length;
didSomething = true;
self.replaceRange("", Pos(i, pos), Pos(i, endPos));
}
});
if (didSomething) return true;
}
// Try block comments
var startString = options.blockCommentStart || mode.blockCommentStart;
var endString = options.blockCommentEnd || mode.blockCommentEnd;
if (!startString || !endString) return false;
var lead = options.blockCommentLead || mode.blockCommentLead;
var startLine = self.getLine(start), endLine = end == start ? startLine : self.getLine(end);
var open = startLine.indexOf(startString), close = endLine.lastIndexOf(endString);
if (close == -1 && start != end) {
endLine = self.getLine(--end);
close = endLine.lastIndexOf(endString);
}
if (open == -1 || close == -1 ||
!/comment/.test(self.getTokenTypeAt(Pos(start, open + 1))) ||
!/comment/.test(self.getTokenTypeAt(Pos(end, close + 1))))
return false;
self.operation(function() {
self.replaceRange("", Pos(end, close - (pad && endLine.slice(close - pad.length, close) == pad ? pad.length : 0)),
Pos(end, close + endString.length));
var openEnd = open + startString.length;
if (pad && startLine.slice(openEnd, openEnd + pad.length) == pad) openEnd += pad.length;
self.replaceRange("", Pos(start, open), Pos(start, openEnd));
if (lead) for (var i = start + 1; i <= end; ++i) {
var line = self.getLine(i), found = line.indexOf(lead);
if (found == -1 || nonWS.test(line.slice(0, found))) continue;
var foundEnd = found + lead.length;
if (pad && line.slice(foundEnd, foundEnd + pad.length) == pad) foundEnd += pad.length;
self.replaceRange("", Pos(i, found), Pos(i, foundEnd));
}
});
return true;
});
})();
CodeMirror.defineMode("diff", function() {
var TOKEN_NAMES = {
'+': 'positive',
'-': 'negative',
'@': 'meta'
};
return {
token: function(stream) {
var tw_pos = stream.string.search(/[\t ]+?$/);
if (!stream.sol() || tw_pos === 0) {
stream.skipToEnd();
return ("error " + (
TOKEN_NAMES[stream.string.charAt(0)] || '')).replace(/ $/, '');
}
var token_name = TOKEN_NAMES[stream.peek()] || stream.skipToEnd();
if (tw_pos === -1) {
stream.skipToEnd();
} else {
stream.pos = tw_pos;
}
return token_name;
}
};
});
CodeMirror.defineMIME("text/x-diff", "diff");
(function() {
CodeMirror.extendMode("css", {
commentStart: "/*",
commentEnd: "*/",
newlineAfterToken: function(_type, content) {
return /^[;{}]$/.test(content);
}
});
CodeMirror.extendMode("javascript", {
commentStart: "/*",
commentEnd: "*/",
// FIXME semicolons inside of for
newlineAfterToken: function(_type, content, textAfter, state) {
if (this.jsonMode) {
return /^[\[,{]$/.test(content) || /^}/.test(textAfter);
} else {
if (content == ";" && state.lexical && state.lexical.type == ")") return false;
return /^[;{}]$/.test(content) && !/^;/.test(textAfter);
}
}
});
var inlineElements = /^(a|abbr|acronym|area|base|bdo|big|br|button|caption|cite|code|col|colgroup|dd|del|dfn|em|frame|hr|iframe|img|input|ins|kbd|label|legend|link|map|object|optgroup|option|param|q|samp|script|select|small|span|strong|sub|sup|textarea|tt|var)$/;
CodeMirror.extendMode("xml", {
commentStart: "<!--",
commentEnd: "-->",
newlineAfterToken: function(type, content, textAfter, state) {
var inline = false;
if (this.configuration == "html")
inline = state.context ? inlineElements.test(state.context.tagName) : false;
return !inline && ((type == "tag" && />$/.test(content) && state.context) ||
/^</.test(textAfter));
}
});
// Comment/uncomment the specified range
CodeMirror.defineExtension("commentRange", function (isComment, from, to) {
var cm = this, curMode = CodeMirror.innerMode(cm.getMode(), cm.getTokenAt(from).state).mode;
cm.operation(function() {
if (isComment) { // Comment range
cm.replaceRange(curMode.commentEnd, to);
cm.replaceRange(curMode.commentStart, from);
if (from.line == to.line && from.ch == to.ch) // An empty comment inserted - put cursor inside
cm.setCursor(from.line, from.ch + curMode.commentStart.length);
} else { // Uncomment range
var selText = cm.getRange(from, to);
var startIndex = selText.indexOf(curMode.commentStart);
var endIndex = selText.lastIndexOf(curMode.commentEnd);
if (startIndex > -1 && endIndex > -1 && endIndex > startIndex) {
// Take string till comment start
selText = selText.substr(0, startIndex)
// From comment start till comment end
+ selText.substring(startIndex + curMode.commentStart.length, endIndex)
// From comment end till string end
+ selText.substr(endIndex + curMode.commentEnd.length);
}
cm.replaceRange(selText, from, to);
}
});
});
// Applies automatic mode-aware indentation to the specified range
CodeMirror.defineExtension("autoIndentRange", function (from, to) {
var cmInstance = this;
this.operation(function () {
for (var i = from.line; i <= to.line; i++) {
cmInstance.indentLine(i, "smart");
}
});
});
// Applies automatic formatting to the specified range
CodeMirror.defineExtension("autoFormatRange", function (from, to) {
var cm = this;
var outer = cm.getMode(), text = cm.getRange(from, to).split("\n");
var state = CodeMirror.copyState(outer, cm.getTokenAt(from).state);
var tabSize = cm.getOption("tabSize");
var out = "", lines = 0, atSol = from.ch == 0;
function newline() {
out += "\n";
atSol = true;
++lines;
}
for (var i = 0; i < text.length; ++i) {
var stream = new CodeMirror.StringStream(text[i], tabSize);
while (!stream.eol()) {
var inner = CodeMirror.innerMode(outer, state);
var style = outer.token(stream, state), cur = stream.current();
stream.start = stream.pos;
if (!atSol || /\S/.test(cur)) {
out += cur;
atSol = false;
}
if (!atSol && inner.mode.newlineAfterToken &&
inner.mode.newlineAfterToken(style, cur, stream.string.slice(stream.pos) || text[i+1] || "", inner.state))
newline();
}
if (!stream.pos && outer.blankLine) outer.blankLine(state);
if (!atSol && i < text.length - 1) newline();
}
cm.operation(function () {
cm.replaceRange(out, from, to);
for (var cur = from.line + 1, end = from.line + lines; cur <= end; ++cur)
cm.indentLine(cur, "smart");
cm.setSelection(from, cm.getCursor(false));
});
});
})();
CodeMirror.defineMode("htmlembedded", function(config, parserConfig) {
//config settings
var scriptStartRegex = parserConfig.scriptStartRegex || /^<%/i,
scriptEndRegex = parserConfig.scriptEndRegex || /^%>/i;
//inner modes
var scriptingMode, htmlMixedMode;
//tokenizer when in html mode
function htmlDispatch(stream, state) {
if (stream.match(scriptStartRegex, false)) {
state.token=scriptingDispatch;
return scriptingMode.token(stream, state.scriptState);
}
else
return htmlMixedMode.token(stream, state.htmlState);
}
//tokenizer when in scripting mode
function scriptingDispatch(stream, state) {
if (stream.match(scriptEndRegex, false)) {
state.token=htmlDispatch;
return htmlMixedMode.token(stream, state.htmlState);
}
else
return scriptingMode.token(stream, state.scriptState);
}
return {
startState: function() {
scriptingMode = scriptingMode || CodeMirror.getMode(config, parserConfig.scriptingModeSpec);
htmlMixedMode = htmlMixedMode || CodeMirror.getMode(config, "htmlmixed");
return {
token : parserConfig.startOpen ? scriptingDispatch : htmlDispatch,
htmlState : CodeMirror.startState(htmlMixedMode),
scriptState : CodeMirror.startState(scriptingMode)
};
},
token: function(stream, state) {
return state.token(stream, state);
},
indent: function(state, textAfter) {
if (state.token == htmlDispatch)
return htmlMixedMode.indent(state.htmlState, textAfter);
else if (scriptingMode.indent)
return scriptingMode.indent(state.scriptState, textAfter);
},
copyState: function(state) {
return {
token : state.token,
htmlState : CodeMirror.copyState(htmlMixedMode, state.htmlState),
scriptState : CodeMirror.copyState(scriptingMode, state.scriptState)
};
},
innerMode: function(state) {
if (state.token == scriptingDispatch) return {state: state.scriptState, mode: scriptingMode};
else return {state: state.htmlState, mode: htmlMixedMode};
}
};
}, "htmlmixed");
CodeMirror.defineMIME("application/x-ejs", { name: "htmlembedded", scriptingModeSpec:"javascript"});
CodeMirror.defineMIME("application/x-aspx", { name: "htmlembedded", scriptingModeSpec:"text/x-csharp"});
CodeMirror.defineMIME("application/x-jsp", { name: "htmlembedded", scriptingModeSpec:"text/x-java"});
CodeMirror.defineMIME("application/x-erb", { name: "htmlembedded", scriptingModeSpec:"ruby"});
CodeMirror.defineMode("htmlmixed", function(config, parserConfig) {
var htmlMode = CodeMirror.getMode(config, {name: "xml", htmlMode: true});
var cssMode = CodeMirror.getMode(config, "css");
var scriptTypes = [], scriptTypesConf = parserConfig && parserConfig.scriptTypes;
scriptTypes.push({matches: /^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^$/i,
mode: CodeMirror.getMode(config, "javascript")});
if (scriptTypesConf) for (var i = 0; i < scriptTypesConf.length; ++i) {
var conf = scriptTypesConf[i];
scriptTypes.push({matches: conf.matches, mode: conf.mode && CodeMirror.getMode(config, conf.mode)});
}
scriptTypes.push({matches: /./,
mode: CodeMirror.getMode(config, "text/plain")});
function html(stream, state) {
var tagName = state.htmlState.tagName;
var style = htmlMode.token(stream, state.htmlState);
if (tagName == "script" && /\btag\b/.test(style) && stream.current() == ">") {
// Script block: mode to change to depends on type attribute
var scriptType = stream.string.slice(Math.max(0, stream.pos - 100), stream.pos).match(/\btype\s*=\s*("[^"]+"|'[^']+'|\S+)[^<]*$/i);
scriptType = scriptType ? scriptType[1] : "";
if (scriptType && /[\"\']/.test(scriptType.charAt(0))) scriptType = scriptType.slice(1, scriptType.length - 1);
for (var i = 0; i < scriptTypes.length; ++i) {
var tp = scriptTypes[i];
if (typeof tp.matches == "string" ? scriptType == tp.matches : tp.matches.test(scriptType)) {
if (tp.mode) {
state.token = script;
state.localMode = tp.mode;
state.localState = tp.mode.startState && tp.mode.startState(htmlMode.indent(state.htmlState, ""));
}
break;
}
}
} else if (tagName == "style" && /\btag\b/.test(style) && stream.current() == ">") {
state.token = css;
state.localMode = cssMode;
state.localState = cssMode.startState(htmlMode.indent(state.htmlState, ""));
}
return style;
}
function maybeBackup(stream, pat, style) {
var cur = stream.current();
var close = cur.search(pat), m;
if (close > -1) stream.backUp(cur.length - close);
else if (m = cur.match(/<\/?$/)) {
stream.backUp(cur.length);
if (!stream.match(pat, false)) stream.match(cur);
}
return style;
}
function script(stream, state) {
if (stream.match(/^<\/\s*script\s*>/i, false)) {
state.token = html;
state.localState = state.localMode = null;
return html(stream, state);
}
return maybeBackup(stream, /<\/\s*script\s*>/,
state.localMode.token(stream, state.localState));
}
function css(stream, state) {
if (stream.match(/^<\/\s*style\s*>/i, false)) {
state.token = html;
state.localState = state.localMode = null;
return html(stream, state);
}
return maybeBackup(stream, /<\/\s*style\s*>/,
cssMode.token(stream, state.localState));
}
return {
startState: function() {
var state = htmlMode.startState();
return {token: html, localMode: null, localState: null, htmlState: state};
},
copyState: function(state) {
if (state.localState)
var local = CodeMirror.copyState(state.localMode, state.localState);
return {token: state.token, localMode: state.localMode, localState: local,
htmlState: CodeMirror.copyState(htmlMode, state.htmlState)};
},
token: function(stream, state) {
return state.token(stream, state);
},
indent: function(state, textAfter) {
if (!state.localMode || /^\s*<\//.test(textAfter))
return htmlMode.indent(state.htmlState, textAfter);
else if (state.localMode.indent)
return state.localMode.indent(state.localState, textAfter);
else
return CodeMirror.Pass;
},
innerMode: function(state) {
return {state: state.localState || state.htmlState, mode: state.localMode || htmlMode};
}
};
}, "xml", "javascript", "css");
CodeMirror.defineMIME("text/html", "htmlmixed");
// Highlighting text that matches the selection
//
// Defines an option highlightSelectionMatches, which, when enabled,
// will style strings that match the selection throughout the
// document.
//
// The option can be set to true to simply enable it, or to a
// {minChars, style, showToken} object to explicitly configure it.
// minChars is the minimum amount of characters that should be
// selected for the behavior to occur, and style is the token style to
// apply to the matches. This will be prefixed by "cm-" to create an
// actual CSS class name. showToken, when enabled, will cause the
// current token to be highlighted when nothing is selected.
(function() {
var DEFAULT_MIN_CHARS = 2;
var DEFAULT_TOKEN_STYLE = "matchhighlight";
var DEFAULT_DELAY = 100;
function State(options) {
if (typeof options == "object") {
this.minChars = options.minChars;
this.style = options.style;
this.showToken = options.showToken;
this.delay = options.delay;
}
if (this.style == null) this.style = DEFAULT_TOKEN_STYLE;
if (this.minChars == null) this.minChars = DEFAULT_MIN_CHARS;
if (this.delay == null) this.delay = DEFAULT_DELAY;
this.overlay = this.timeout = null;
}
CodeMirror.defineOption("highlightSelectionMatches", false, function(cm, val, old) {
if (old && old != CodeMirror.Init) {
var over = cm.state.matchHighlighter.overlay;
if (over) cm.removeOverlay(over);
clearTimeout(cm.state.matchHighlighter.timeout);
cm.state.matchHighlighter = null;
cm.off("cursorActivity", cursorActivity);
}
if (val) {
cm.state.matchHighlighter = new State(val);
highlightMatches(cm);
cm.on("cursorActivity", cursorActivity);
}
});
function cursorActivity(cm) {
var state = cm.state.matchHighlighter;
clearTimeout(state.timeout);
state.timeout = setTimeout(function() {highlightMatches(cm);}, state.delay);
}
function highlightMatches(cm) {
cm.operation(function() {
var state = cm.state.matchHighlighter;
if (state.overlay) {
cm.removeOverlay(state.overlay);
state.overlay = null;
}
if (!cm.somethingSelected() && state.showToken) {
var re = state.showToken === true ? /[\w$]/ : state.showToken;
var cur = cm.getCursor(), line = cm.getLine(cur.line), start = cur.ch, end = start;
while (start && re.test(line.charAt(start - 1))) --start;
while (end < line.length && re.test(line.charAt(end))) ++end;
if (start < end)
cm.addOverlay(state.overlay = makeOverlay(line.slice(start, end), re, state.style));
return;
}
if (cm.getCursor("head").line != cm.getCursor("anchor").line) return;
var selection = cm.getSelection().replace(/^\s+|\s+$/g, "");
if (selection.length >= state.minChars)
cm.addOverlay(state.overlay = makeOverlay(selection, false, state.style));
});
}
function boundariesAround(stream, re) {
return (!stream.start || !re.test(stream.string.charAt(stream.start - 1))) &&
(stream.pos == stream.string.length || !re.test(stream.string.charAt(stream.pos)));
}
function makeOverlay(query, hasBoundary, style) {
return {token: function(stream) {
if (stream.match(query) &&
(!hasBoundary || boundariesAround(stream, hasBoundary)))
return style;
stream.next();
stream.skipTo(query.charAt(0)) || stream.skipToEnd();
}};
}
})();
// Define search commands. Depends on dialog.js or another
// implementation of the openDialog method.
// Replace works a little oddly -- it will do the replace on the next
// Ctrl-G (or whatever is bound to findNext) press. You prevent a
// replace by making sure the match is no longer selected when hitting
// Ctrl-G.
(function() {
function searchOverlay(query, caseInsensitive) {
var startChar;
if (typeof query == "string") {
startChar = query.charAt(0);
query = new RegExp("^" + query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"),
caseInsensitive ? "i" : "");
} else {
query = new RegExp("^(?:" + query.source + ")", query.ignoreCase ? "i" : "");
}
if (typeof query == "string") return {token: function(stream) {
if (stream.match(query)) return "searching";
stream.next();
stream.skipTo(query.charAt(0)) || stream.skipToEnd();
}};
return {token: function(stream) {
if (stream.match(query)) return "searching";
while (!stream.eol()) {
stream.next();
if (startChar)
stream.skipTo(startChar) || stream.skipToEnd();
if (stream.match(query, false)) break;
}
}};
}
function SearchState() {
this.posFrom = this.posTo = this.query = null;
this.overlay = null;
}
function getSearchState(cm) {
return cm.state.search || (cm.state.search = new SearchState());
}
function queryCaseInsensitive(query) {
return typeof query == "string" && query == query.toLowerCase();
}
function getSearchCursor(cm, query, pos) {
// Heuristic: if the query string is all lowercase, do a case insensitive search.
return cm.getSearchCursor(query, pos, queryCaseInsensitive(query));
}
function dialog(cm, text, shortText, deflt, f) {
if (cm.openDialog) cm.openDialog(text, f, {value: deflt});
else f(prompt(shortText, deflt));
}
function confirmDialog(cm, text, shortText, fs) {
if (cm.openConfirm) cm.openConfirm(text, fs);
else if (confirm(shortText)) fs[0]();
}
function parseQuery(query) {
var isRE = query.match(/^\/(.*)\/([a-z]*)$/);
return isRE ? new RegExp(isRE[1], isRE[2].indexOf("i") == -1 ? "" : "i") : query;
}
var queryDialog =
'Search: <input type="text" style="width: 10em"/> <span style="color: #888">(Use /re/ syntax for regexp search)</span>';
function doSearch(cm, rev) {
var state = getSearchState(cm);
if (state.query) return findNext(cm, rev);
dialog(cm, queryDialog, "Search for:", cm.getSelection(), function(query) {
cm.operation(function() {
if (!query || state.query) return;
state.query = parseQuery(query);
cm.removeOverlay(state.overlay, queryCaseInsensitive(state.query));
state.overlay = searchOverlay(state.query);
cm.addOverlay(state.overlay);
state.posFrom = state.posTo = cm.getCursor();
findNext(cm, rev);
});
});
}
function findNext(cm, rev) {cm.operation(function() {
var state = getSearchState(cm);
var cursor = getSearchCursor(cm, state.query, rev ? state.posFrom : state.posTo);
if (!cursor.find(rev)) {
cursor = getSearchCursor(cm, state.query, rev ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0));
if (!cursor.find(rev)) return;
}
cm.setSelection(cursor.from(), cursor.to());
cm.scrollIntoView({from: cursor.from(), to: cursor.to()});
state.posFrom = cursor.from(); state.posTo = cursor.to();
});}
function clearSearch(cm) {cm.operation(function() {
var state = getSearchState(cm);
if (!state.query) return;
state.query = null;
cm.removeOverlay(state.overlay);
});}
var replaceQueryDialog =
'Replace: <input type="text" style="width: 10em"/> <span style="color: #888">(Use /re/ syntax for regexp search)</span>';
var replacementQueryDialog = 'With: <input type="text" style="width: 10em"/>';
var doReplaceConfirm = "Replace? <button>Yes</button> <button>No</button> <button>Stop</button>";
function replace(cm, all) {
dialog(cm, replaceQueryDialog, "Replace:", cm.getSelection(), function(query) {
if (!query) return;
query = parseQuery(query);
dialog(cm, replacementQueryDialog, "Replace with:", "", function(text) {
if (all) {
cm.operation(function() {
for (var cursor = getSearchCursor(cm, query); cursor.findNext();) {
if (typeof query != "string") {
var match = cm.getRange(cursor.from(), cursor.to()).match(query);
cursor.replace(text.replace(/\$(\d)/, function(_, i) {return match[i];}));
} else cursor.replace(text);
}
});
} else {
clearSearch(cm);
var cursor = getSearchCursor(cm, query, cm.getCursor());
var advance = function() {
var start = cursor.from(), match;
if (!(match = cursor.findNext())) {
cursor = getSearchCursor(cm, query);
if (!(match = cursor.findNext()) ||
(start && cursor.from().line == start.line && cursor.from().ch == start.ch)) return;
}
cm.setSelection(cursor.from(), cursor.to());
cm.scrollIntoView({from: cursor.from(), to: cursor.to()});
confirmDialog(cm, doReplaceConfirm, "Replace?",
[function() {doReplace(match);}, advance]);
};
var doReplace = function(match) {
cursor.replace(typeof query == "string" ? text :
text.replace(/\$(\d)/, function(_, i) {return match[i];}));
advance();
};
advance();
}
});
});
}
CodeMirror.commands.find = function(cm) {clearSearch(cm); doSearch(cm);};
CodeMirror.commands.findNext = doSearch;
CodeMirror.commands.findPrev = function(cm) {doSearch(cm, true);};
CodeMirror.commands.clearSearch = clearSearch;
CodeMirror.commands.replace = replace;
CodeMirror.commands.replaceAll = function(cm) {replace(cm, true);};
})();
(function(){
var Pos = CodeMirror.Pos;
function SearchCursor(doc, query, pos, caseFold) {
this.atOccurrence = false; this.doc = doc;
if (caseFold == null && typeof query == "string") caseFold = false;
pos = pos ? doc.clipPos(pos) : Pos(0, 0);
this.pos = {from: pos, to: pos};
// The matches method is filled in based on the type of query.
// It takes a position and a direction, and returns an object
// describing the next occurrence of the query, or null if no
// more matches were found.
if (typeof query != "string") { // Regexp match
if (!query.global) query = new RegExp(query.source, query.ignoreCase ? "ig" : "g");
this.matches = function(reverse, pos) {
if (reverse) {
query.lastIndex = 0;
var line = doc.getLine(pos.line).slice(0, pos.ch), cutOff = 0, match, start;
for (;;) {
query.lastIndex = cutOff;
var newMatch = query.exec(line);
if (!newMatch) break;
match = newMatch;
start = match.index;
cutOff = match.index + (match[0].length || 1);
if (cutOff == line.length) break;
}
var matchLen = (match && match[0].length) || 0;
if (!matchLen) {
if (start == 0 && line.length == 0) {match = undefined;}
else if (start != doc.getLine(pos.line).length) {
matchLen++;
}
}
} else {
query.lastIndex = pos.ch;
var line = doc.getLine(pos.line), match = query.exec(line);
var matchLen = (match && match[0].length) || 0;
var start = match && match.index;
if (start + matchLen != line.length && !matchLen) matchLen = 1;
}
if (match && matchLen)
return {from: Pos(pos.line, start),
to: Pos(pos.line, start + matchLen),
match: match};
};
} else { // String query
var origQuery = query;
if (caseFold) query = query.toLowerCase();
var fold = caseFold ? function(str){return str.toLowerCase();} : function(str){return str;};
var target = query.split("\n");
// Different methods for single-line and multi-line queries
if (target.length == 1) {
if (!query.length) {
// Empty string would match anything and never progress, so
// we define it to match nothing instead.
this.matches = function() {};
} else {
this.matches = function(reverse, pos) {
if (reverse) {
var orig = doc.getLine(pos.line).slice(0, pos.ch), line = fold(orig);
var match = line.lastIndexOf(query);
if (match > -1) {
match = adjustPos(orig, line, match);
return {from: Pos(pos.line, match), to: Pos(pos.line, match + origQuery.length)};
}
} else {
var orig = doc.getLine(pos.line).slice(pos.ch), line = fold(orig);
var match = line.indexOf(query);
if (match > -1) {
match = adjustPos(orig, line, match) + pos.ch;
return {from: Pos(pos.line, match), to: Pos(pos.line, match + origQuery.length)};
}
}
};
}
} else {
var origTarget = origQuery.split("\n");
this.matches = function(reverse, pos) {
var last = target.length - 1;
if (reverse) {
if (pos.line - (target.length - 1) < doc.firstLine()) return;
if (fold(doc.getLine(pos.line).slice(0, origTarget[last].length)) != target[target.length - 1]) return;
var to = Pos(pos.line, origTarget[last].length);
for (var ln = pos.line - 1, i = last - 1; i >= 1; --i, --ln)
if (target[i] != fold(doc.getLine(ln))) return;
var line = doc.getLine(ln), cut = line.length - origTarget[0].length;
if (fold(line.slice(cut)) != target[0]) return;
return {from: Pos(ln, cut), to: to};
} else {
if (pos.line + (target.length - 1) > doc.lastLine()) return;
var line = doc.getLine(pos.line), cut = line.length - origTarget[0].length;
if (fold(line.slice(cut)) != target[0]) return;
var from = Pos(pos.line, cut);
for (var ln = pos.line + 1, i = 1; i < last; ++i, ++ln)
if (target[i] != fold(doc.getLine(ln))) return;
if (doc.getLine(ln).slice(0, origTarget[last].length) != target[last]) return;
return {from: from, to: Pos(ln, origTarget[last].length)};
}
};
}
}
}
SearchCursor.prototype = {
findNext: function() {return this.find(false);},
findPrevious: function() {return this.find(true);},
find: function(reverse) {
var self = this, pos = this.doc.clipPos(reverse ? this.pos.from : this.pos.to);
function savePosAndFail(line) {
var pos = Pos(line, 0);
self.pos = {from: pos, to: pos};
self.atOccurrence = false;
return false;
}
for (;;) {
if (this.pos = this.matches(reverse, pos)) {
this.atOccurrence = true;
return this.pos.match || true;
}
if (reverse) {
if (!pos.line) return savePosAndFail(0);
pos = Pos(pos.line-1, this.doc.getLine(pos.line-1).length);
}
else {
var maxLine = this.doc.lineCount();
if (pos.line == maxLine - 1) return savePosAndFail(maxLine);
pos = Pos(pos.line + 1, 0);
}
}
},
from: function() {if (this.atOccurrence) return this.pos.from;},
to: function() {if (this.atOccurrence) return this.pos.to;},
replace: function(newText) {
if (!this.atOccurrence) return;
var lines = CodeMirror.splitLines(newText);
this.doc.replaceRange(lines, this.pos.from, this.pos.to);
this.pos.to = Pos(this.pos.from.line + lines.length - 1,
lines[lines.length - 1].length + (lines.length == 1 ? this.pos.from.ch : 0));
}
};
// Maps a position in a case-folded line back to a position in the original line
// (compensating for codepoints increasing in number during folding)
function adjustPos(orig, folded, pos) {
if (orig.length == folded.length) return pos;
for (var pos1 = Math.min(pos, orig.length);;) {
var len1 = orig.slice(0, pos1).toLowerCase().length;
if (len1 < pos) ++pos1;
else if (len1 > pos) --pos1;
else return pos1;
}
}
CodeMirror.defineExtension("getSearchCursor", function(query, pos, caseFold) {
return new SearchCursor(this.doc, query, pos, caseFold);
});
CodeMirror.defineDocExtension("getSearchCursor", function(query, pos, caseFold) {
return new SearchCursor(this, query, pos, caseFold);
});
})();
CodeMirror.defineMode("yaml", function() {
var cons = ['true', 'false', 'on', 'off', 'yes', 'no'];
var keywordRegex = new RegExp("\\b(("+cons.join(")|(")+"))$", 'i');
return {
token: function(stream, state) {
var ch = stream.peek();
var esc = state.escaped;
state.escaped = false;
/* comments */
if (ch == "#" && (stream.pos == 0 || /\s/.test(stream.string.charAt(stream.pos - 1)))) {
stream.skipToEnd(); return "comment";
}
if (state.literal && stream.indentation() > state.keyCol) {
stream.skipToEnd(); return "string";
} else if (state.literal) { state.literal = false; }
if (stream.sol()) {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
/* document start */
if(stream.match(/---/)) { return "def"; }
/* document end */
if (stream.match(/\.\.\./)) { return "def"; }
/* array list item */
if (stream.match(/\s*-\s+/)) { return 'meta'; }
}
/* inline pairs/lists */
if (stream.match(/^(\{|\}|\[|\])/)) {
if (ch == '{')
state.inlinePairs++;
else if (ch == '}')
state.inlinePairs--;
else if (ch == '[')
state.inlineList++;
else
state.inlineList--;
return 'meta';
}
/* list seperator */
if (state.inlineList > 0 && !esc && ch == ',') {
stream.next();
return 'meta';
}
/* pairs seperator */
if (state.inlinePairs > 0 && !esc && ch == ',') {
state.keyCol = 0;
state.pair = false;
state.pairStart = false;
stream.next();
return 'meta';
}
/* start of value of a pair */
if (state.pairStart) {
/* block literals */
if (stream.match(/^\s*(\||\>)\s*/)) { state.literal = true; return 'meta'; };
/* references */
if (stream.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i)) { return 'variable-2'; }
/* numbers */
if (state.inlinePairs == 0 && stream.match(/^\s*-?[0-9\.\,]+\s?$/)) { return 'number'; }
if (state.inlinePairs > 0 && stream.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/)) { return 'number'; }
/* keywords */
if (stream.match(keywordRegex)) { return 'keyword'; }
}
/* pairs (associative arrays) -> key */
if (!state.pair && stream.match(/^\s*\S+(?=\s*:($|\s))/i)) {
state.pair = true;
state.keyCol = stream.indentation();
return "atom";
}
if (state.pair && stream.match(/^:\s*/)) { state.pairStart = true; return 'meta'; }
/* nothing found, continue */
state.pairStart = false;
state.escaped = (ch == '\\');
stream.next();
return null;
},
startState: function() {
return {
pair: false,
pairStart: false,
keyCol: 0,
inlinePairs: 0,
inlineList: 0,
literal: false,
escaped: false
};
}
};
});
CodeMirror.defineMIME("text/x-yaml", "yaml");
/* BASICS */
.CodeMirror {
/* Set height, width, borders, and global font properties here */
font-family: monospace;
height: 300px;
}
.CodeMirror-scroll {
/* Set scrolling behaviour here */
overflow: auto;
}
/* PADDING */
.CodeMirror-lines {
padding: 4px 0; /* Vertical padding around content */
}
.CodeMirror pre {
padding: 0 4px; /* Horizontal padding of content */
}
.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
background-color: white; /* The little square between H and V scrollbars */
}
/* GUTTER */
.CodeMirror-gutters {
border-right: 1px solid #ddd;
background-color: #f7f7f7;
white-space: nowrap;
}
.CodeMirror-linenumbers {}
.CodeMirror-linenumber {
padding: 0 3px 0 5px;
min-width: 20px;
text-align: right;
color: #999;
}
/* CURSOR */
.CodeMirror div.CodeMirror-cursor {
border-left: 1px solid black;
z-index: 3;
}
/* Shown when moving in bi-directional text */
.CodeMirror div.CodeMirror-secondarycursor {
border-left: 1px solid silver;
}
.CodeMirror.cm-keymap-fat-cursor div.CodeMirror-cursor {
width: auto;
border: 0;
background: #7e7;
z-index: 1;
}
/* Can style cursor different in overwrite (non-insert) mode */
.CodeMirror div.CodeMirror-cursor.CodeMirror-overwrite {}
.cm-tab { display: inline-block; }
/* DEFAULT THEME */
.cm-s-default .cm-keyword {color: #708;}
.cm-s-default .cm-atom {color: #219;}
.cm-s-default .cm-number {color: #164;}
.cm-s-default .cm-def {color: #00f;}
.cm-s-default .cm-variable {color: black;}
.cm-s-default .cm-variable-2 {color: #05a;}
.cm-s-default .cm-variable-3 {color: #085;}
.cm-s-default .cm-property {color: black;}
.cm-s-default .cm-operator {color: black;}
.cm-s-default .cm-comment {color: #a50;}
.cm-s-default .cm-string {color: #a11;}
.cm-s-default .cm-string-2 {color: #f50;}
.cm-s-default .cm-meta {color: #555;}
.cm-s-default .cm-qualifier {color: #555;}
.cm-s-default .cm-builtin {color: #30a;}
.cm-s-default .cm-bracket {color: #997;}
.cm-s-default .cm-tag {color: #170;}
.cm-s-default .cm-attribute {color: #00c;}
.cm-s-default .cm-header {color: blue;}
.cm-s-default .cm-quote {color: #090;}
.cm-s-default .cm-hr {color: #999;}
.cm-s-default .cm-link {color: #00c;}
.cm-negative {color: #d44;}
.cm-positive {color: #292;}
.cm-header, .cm-strong {font-weight: bold;}
.cm-em {font-style: italic;}
.cm-link {text-decoration: underline;}
.cm-s-default .cm-error {color: #f00;}
.cm-invalidchar {color: #f00;}
div.CodeMirror span.CodeMirror-matchingbracket {color: #0f0;}
div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
.CodeMirror-activeline-background {background: #e8f2ff;}
/* STOP */
/* The rest of this file contains styles related to the mechanics of
the editor. You probably shouldn't touch them. */
.CodeMirror {
line-height: 1;
position: relative;
overflow: hidden;
background: white;
color: black;
}
.CodeMirror-scroll {
/* 30px is the magic margin used to hide the element's real scrollbars */
/* See overflow: hidden in .CodeMirror */
margin-bottom: -30px; margin-right: -30px;
padding-bottom: 30px; padding-right: 30px;
height: 100%;
outline: none; /* Prevent dragging from highlighting the element */
position: relative;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
.CodeMirror-sizer {
position: relative;
}
/* The fake, visible scrollbars. Used to force redraw during scrolling
before actuall scrolling happens, thus preventing shaking and
flickering artifacts. */
.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler {
position: absolute;
z-index: 6;
display: none;
}
.CodeMirror-vscrollbar {
right: 0; top: 0;
overflow-x: hidden;
overflow-y: scroll;
}
.CodeMirror-hscrollbar {
bottom: 0; left: 0;
overflow-y: hidden;
overflow-x: scroll;
}
.CodeMirror-scrollbar-filler {
right: 0; bottom: 0;
}
.CodeMirror-gutter-filler {
left: 0; bottom: 0;
}
.CodeMirror-gutters {
position: absolute; left: 0; top: 0;
padding-bottom: 30px;
z-index: 3;
}
.CodeMirror-gutter {
white-space: normal;
height: 100%;
-moz-box-sizing: content-box;
box-sizing: content-box;
padding-bottom: 30px;
margin-bottom: -32px;
display: inline-block;
/* Hack to make IE7 behave */
*zoom:1;
*display:inline;
}
.CodeMirror-gutter-elt {
position: absolute;
cursor: default;
z-index: 4;
}
.CodeMirror-lines {
cursor: text;
}
.CodeMirror pre {
/* Reset some styles that the rest of the page might have set */
-moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
border-width: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre;
word-wrap: normal;
line-height: inherit;
color: inherit;
z-index: 2;
position: relative;
overflow: visible;
}
.CodeMirror-wrap pre {
word-wrap: break-word;
white-space: pre-wrap;
word-break: normal;
}
.CodeMirror-code pre {
border-right: 30px solid transparent;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.CodeMirror-wrap .CodeMirror-code pre {
border-right: none;
width: auto;
}
.CodeMirror-linebackground {
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 0;
}
.CodeMirror-linewidget {
position: relative;
z-index: 2;
overflow: auto;
}
.CodeMirror-widget {}
.CodeMirror-wrap .CodeMirror-scroll {
overflow-x: hidden;
}
.CodeMirror-measure {
position: absolute;
width: 100%;
height: 0;
overflow: hidden;
visibility: hidden;
}
.CodeMirror-measure pre { position: static; }
.CodeMirror div.CodeMirror-cursor {
position: absolute;
visibility: hidden;
border-right: none;
width: 0;
}
.CodeMirror-focused div.CodeMirror-cursor {
visibility: visible;
}
.CodeMirror-selected { background: #d9d9d9; }
.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; }
.cm-searching {
background: #ffa;
background: rgba(255, 255, 0, .4);
}
/* IE7 hack to prevent it from returning funny offsetTops on the spans */
.CodeMirror span { *vertical-align: text-bottom; }
@media print {
/* Hide the cursor when printing */
.CodeMirror div.CodeMirror-cursor {
visibility: hidden;
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -224,9 +224,6 @@ class CourseFixture(StudioApiFixture): ...@@ -224,9 +224,6 @@ class CourseFixture(StudioApiFixture):
""" """
self._create_course() self._create_course()
# Remove once STUD-1248 is resolved
self._update_loc_map()
self._install_course_updates() self._install_course_updates()
self._install_course_handouts() self._install_course_handouts()
self._configure_course() self._configure_course()
...@@ -362,20 +359,6 @@ class CourseFixture(StudioApiFixture): ...@@ -362,20 +359,6 @@ class CourseFixture(StudioApiFixture):
"Could not add update to course: {0}. Status was {1}".format( "Could not add update to course: {0}. Status was {1}".format(
update, response.status_code)) update, response.status_code))
def _update_loc_map(self):
"""
Force update of the location map.
"""
# We perform a GET request to force Studio to update the course location map.
# This is a (minor) bug in the Studio RESTful API: STUD-1248
url = "{base}/course_info/{course}".format(base=STUDIO_BASE_URL, course=self._course_loc)
response = self.session.get(url, headers={'Accept': 'text/html'})
if not response.ok:
raise CourseFixtureError(
"Could not load Studio dashboard to trigger location map update. Status was {0}".format(
response.status_code))
def _create_xblock_children(self, parent_loc, xblock_descriptions): def _create_xblock_children(self, parent_loc, xblock_descriptions):
""" """
Recursively create XBlock children. Recursively create XBlock children.
......
...@@ -72,9 +72,28 @@ class DiscussionSingleThreadPage(CoursePage): ...@@ -72,9 +72,28 @@ class DiscussionSingleThreadPage(CoursePage):
self.css_map(selector, lambda el: el.visible)[0] self.css_map(selector, lambda el: el.visible)[0]
) )
def is_response_editor_visible(self, response_id):
"""Returns true if the response editor is present, false otherwise"""
return self._is_element_visible(".response_{} .edit-post-body".format(response_id))
def start_response_edit(self, response_id):
"""Click the edit button for the response, loading the editing view"""
self.css_click(".response_{} .discussion-response .action-edit".format(response_id))
fulfill(EmptyPromise(
lambda: self.is_response_editor_visible(response_id),
"Response edit started"
))
def is_add_comment_visible(self, response_id):
"""Returns true if the "add comment" form is visible for a response"""
return self._is_element_visible(".response_{} .new-comment".format(response_id))
def is_comment_visible(self, comment_id): def is_comment_visible(self, comment_id):
"""Returns true if the comment is viewable onscreen""" """Returns true if the comment is viewable onscreen"""
return self._is_element_visible("#comment_{}".format(comment_id)) return self._is_element_visible("#comment_{} .response-body".format(comment_id))
def get_comment_body(self, comment_id):
return self._get_element_text("#comment_{} .response-body".format(comment_id))
def is_comment_deletable(self, comment_id): def is_comment_deletable(self, comment_id):
"""Returns true if the delete comment button is present, false otherwise""" """Returns true if the delete comment button is present, false otherwise"""
...@@ -87,3 +106,56 @@ class DiscussionSingleThreadPage(CoursePage): ...@@ -87,3 +106,56 @@ class DiscussionSingleThreadPage(CoursePage):
lambda: not self.is_comment_visible(comment_id), lambda: not self.is_comment_visible(comment_id),
"Deleted comment was removed" "Deleted comment was removed"
)) ))
def is_comment_editable(self, comment_id):
"""Returns true if the edit comment button is present, false otherwise"""
return self._is_element_visible("#comment_{} .action-edit".format(comment_id))
def is_comment_editor_visible(self, comment_id):
"""Returns true if the comment editor is present, false otherwise"""
return self._is_element_visible("#comment_{} .edit-comment-body".format(comment_id))
def _get_comment_editor_value(self, comment_id):
return self.css_value("#comment_{} .wmd-input".format(comment_id))[0]
def start_comment_edit(self, comment_id):
"""Click the edit button for the comment, loading the editing view"""
old_body = self.get_comment_body(comment_id)
self.css_click("#comment_{} .action-edit".format(comment_id))
fulfill(EmptyPromise(
lambda: (
self.is_comment_editor_visible(comment_id) and
not self.is_comment_visible(comment_id) and
self._get_comment_editor_value(comment_id) == old_body
),
"Comment edit started"
))
def set_comment_editor_value(self, comment_id, new_body):
"""Replace the contents of the comment editor"""
self.css_fill("#comment_{} .wmd-input".format(comment_id), new_body)
def submit_comment_edit(self, comment_id):
"""Click the submit button on the comment editor"""
new_body = self._get_comment_editor_value(comment_id)
self.css_click("#comment_{} .post-update".format(comment_id))
fulfill(EmptyPromise(
lambda: (
not self.is_comment_editor_visible(comment_id) and
self.is_comment_visible(comment_id) and
self.get_comment_body(comment_id) == new_body
),
"Comment edit succeeded"
))
def cancel_comment_edit(self, comment_id, original_body):
"""Click the cancel button on the comment editor"""
self.css_click("#comment_{} .post-cancel".format(comment_id))
fulfill(EmptyPromise(
lambda: (
not self.is_comment_editor_visible(comment_id) and
self.is_comment_visible(comment_id) and
self.get_comment_body(comment_id) == original_body
),
"Comment edit was canceled"
))
...@@ -135,3 +135,91 @@ class DiscussionCommentDeletionTest(UniqueCourseTest): ...@@ -135,3 +135,91 @@ class DiscussionCommentDeletionTest(UniqueCourseTest):
self.assertTrue(page.is_comment_deletable("comment_other_author")) self.assertTrue(page.is_comment_deletable("comment_other_author"))
page.delete_comment("comment_self_author") page.delete_comment("comment_self_author")
page.delete_comment("comment_other_author") page.delete_comment("comment_other_author")
class DiscussionCommentEditTest(UniqueCourseTest):
"""
Tests for editing comments displayed beneath responses in the single thread view.
"""
def setUp(self):
super(DiscussionCommentEditTest, self).setUp()
# Create a course to register for
CourseFixture(**self.course_info).install()
def setup_user(self, roles=[]):
roles_str = ','.join(roles)
self.user_id = AutoAuthPage(self.browser, course_id=self.course_id, roles=roles_str).visit().get_user_id()
def setup_view(self):
view = SingleThreadViewFixture(Thread(id="comment_edit_test_thread"))
view.addResponse(
Response(id="response1"),
[Comment(id="comment_other_author", user_id="other"), Comment(id="comment_self_author", user_id=self.user_id)])
view.push()
def edit_comment(self, page, comment_id):
page.start_comment_edit(comment_id)
page.set_comment_editor_value(comment_id, "edited body")
page.submit_comment_edit(comment_id)
def test_edit_comment_as_student(self):
self.setup_user()
self.setup_view()
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
page.visit()
self.assertTrue(page.is_comment_editable("comment_self_author"))
self.assertTrue(page.is_comment_visible("comment_other_author"))
self.assertFalse(page.is_comment_editable("comment_other_author"))
self.edit_comment(page, "comment_self_author")
def test_edit_comment_as_moderator(self):
self.setup_user(roles=["Moderator"])
self.setup_view()
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
page.visit()
self.assertTrue(page.is_comment_editable("comment_self_author"))
self.assertTrue(page.is_comment_editable("comment_other_author"))
self.edit_comment(page, "comment_self_author")
self.edit_comment(page, "comment_other_author")
def test_cancel_comment_edit(self):
self.setup_user()
self.setup_view()
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
page.visit()
self.assertTrue(page.is_comment_editable("comment_self_author"))
original_body = page.get_comment_body("comment_self_author")
page.start_comment_edit("comment_self_author")
page.set_comment_editor_value("comment_self_author", "edited body")
page.cancel_comment_edit("comment_self_author", original_body)
def test_editor_visibility(self):
"""Only one editor should be visible at a time within a single response"""
self.setup_user(roles=["Moderator"])
self.setup_view()
page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_edit_test_thread")
page.visit()
self.assertTrue(page.is_comment_editable("comment_self_author"))
self.assertTrue(page.is_comment_editable("comment_other_author"))
self.assertTrue(page.is_add_comment_visible("response1"))
original_body = page.get_comment_body("comment_self_author")
page.start_comment_edit("comment_self_author")
self.assertFalse(page.is_add_comment_visible("response1"))
self.assertTrue(page.is_comment_editor_visible("comment_self_author"))
page.set_comment_editor_value("comment_self_author", "edited body")
page.start_comment_edit("comment_other_author")
self.assertFalse(page.is_comment_editor_visible("comment_self_author"))
self.assertTrue(page.is_comment_editor_visible("comment_other_author"))
self.assertEqual(page.get_comment_body("comment_self_author"), original_body)
page.start_response_edit("response1")
self.assertFalse(page.is_comment_editor_visible("comment_other_author"))
self.assertTrue(page.is_response_editor_visible("response1"))
original_body = page.get_comment_body("comment_self_author")
page.start_comment_edit("comment_self_author")
self.assertFalse(page.is_response_editor_visible("response1"))
self.assertTrue(page.is_comment_editor_visible("comment_self_author"))
page.cancel_comment_edit("comment_self_author", original_body)
self.assertFalse(page.is_comment_editor_visible("comment_self_author"))
self.assertTrue(page.is_add_comment_visible("response1"))
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment