Commit 9feff628 by Calen Pennington

Merge pull request #3834 from cpennington/ok-merge-from-master

Merge master into opaque-keys
parents a0799f38 21ddc7db
...@@ -146,3 +146,4 @@ Ben Weeks <benweeks@mit.edu> ...@@ -146,3 +146,4 @@ Ben Weeks <benweeks@mit.edu>
David Bodor <david.gabor.bodor@gmail.com> David Bodor <david.gabor.bodor@gmail.com>
Sébastien Hinderer <Sebastien.Hinderer@inria.fr> Sébastien Hinderer <Sebastien.Hinderer@inria.fr>
Kristin Stephens <ksteph@cs.berkeley.edu> Kristin Stephens <ksteph@cs.berkeley.edu>
Ben Patterson <bpatterson@edx.org>
...@@ -5,10 +5,18 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,10 +5,18 @@ 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: Fix displaying transcripts on touch devices. BLD-1033.
Blades: Tolerance expressed in percentage now computes correctly. BLD-522.
Studio: Support add, delete and duplicate on the container page. STUD-1490.
Studio: Add drag-and-drop support to the container page. STUD-1309. Studio: Add drag-and-drop support to the container page. STUD-1309.
Common: Add extensible third-party auth module. Common: Add extensible third-party auth module.
Blades: Added new error message that displays when HTML5 video is not supported altogether. Make sure spinner gets hidden when error message is shown. BLD-638.
LMS: Switch default instructor dashboard to the new (formerly "beta") LMS: Switch default instructor dashboard to the new (formerly "beta")
instructor dashboard. Puts the old (now "legacy") dash behind a feature flag. instructor dashboard. Puts the old (now "legacy") dash behind a feature flag.
LMS-1296 LMS-1296
...@@ -16,7 +24,8 @@ LMS: Switch default instructor dashboard to the new (formerly "beta") ...@@ -16,7 +24,8 @@ LMS: Switch default instructor dashboard to the new (formerly "beta")
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
problem after Run Code button press. BLD-994. problem after Run Code button press. BLD-994.
Blades: Set initial video quality to large instead of default to avoid automatic switch to HD when iframe resizes. BLD-981. Blades: Set initial video quality to large instead of default to avoid automatic
switch to HD when iframe resizes. BLD-981.
Blades: Add an upload button for authors to provide students with an option to Blades: Add an upload button for authors to provide students with an option to
download a handout associated with a video (of arbitrary file format). BLD-1000. download a handout associated with a video (of arbitrary file format). BLD-1000.
......
...@@ -204,8 +204,9 @@ def add_subsection(name='Subsection One'): ...@@ -204,8 +204,9 @@ def add_subsection(name='Subsection One'):
def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None): def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None):
set_element_value(date_css, desired_date, key) set_element_value(date_css, desired_date, key)
set_element_value(time_css, desired_time, key) world.wait_for_ajax_complete()
set_element_value(time_css, desired_time, key)
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
......
...@@ -10,10 +10,11 @@ Feature: Course export ...@@ -10,10 +10,11 @@ Feature: Course export
Then I get an error dialog Then I get an error dialog
And I can click to go to the unit with the error And I can click to go to the unit with the error
Scenario: User is directed to problem with & in it when export fails # Disabling due to failure on master. 05/21/2014 TODO: fix
Given I am in Studio editing a new unit # Scenario: User is directed to problem with & in it when export fails
When I add a "Blank Advanced Problem" "Advanced Problem" component # Given I am in Studio editing a new unit
And I edit and enter an ampersand # When I add a "Blank Advanced Problem" "Advanced Problem" component
And I export the course # And I edit and enter an ampersand
Then I get an error dialog # And I export the course
And I can click to go to the unit with the error # Then I get an error dialog
# And I can click to go to the unit with the error
...@@ -105,26 +105,28 @@ Feature: CMS.HTML Editor ...@@ -105,26 +105,28 @@ Feature: CMS.HTML Editor
<li>zzzz<ol> <li>zzzz<ol>
""" """
Scenario: Can switch from Visual Editor to Raw # Skipping in master due to brittleness JZ 05/22/2014
Given I have created a Blank HTML Page # Scenario: Can switch from Visual Editor to Raw
When I edit the component and select the Raw Editor # Given I have created a Blank HTML Page
And I save the page # When I edit the component and select the Raw Editor
When I edit the page # And I save the page
And type "fancy html" into the Raw Editor # When I edit the page
And I save the page # And type "fancy html" into the Raw Editor
Then the page text contains: # And I save the page
""" # Then the page text contains:
fancy html # """
""" # fancy html
# """
Scenario: Can switch from Raw Editor to Visual # Skipping in master due to brittleness JZ 05/22/2014
Given I have created a raw HTML component # Scenario: Can switch from Raw Editor to Visual
And I edit the component and select the Visual Editor # Given I have created a raw HTML component
And I save the page # And I edit the component and select the Visual Editor
When I edit the page # And I save the page
And type "less fancy html" in the code editor and press OK # When I edit the page
And I save the page # And type "less fancy html" in the code editor and press OK
Then the page text contains: # And I save the page
""" # Then the page text contains:
less fancy html # """
""" # less fancy html
# """
...@@ -38,14 +38,16 @@ Feature: CMS.Create Subsection ...@@ -38,14 +38,16 @@ Feature: CMS.Create Subsection
Then I see the subsection release date is 12/25/2011 03:00 Then I see the subsection release date is 12/25/2011 03:00
And I see the subsection due date is 01/02/2012 04:00 And I see the subsection due date is 01/02/2012 04:00
# Disabling due to failure on master. JZ 05/14/2014 TODO: fix @skip_safari
# Scenario: Set release and due dates of subsection on enter Scenario: Set release and due dates of subsection on enter
# Given I have opened a new subsection in Studio Given I have opened a new subsection in Studio
# And I set the subsection release date on enter to 04/04/2014 03:00 And I set the subsection release date on enter to 04/04/2014 03:00
# And I set the subsection due date on enter to 04/04/2014 04:00 And I set the subsection due date on enter to 04/04/2014 04:00
# And I reload the page Then I see the subsection release date is 04/04/2014 03:00
# Then I see the subsection release date is 04/04/2014 03:00 And I see the subsection due date is 04/04/2014 04:00
# And I see the subsection due date is 04/04/2014 04:00 And I reload the page
Then I see the subsection release date is 04/04/2014 03:00
And I see the subsection due date is 04/04/2014 04:00
Scenario: Delete a subsection Scenario: Delete a subsection
Given I have opened a new course section in Studio Given I have opened a new course section in Studio
...@@ -56,16 +58,18 @@ Feature: CMS.Create Subsection ...@@ -56,16 +58,18 @@ Feature: CMS.Create Subsection
And I confirm the prompt And I confirm the prompt
Then the subsection does not exist Then the subsection does not exist
# Disabling due to failure on master. JZ 05/14/2014 TODO: fix @skip_safari
# Scenario: Sync to Section Scenario: Sync to Section
# Given I have opened a new course section in Studio Given I have opened a new course section in Studio
# And I click the Edit link for the release date And I click the Edit link for the release date
# And I set the section release date to 01/02/2103 And I set the section release date to 01/02/2103
# And I have added a new subsection And I have added a new subsection
# And I click on the subsection And I click on the subsection
# And I set the subsection release date to 01/20/2103 And I set the subsection release date to 06/20/2104
# And I reload the page Then I see the subsection release date is 06/20/2104
# And I click the link to sync release date to section And I reload the page
# And I wait for "1" second Then I see the subsection release date is 06/20/2104
# And I reload the page And I click the link to sync release date to section
# Then I see the subsection release date is 01/02/2103 And I wait for "1" second
And I reload the page
Then I see the subsection release date is 01/02/2103
...@@ -64,19 +64,17 @@ def set_subsection_release_date_on_enter(_step, datestring, timestring): # pyli ...@@ -64,19 +64,17 @@ def set_subsection_release_date_on_enter(_step, datestring, timestring): # pyli
@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?') @step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
def set_subsection_due_date(_step, datestring, timestring): def set_subsection_due_date(_step, datestring, timestring, key=None):
if not world.css_visible('input#due_date'): if not world.css_visible('input#due_date'):
world.css_click('.due-date-input .set-date') world.css_click('.due-date-input .set-date')
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring) assert world.css_visible('input#due_date')
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, key)
@step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?') @step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?')
def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
if not world.css_visible('input#due_date'): set_subsection_due_date(_step, datestring, timestring, 'ENTER')
world.css_click('.due-date-input .set-date')
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, 'ENTER')
@step('I mark it as Homework$') @step('I mark it as Homework$')
......
...@@ -6,9 +6,12 @@ Feature: CMS.Upload Files ...@@ -6,9 +6,12 @@ Feature: CMS.Upload Files
@skip_safari @skip_safari
Scenario: Users can upload files Scenario: Users can upload files
Given I am at the files and upload page of a Studio course Given I am at the files and upload page of a Studio course
When I upload the file "test" When I upload the file "test" by clicking "Upload your first asset"
Then I should see the file "test" was uploaded Then I should see the file "test" was uploaded
And The url for the file "test" is valid And The url for the file "test" is valid
When I upload the file "test2"
Then I should see the file "test2" was uploaded
And The url for the file "test2" is valid
# Uploading isn't working on safari with sauce labs # Uploading isn't working on safari with sauce labs
@skip_safari @skip_safari
......
...@@ -25,8 +25,11 @@ def go_to_uploads(_step): ...@@ -25,8 +25,11 @@ def go_to_uploads(_step):
@step(u'I upload the( test)? file "([^"]*)"$') @step(u'I upload the( test)? file "([^"]*)"$')
def upload_file(_step, is_test_file, file_name): def upload_file(_step, is_test_file, file_name, button_text=None):
world.click_link('Upload New File') if button_text:
world.click_link(button_text)
else:
world.click_link('Upload New File')
if not is_test_file: if not is_test_file:
_write_test_file(file_name, "test file") _write_test_file(file_name, "test file")
...@@ -39,6 +42,11 @@ def upload_file(_step, is_test_file, file_name): ...@@ -39,6 +42,11 @@ def upload_file(_step, is_test_file, file_name):
world.css_click(close_css) world.css_click(close_css)
@step(u'I upload the file "([^"]*)" by clicking "([^"]*)"')
def upload_file_on_button_press(_step, file_name, button_text=None):
upload_file(_step, '', file_name, button_text)
@step(u'I upload the files "([^"]*)"$') @step(u'I upload the files "([^"]*)"$')
def upload_files(_step, files_string): def upload_files(_step, files_string):
# files_string should be comma separated with no spaces. # files_string should be comma separated with no spaces.
......
...@@ -16,6 +16,8 @@ from contentstore.utils import get_modulestore, reverse_course_url ...@@ -16,6 +16,8 @@ from contentstore.utils import get_modulestore, reverse_course_url
from .access import has_course_access from .access import has_course_access
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from django.utils.translation import ugettext
__all__ = ['checklists_handler'] __all__ = ['checklists_handler']
...@@ -76,7 +78,7 @@ def checklists_handler(request, course_key_string, checklist_index=None): ...@@ -76,7 +78,7 @@ def checklists_handler(request, course_key_string, checklist_index=None):
course_module.save() course_module.save()
get_modulestore(course_module.location).update_item(course_module, request.user.id) get_modulestore(course_module.location).update_item(course_module, request.user.id)
expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist) expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist)
return JsonResponse(expanded_checklist) return JsonResponse(localize_checklist_text(expanded_checklist))
else: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
("Could not save checklist state because the checklist index " ("Could not save checklist state because the checklist index "
...@@ -96,7 +98,7 @@ def expand_all_action_urls(course_module): ...@@ -96,7 +98,7 @@ def expand_all_action_urls(course_module):
""" """
expanded_checklists = [] expanded_checklists = []
for checklist in course_module.checklists: for checklist in course_module.checklists:
expanded_checklists.append(expand_checklist_action_url(course_module, checklist)) expanded_checklists.append(localize_checklist_text(expand_checklist_action_url(course_module, checklist)))
return expanded_checklists return expanded_checklists
...@@ -121,3 +123,20 @@ def expand_checklist_action_url(course_module, checklist): ...@@ -121,3 +123,20 @@ def expand_checklist_action_url(course_module, checklist):
item['action_url'] = reverse_course_url(urlconf_map[action_url], course_module.id) item['action_url'] = reverse_course_url(urlconf_map[action_url], course_module.id)
return expanded_checklist return expanded_checklist
def localize_checklist_text(checklist):
"""
Localize texts for a given checklist and returns the modified version.
The method does an in-place operation so the input checklist is modified directly.
"""
# Localize checklist name
checklist['short_description'] = ugettext(checklist['short_description'])
# Localize checklist items
for item in checklist.get('items'):
item['short_description'] = ugettext(item['short_description'])
item['long_description'] = ugettext(item['long_description'])
item['action_text'] = ugettext(item['action_text']) if item['action_text'] != "" else u""
return checklist
...@@ -157,70 +157,7 @@ def unit_handler(request, usage_key_string): ...@@ -157,70 +157,7 @@ def unit_handler(request, usage_key_string):
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
component_templates = defaultdict(list) component_templates = _get_component_templates(course)
for category in COMPONENT_TYPES:
component_class = _load_mixed_class(category)
# add the default template
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
if hasattr(component_class, 'display_name'):
display_name = component_class.display_name.default or 'Blank'
else:
display_name = 'Blank'
component_templates[category].append((
display_name,
category,
False, # No defaults have markdown (hardcoded current default)
None # no boilerplate for overrides
))
# add boilerplates
if hasattr(component_class, 'templates'):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, course):
component_templates[category].append((
template['metadata'].get('display_name'),
category,
template['metadata'].get('markdown') is not None,
template.get('template_id')
))
# Check if there are any advanced modules specified in the course policy.
# These modules should be specified as a list of strings, where the strings
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course.
course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
if isinstance(course_advanced_keys, list):
for category in course_advanced_keys:
if category in ADVANCED_COMPONENT_TYPES:
# Do I need to allow for boilerplates or just defaults on the
# class? i.e., can an advanced have more than one entry in the
# menu? one for default and others for prefilled boilerplates?
try:
component_class = _load_mixed_class(category)
component_templates['advanced'].append(
(
component_class.display_name.default or category,
category,
False,
None # don't override default data
)
)
except PluginMissingError:
# dhm: I got this once but it can happen any time the
# course author configures an advanced component which does
# not exist on the server. This code here merely
# prevents any authors from trying to instantiate the
# non-existent component type by not showing it in the menu
pass
else:
log.error(
"Improper format for course advanced keys! %s",
course_advanced_keys
)
xblocks = item.get_children() xblocks = item.get_children()
...@@ -259,9 +196,9 @@ def unit_handler(request, usage_key_string): ...@@ -259,9 +196,9 @@ def unit_handler(request, usage_key_string):
return render_to_response('unit.html', { return render_to_response('unit.html', {
'context_course': course, 'context_course': course,
'unit': item, 'unit': item,
'unit_locator': usage_key, 'unit_usage_key': usage_key,
'xblocks': xblocks, 'child_usage_keys': [block.scope_ids.usage_id for block in xblocks],
'component_templates': component_templates, 'component_templates': json.dumps(component_templates),
'draft_preview_link': preview_lms_link, 'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link, 'published_preview_link': lms_link,
'subsection': containing_subsection, 'subsection': containing_subsection,
...@@ -293,14 +230,14 @@ def container_handler(request, usage_key_string): ...@@ -293,14 +230,14 @@ def container_handler(request, usage_key_string):
json: not currently supported json: not currently supported
""" """
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
usage_key = UsageKey.from_string(usage_key_string) usage_key = UsageKey.from_string(usage_key_string)
if not has_course_access(request.user, usage_key.course_key):
raise PermissionDenied()
try: try:
xblock = get_modulestore(usage_key).get_item(usage_key) course, xblock, __ = _get_item_in_course(request, usage_key)
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
component_templates = _get_component_templates(course)
ancestor_xblocks = [] ancestor_xblocks = []
parent = get_parent_xblock(xblock) parent = get_parent_xblock(xblock)
while parent and parent.category != 'sequential': while parent and parent.category != 'sequential':
...@@ -317,11 +254,106 @@ def container_handler(request, usage_key_string): ...@@ -317,11 +254,106 @@ def container_handler(request, usage_key_string):
'xblock_locator': usage_key, 'xblock_locator': usage_key,
'unit': None if not ancestor_xblocks else ancestor_xblocks[0], 'unit': None if not ancestor_xblocks else ancestor_xblocks[0],
'ancestor_xblocks': ancestor_xblocks, 'ancestor_xblocks': ancestor_xblocks,
'component_templates': json.dumps(component_templates),
}) })
else: else:
return HttpResponseBadRequest("Only supports html requests") return HttpResponseBadRequest("Only supports html requests")
def _get_component_templates(course):
"""
Returns the applicable component templates that can be used by the specified course.
"""
def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
"""
Creates a component template dict.
Parameters
display_name: the user-visible name of the component
category: the type of component (problem, html, etc.)
boilerplate_name: name of boilerplate for filling in default values. May be None.
is_common: True if "common" problem, False if "advanced". May be None, as it is only used for problems.
"""
return {
"display_name": name,
"category": cat,
"boilerplate_name": boilerplate_name,
"is_common": is_common
}
component_templates = []
# The component_templates array is in the order of "advanced" (if present), followed
# by the components in the order listed in COMPONENT_TYPES.
for category in COMPONENT_TYPES:
templates_for_category = []
component_class = _load_mixed_class(category)
# add the default template
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
if hasattr(component_class, 'display_name'):
display_name = component_class.display_name.default or 'Blank'
else:
display_name = 'Blank'
templates_for_category.append(create_template_dict(display_name, category))
# add boilerplates
if hasattr(component_class, 'templates'):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, course):
templates_for_category.append(
create_template_dict(
template['metadata'].get('display_name'),
category,
template.get('template_id'),
template['metadata'].get('markdown') is not None
)
)
component_templates.append({"type": category, "templates": templates_for_category})
# Check if there are any advanced modules specified in the course policy.
# These modules should be specified as a list of strings, where the strings
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course.
course_advanced_keys = course.advanced_modules
advanced_component_templates = {"type": "advanced", "templates": []}
# Set component types according to course policy file
if isinstance(course_advanced_keys, list):
for category in course_advanced_keys:
if category in ADVANCED_COMPONENT_TYPES:
# boilerplates not supported for advanced components
try:
component_class = _load_mixed_class(category)
advanced_component_templates['templates'].append(
create_template_dict(
component_class.display_name.default or category,
category
)
)
except PluginMissingError:
# dhm: I got this once but it can happen any time the
# course author configures an advanced component which does
# not exist on the server. This code here merely
# prevents any authors from trying to instantiate the
# non-existent component type by not showing it in the menu
log.warning(
"Advanced component %s does not exist. It will not be added to the Studio new component menu.",
category
)
pass
else:
log.error(
"Improper format for course advanced keys! %s",
course_advanced_keys
)
if len(advanced_component_templates['templates']) > 0:
component_templates.insert(0, advanced_component_templates)
return component_templates
@login_required @login_required
def _get_item_in_course(request, usage_key): def _get_item_in_course(request, usage_key):
""" """
......
...@@ -9,7 +9,9 @@ from contentstore.utils import reverse_course_url, reverse_usage_url ...@@ -9,7 +9,9 @@ from contentstore.utils import reverse_course_url, reverse_usage_url
__all__ = ['edge', 'event', 'landing'] __all__ = ['edge', 'event', 'landing']
EDITING_TEMPLATES = [ EDITING_TEMPLATES = [
"basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", "image-modal" "basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem"
] ]
# points to the temporary course landing page with log in and sign up # points to the temporary course landing page with log in and sign up
...@@ -37,11 +39,20 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'): ...@@ -37,11 +39,20 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'):
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace) return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
def _xmodule_recurse(item, action): def _xmodule_recurse(item, action, ignore_exception=()):
"""
Recursively apply provided action on item and its children
ignore_exception (Exception Object): A optional argument; when passed ignores the corresponding
exception raised during xmodule recursion,
"""
for child in item.get_children(): for child in item.get_children():
_xmodule_recurse(child, action) _xmodule_recurse(child, action, ignore_exception)
action(item) try:
return action(item)
except ignore_exception:
return
def get_parent_xblock(xblock): def get_parent_xblock(xblock):
...@@ -58,40 +69,54 @@ def get_parent_xblock(xblock): ...@@ -58,40 +69,54 @@ def get_parent_xblock(xblock):
return modulestore().get_item(parent_locations[0]) return modulestore().get_item(parent_locations[0])
def _xblock_has_studio_page(xblock): def is_unit(xblock):
"""
Returns true if the specified xblock is a vertical that is treated as a unit.
A unit is a vertical that is a direct child of a sequential (aka a subsection).
"""
if xblock.category == 'vertical':
parent_xblock = get_parent_xblock(xblock)
parent_category = parent_xblock.category if parent_xblock else None
return parent_category == 'sequential'
return False
def xblock_has_own_studio_page(xblock):
""" """
Returns true if the specified xblock has an associated Studio page. Most xblocks do Returns true if the specified xblock has an associated Studio page. Most xblocks do
not have their own page but are instead shown on the page of their parent. There not have their own page but are instead shown on the page of their parent. There
are a few exceptions: are a few exceptions:
1. Courses 1. Courses
2. Verticals 2. Verticals that are either:
- themselves treated as units (in which case they are shown on a unit page)
- a direct child of a unit (in which case they are shown on a container page)
3. XBlocks with children, except for: 3. XBlocks with children, except for:
- subsections (aka sequential blocks) - sequentials (aka subsections)
- chapters - chapters (aka sections)
""" """
category = xblock.category category = xblock.category
if category in ('course', 'vertical'):
if is_unit(xblock):
return True return True
elif category == 'vertical':
parent_xblock = get_parent_xblock(xblock)
return is_unit(parent_xblock) if parent_xblock else False
elif category in ('sequential', 'chapter'): elif category in ('sequential', 'chapter'):
return False return False
elif xblock.has_children:
return True # All other xblocks with children have their own page
else: return xblock.has_children
return False
def xblock_studio_url(xblock): def xblock_studio_url(xblock):
""" """
Returns the Studio editing URL for the specified xblock. Returns the Studio editing URL for the specified xblock.
""" """
if not _xblock_has_studio_page(xblock): if not xblock_has_own_studio_page(xblock):
return None return None
category = xblock.category category = xblock.category
parent_xblock = get_parent_xblock(xblock) parent_xblock = get_parent_xblock(xblock)
if parent_xblock: parent_category = parent_xblock.category if parent_xblock else None
parent_category = parent_xblock.category
else:
parent_category = None
if category == 'course': if category == 'course':
return reverse_course_url('course_handler', xblock.location.course_key) return reverse_course_url('course_handler', xblock.location.course_key)
elif category == 'vertical' and parent_category == 'sequential': elif category == 'vertical' and parent_category == 'sequential':
......
...@@ -21,7 +21,7 @@ from xblock.fragment import Fragment ...@@ -21,7 +21,7 @@ from xblock.fragment import Fragment
import xmodule import xmodule
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xmodule.video_module import manage_video_subtitles_save from xmodule.video_module import manage_video_subtitles_save
...@@ -31,7 +31,7 @@ from util.string_utils import str_to_bool ...@@ -31,7 +31,7 @@ from util.string_utils import str_to_bool
from ..utils import get_modulestore from ..utils import get_modulestore
from .access import has_course_access from .access import has_course_access
from .helpers import _xmodule_recurse from .helpers import _xmodule_recurse, xblock_has_own_studio_page
from contentstore.utils import compute_publish_state, PublishState from contentstore.utils import compute_publish_state, PublishState
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from contentstore.views.preview import get_preview_fragment from contentstore.views.preview import get_preview_fragment
...@@ -178,46 +178,56 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -178,46 +178,56 @@ def xblock_view_handler(request, usage_key_string, view_name):
if 'application/json' in accept_header: if 'application/json' in accept_header:
store = get_modulestore(usage_key) store = get_modulestore(usage_key)
component = store.get_item(usage_key) xblock = store.get_item(usage_key)
is_read_only = _xblock_is_read_only(component) is_read_only = _is_xblock_read_only(xblock)
container_views = ['container_preview', 'reorderable_container_child_preview']
unit_views = ['student_view']
# wrap the generated fragment in the xmodule_editor div so that the javascript # wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly # can bind to it correctly
component.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime', usage_id_serializer=unicode)) xblock.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime', usage_id_serializer=unicode))
if view_name == 'studio_view': if view_name == 'studio_view':
try: try:
fragment = component.render('studio_view') fragment = xblock.render('studio_view')
# catch exceptions indiscriminately, since after this point they escape the # catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable # dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins. # component-goblins.
except Exception as exc: # pylint: disable=w0703 except Exception as exc: # pylint: disable=w0703
log.debug("unable to render studio_view for %r", component, exc_info=True) log.debug("unable to render studio_view for %r", xblock, 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)}))
# change not authored by requestor but by xblocks. # change not authored by requestor but by xblocks.
store.update_item(component, None) store.update_item(xblock, None)
elif view_name == 'student_view' and component.has_children: elif view_name == 'student_view' and xblock_has_own_studio_page(xblock):
context = { context = {
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': False, 'container_view': False,
'read_only': is_read_only, 'read_only': is_read_only,
'root_xblock': component, 'root_xblock': xblock,
} }
# 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.
html = render_to_string('container_xblock_component.html', { html = render_to_string('container_xblock_component.html', {
'xblock_context': context, 'xblock_context': context,
'xblock': component, 'xblock': xblock,
'locator': usage_key, 'locator': usage_key,
}) })
return JsonResponse({ return JsonResponse({
'html': html, 'html': html,
'resources': [], 'resources': [],
}) })
elif view_name in ('student_view', 'container_preview'): elif view_name in (unit_views + container_views):
is_container_view = (view_name == 'container_preview') is_container_view = (view_name in container_views)
# Determine the items to be shown as reorderable. Note that the view
# 'reorderable_container_child_preview' is only rendered for xblocks that
# are being shown in a reorderable container, so the xblock is automatically
# added to the list.
reorderable_items = set()
if view_name == 'reorderable_container_child_preview':
reorderable_items.add(xblock.location)
# Only show the new style HTML for the container view, i.e. for non-verticals # Only show the new style HTML for the container view, i.e. for non-verticals
# Note: this special case logic can be removed once the unit page is replaced # Note: this special case logic can be removed once the unit page is replaced
...@@ -226,10 +236,11 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -226,10 +236,11 @@ def xblock_view_handler(request, usage_key_string, view_name):
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': is_container_view, 'container_view': is_container_view,
'read_only': is_read_only, 'read_only': is_read_only,
'root_xblock': component, 'root_xblock': xblock if (view_name == 'container_preview') else None,
'reorderable_items': reorderable_items
} }
fragment = get_preview_fragment(request, component, context) fragment = get_preview_fragment(request, xblock, context)
# For old-style pages (such as unit and static pages), wrap the preview with # For old-style pages (such as unit and static pages), wrap the preview with
# the component div. Note that the container view recursively adds headers # the component div. Note that the container view recursively adds headers
# into the preview fragment, so we don't want to add another header here. # into the preview fragment, so we don't want to add another header here.
...@@ -237,7 +248,7 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -237,7 +248,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
fragment.content = render_to_string('component.html', { fragment.content = render_to_string('component.html', {
'xblock_context': context, 'xblock_context': context,
'preview': fragment.content, 'preview': fragment.content,
'label': component.display_name or component.scope_ids.block_type, 'label': xblock.display_name or xblock.scope_ids.block_type,
}) })
else: else:
raise Http404 raise Http404
...@@ -255,7 +266,7 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -255,7 +266,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406) return HttpResponse(status=406)
def _xblock_is_read_only(xblock): def _is_xblock_read_only(xblock):
""" """
Returns true if the specified xblock is read-only, meaning that it cannot be edited. Returns true if the specified xblock is read-only, meaning that it cannot be edited.
""" """
...@@ -293,11 +304,19 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null ...@@ -293,11 +304,19 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null
if publish: if publish:
if publish == 'make_private': if publish == 'make_private':
_xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location)) _xmodule_recurse(
existing_item,
lambda i: modulestore().unpublish(i.location),
ignore_exception=ItemNotFoundError
)
elif publish == 'create_draft': elif publish == 'create_draft':
# This recursively clones the existing item location to a draft location (the draft is # This recursively clones the existing item location to a draft location (the draft is
# implicit, because modulestore is a Draft modulestore) # implicit, because modulestore is a Draft modulestore)
_xmodule_recurse(existing_item, lambda i: modulestore().convert_to_draft(i.location)) _xmodule_recurse(
existing_item,
lambda i: modulestore().convert_to_draft(i.location),
ignore_exception=DuplicateItemError
)
if data: if data:
# TODO Allow any scope.content fields not just "data" (exactly like the get below this) # TODO Allow any scope.content fields not just "data" (exactly like the get below this)
...@@ -393,7 +412,7 @@ def _create_item(request): ...@@ -393,7 +412,7 @@ def _create_item(request):
metadata = {} metadata = {}
data = None data = None
template_id = request.json.get('boilerplate') template_id = request.json.get('boilerplate')
if template_id is not None: if template_id:
clz = parent.runtime.load_block_type(category) clz = parent.runtime.load_block_type(category)
if clz is not None: if clz is not None:
template = clz.get_template(template_id) template = clz.get_template(template_id)
......
...@@ -27,7 +27,7 @@ from util.sandboxing import can_execute_unsafe_code ...@@ -27,7 +27,7 @@ from util.sandboxing import can_execute_unsafe_code
import static_replace import static_replace
from .session_kv_store import SessionKeyValueStore from .session_kv_store import SessionKeyValueStore
from .helpers import render_from_lms from .helpers import render_from_lms, xblock_has_own_studio_page
from contentstore.views.access import get_user_role from contentstore.views.access import get_user_role
...@@ -156,6 +156,13 @@ def _load_preview_module(request, descriptor): ...@@ -156,6 +156,13 @@ def _load_preview_module(request, descriptor):
return descriptor return descriptor
def _is_xblock_reorderable(xblock, context):
"""
Returns true if the specified xblock is in the set of reorderable xblocks.
"""
return xblock.location in context['reorderable_items']
# pylint: disable=unused-argument # pylint: disable=unused-argument
def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
""" """
...@@ -163,15 +170,19 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -163,15 +170,19 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
""" """
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now. # Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
if context.get('container_view', None) and view == 'student_view': if context.get('container_view', None) and view == 'student_view':
root_xblock = context.get('root_xblock')
is_root = root_xblock and xblock.location == root_xblock.location
is_reorderable = _is_xblock_reorderable(xblock, context)
template_context = { template_context = {
'xblock_context': context, 'xblock_context': context,
'xblock': xblock, 'xblock': xblock,
'content': frag.content, 'content': frag.content,
'is_root': is_root,
'is_reorderable': is_reorderable,
} }
if xblock.category == 'vertical': # For child xblocks with their own page, render a link to the page
template = 'studio_vertical_wrapper.html' if xblock_has_own_studio_page(xblock) and not is_root:
elif xblock.location != context.get('root_xblock').location and xblock.has_children: template = 'studio_container_wrapper.html'
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)
......
"""
Unit tests for the container view.
"""
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.helpers import xblock_studio_url
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory
class ContainerViewTestCase(CourseTestCase):
"""
Unit tests for the container view.
"""
def setUp(self):
super(ContainerViewTestCase, self).setUp()
self.chapter = ItemFactory.create(parent_location=self.course.location,
category='chapter', display_name="Week 1")
self.sequential = ItemFactory.create(parent_location=self.chapter.location,
category='sequential', display_name="Lesson 1")
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
category='vertical', display_name='Unit')
self.child_vertical = ItemFactory.create(parent_location=self.vertical.location,
category='vertical', display_name='Child Vertical')
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
category="video", display_name="My Video")
def test_container_html(self):
self._test_html_content(
self.child_vertical,
expected_location_in_section_tag=self.child_vertical.location,
expected_breadcrumbs=(
r'<a href="/unit/{unit_location}"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Child Vertical</a>'
).format(unit_location=(unicode(self.vertical.location).replace("+", "\\+")))
)
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.
"""
published_xblock_with_child = ItemFactory.create(
parent_location=self.child_vertical.location,
category="wrapper", display_name="Wrapper"
)
ItemFactory.create(
parent_location=published_xblock_with_child.location,
category="html", display_name="Child HTML"
)
expected_breadcrumbs = (
r'<a href="/unit/{unit_location}"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="/container/{child_vertical_location}"\s*'
r'class="navigation-link navigation-parent">Child Vertical</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(
unit_location=unicode(self.vertical.location).replace("+", "\\+"),
child_vertical_location=unicode(self.child_vertical.location).replace("+", "\\+"),
)
self._test_html_content(
published_xblock_with_child,
expected_location_in_section_tag=published_xblock_with_child.location,
expected_breadcrumbs=expected_breadcrumbs
)
# Now make the unit and its children into a draft and validate the container again
modulestore('draft').convert_to_draft(self.vertical.location)
modulestore('draft').convert_to_draft(self.child_vertical.location)
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
self._test_html_content(
draft_xblock_with_child,
expected_location_in_section_tag=draft_xblock_with_child.location,
expected_breadcrumbs=expected_breadcrumbs
)
def _test_html_content(self, xblock, expected_location_in_section_tag, expected_breadcrumbs):
"""
Get the HTML for a container page and verify the section tag is correct
and the breadcrumbs trail is correct.
"""
publish_state = compute_publish_state(xblock)
url = xblock_studio_url(xblock)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200)
html = resp.content
expected_section_tag = \
'<section class="wrapper-xblock level-page is-hidden" ' \
'data-locator="{child_location}" ' \
'data-course-key="{course_key}">'.format(
child_location=unicode(expected_location_in_section_tag),
course_key=unicode(expected_location_in_section_tag.course_key)
)
self.assertIn(expected_section_tag, html)
# Verify the navigation link at the top of the page is correct.
self.assertRegexpMatches(html, expected_breadcrumbs)
# Verify the link that allows users to change publish status.
if publish_state == PublishState.public:
expected_message = 'you need to edit unit <a href="/unit/{unit_location}">Unit</a> as a draft.'
else:
expected_message = 'your changes will be published with unit <a href="/unit/{unit_location}">Unit</a>.'
expected_unit_link = expected_message.format(
unit_location=unicode(self.vertical.location)
)
self.assertIn(expected_unit_link, html)
def test_container_preview_html(self):
"""
Verify that an xblock returns the expected HTML for a container preview
"""
# First verify that the behavior is correct with a published container
self._test_preview_html(self.vertical)
self._test_preview_html(self.child_vertical)
# Now make the unit and its children into a draft and validate the preview again
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location)
self._test_preview_html(draft_unit)
self._test_preview_html(draft_container)
def _test_preview_html(self, xblock):
"""
Verify that the specified xblock has the expected HTML elements for container preview
"""
publish_state = compute_publish_state(xblock)
preview_url = '/xblock/{}/container_preview'.format(xblock.location)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
html = resp_content['html']
# Verify that there are no drag handles for public pages
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
if publish_state == PublishState.public:
self.assertNotIn(drag_handle_html, html)
else:
self.assertIn(drag_handle_html, html)
"""
Unit tests for the container page.
"""
import re
from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.tests.utils import StudioPageTestCase
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory
class ContainerPageTestCase(StudioPageTestCase):
"""
Unit tests for the container page.
"""
container_view = 'container_preview'
reorderable_child_view = 'reorderable_container_child_preview'
def setUp(self):
super(ContainerPageTestCase, self).setUp()
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
category='vertical', display_name='Unit')
self.html = ItemFactory.create(parent_location=self.vertical.location,
category="html", display_name="HTML")
self.child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
self.child_vertical = ItemFactory.create(parent_location=self.child_container.location,
category='vertical', display_name='Child Vertical')
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
category="video", display_name="My Video")
def test_container_html(self):
self._test_html_content(
self.child_container,
expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location)
),
expected_breadcrumbs=(
r'<a href="/unit/{}"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Split Test</a>'
).format(re.escape(unicode(self.vertical.location)))
)
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.
"""
published_container = ItemFactory.create(
parent_location=self.child_container.location,
category="wrapper", display_name="Wrapper"
)
ItemFactory.create(
parent_location=published_container.location,
category="html", display_name="Child HTML"
)
def test_container_html(xblock):
self._test_html_content(
xblock,
expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{0}" data-course-key="{0.course_key}">'.format(published_container.location)
),
expected_breadcrumbs=(
r'<a href="/unit/{unit}"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="/container/{split_test}"\s*'
r'class="navigation-link navigation-parent">Split Test</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(
unit=re.escape(unicode(self.vertical.location)),
split_test=re.escape(unicode(self.child_container.location))
)
)
# Test the published version of the container
test_container_html(published_container)
# Now make the unit and its children into a draft and validate the container again
modulestore('draft').convert_to_draft(self.vertical.location)
modulestore('draft').convert_to_draft(self.child_vertical.location)
draft_container = modulestore('draft').convert_to_draft(published_container.location)
test_container_html(draft_container)
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.
"""
html = self.get_page_html(xblock)
publish_state = compute_publish_state(xblock)
self.assertIn(expected_section_tag, html)
# Verify the navigation link at the top of the page is correct.
self.assertRegexpMatches(html, expected_breadcrumbs)
# Verify the link that allows users to change publish status.
expected_message = None
if publish_state == PublishState.public:
expected_message = 'you need to edit unit <a href="/unit/{}">Unit</a> as a draft.'
else:
expected_message = 'your changes will be published with unit <a href="/unit/{}">Unit</a>.'
expected_unit_link = expected_message.format(self.vertical.location)
self.assertIn(expected_unit_link, html)
def test_public_container_preview_html(self):
"""
Verify that a public xblock's container preview returns the expected HTML.
"""
self.validate_preview_html(self.vertical, self.container_view,
can_edit=False, can_reorder=False, can_add=False)
self.validate_preview_html(self.child_container, self.container_view,
can_edit=False, can_reorder=False, can_add=False)
self.validate_preview_html(self.child_vertical, self.reorderable_child_view,
can_edit=False, can_reorder=False, can_add=False)
def test_draft_container_preview_html(self):
"""
Verify that a draft xblock's container preview returns the expected HTML.
"""
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
draft_child_container = modulestore('draft').convert_to_draft(self.child_container.location)
draft_child_vertical = modulestore('draft').convert_to_draft(self.child_vertical.location)
self.validate_preview_html(draft_unit, self.container_view,
can_edit=True, can_reorder=True, can_add=True)
self.validate_preview_html(draft_child_container, self.container_view,
can_edit=True, can_reorder=True, can_add=True)
self.validate_preview_html(draft_child_vertical, self.reorderable_child_view,
can_edit=True, can_reorder=True, can_add=True)
def test_public_child_container_preview_html(self):
"""
Verify that a public container rendered as a child of the container page returns the expected HTML.
"""
empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=empty_child_container.location,
category='html', display_name='Split Child')
self.validate_preview_html(empty_child_container, self.reorderable_child_view,
can_reorder=False, can_edit=False, can_add=False)
def test_draft_child_container_preview_html(self):
"""
Verify that a draft container rendered as a child of the container page returns the expected HTML.
"""
empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=empty_child_container.location,
category='html', display_name='Split Child')
modulestore('draft').convert_to_draft(self.vertical.location)
draft_empty_child_container = modulestore('draft').convert_to_draft(empty_child_container.location)
self.validate_preview_html(draft_empty_child_container, self.reorderable_child_view,
can_reorder=True, can_edit=False, can_add=False)
...@@ -612,6 +612,80 @@ class TestEditItem(ItemTest): ...@@ -612,6 +612,80 @@ class TestEditItem(ItemTest):
draft = self.get_item_from_modulestore(self.problem_usage_key, True) draft = self.get_item_from_modulestore(self.problem_usage_key, True)
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_create_draft_with_multiple_requests(self):
"""
Create a draft request returns already created version if it exists.
"""
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key, False))
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'create_draft'
}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key, False))
draft_1 = self.get_item_from_modulestore(self.problem_usage_key, True)
self.assertIsNotNone(draft_1)
# Now check that when a user sends request to create a draft when there is already a draft version then
# user gets that already created draft instead of getting 'DuplicateItemError' exception.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'create_draft'
}
)
draft_2 = self.get_item_from_modulestore(self.problem_usage_key, True)
self.assertIsNotNone(draft_2)
self.assertEqual(draft_1, draft_2)
def test_make_private_with_multiple_requests(self):
"""
Make private requests gets proper response even if xmodule is already made private.
"""
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key, False))
# Now make it private, and check that its published version not exists
resp = self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'make_private'
}
)
self.assertEqual(resp.status_code, 200)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_usage_key, False)
draft_1 = self.get_item_from_modulestore(self.problem_usage_key, True)
self.assertIsNotNone(draft_1)
# Now check that when a user sends request to make it private when it already is private then
# user gets that private version instead of getting 'ItemNotFoundError' exception.
self.client.ajax_post(
self.problem_update_url,
data={
'publish': 'make_private'
}
)
self.assertEqual(resp.status_code, 200)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_usage_key, False)
draft_2 = self.get_item_from_modulestore(self.problem_usage_key, True)
self.assertIsNotNone(draft_2)
self.assertEqual(draft_1, draft_2)
def test_published_and_draft_contents_with_update(self): def test_published_and_draft_contents_with_update(self):
""" Create a draft and publish it then modify the draft and check that published content is not modified """ """ Create a draft and publish it then modify the draft and check that published content is not modified """
......
...@@ -189,7 +189,7 @@ class TabsPageTests(CourseTestCase): ...@@ -189,7 +189,7 @@ class TabsPageTests(CourseTestCase):
self.assertIn('<span class="action-button-text">Edit</span>', html) self.assertIn('<span class="action-button-text">Edit</span>', html)
self.assertIn('<span class="sr">Duplicate this component</span>', html) self.assertIn('<span class="sr">Duplicate this component</span>', html)
self.assertIn('<span class="sr">Delete this component</span>', html) self.assertIn('<span class="sr">Delete this component</span>', html)
self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle"></span>', html) self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle action"></span>', html)
......
"""
Unit tests for the unit page.
"""
from contentstore.views.tests.utils import StudioPageTestCase
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory
class UnitPageTestCase(StudioPageTestCase):
"""
Unit tests for the unit page.
"""
def setUp(self):
super(UnitPageTestCase, self).setUp()
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
category='vertical', display_name='Unit')
self.video = ItemFactory.create(parent_location=self.vertical.location,
category="video", display_name="My Video")
def test_public_unit_page_html(self):
"""
Verify that an xblock returns the expected HTML for a public unit page.
"""
html = self.get_page_html(self.vertical)
self.validate_html_for_add_buttons(html)
def test_draft_unit_page_html(self):
"""
Verify that an xblock returns the expected HTML for a draft unit page.
"""
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
html = self.get_page_html(draft_unit)
self.validate_html_for_add_buttons(html)
def test_public_component_preview_html(self):
"""
Verify that a public xblock's preview returns the expected HTML.
"""
self.validate_preview_html(self.video, 'student_view',
can_edit=True, can_reorder=True, can_add=False)
def test_draft_component_preview_html(self):
"""
Verify that a draft xblock's preview returns the expected HTML.
"""
modulestore('draft').convert_to_draft(self.vertical.location)
draft_video = modulestore('draft').convert_to_draft(self.video.location)
self.validate_preview_html(draft_video, 'student_view',
can_edit=True, can_reorder=True, can_add=False)
def test_public_child_container_preview_html(self):
"""
Verify that a public child container rendering on the unit page (which shows a View arrow
to the container page) returns the expected HTML.
"""
child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=child_container.location,
category='html', display_name='grandchild')
self.validate_preview_html(child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False)
def test_draft_child_container_preview_html(self):
"""
Verify that a draft child container rendering on the unit page (which shows a View arrow
to the container page) returns the expected HTML.
"""
child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=child_container.location,
category='html', display_name='grandchild')
modulestore('draft').convert_to_draft(self.vertical.location)
draft_child_container = modulestore('draft').get_item(child_container.location)
self.validate_preview_html(draft_child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False)
"""
Utilities for view tests.
"""
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.views.helpers import xblock_studio_url
from xmodule.modulestore.tests.factories import ItemFactory
class StudioPageTestCase(CourseTestCase):
"""
Base class for all tests of Studio pages.
"""
def setUp(self):
super(StudioPageTestCase, self).setUp()
self.chapter = ItemFactory.create(parent_location=self.course.location,
category='chapter', display_name="Week 1")
self.sequential = ItemFactory.create(parent_location=self.chapter.location,
category='sequential', display_name="Lesson 1")
def get_page_html(self, xblock):
"""
Returns the HTML for the page representing the xblock.
"""
url = xblock_studio_url(xblock)
self.assertIsNotNone(url)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200)
return resp.content
def get_preview_html(self, xblock, view_name):
"""
Returns the HTML for the xblock when shown within a unit or container page.
"""
preview_url = '/xblock/{usage_key}/{view_name}'.format(usage_key=xblock.location, view_name=view_name)
resp = self.client.get_json(preview_url)
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
return resp_content['html']
def validate_preview_html(self, xblock, view_name, can_edit=True, can_reorder=True, can_add=True):
"""
Verify that the specified xblock's preview has the expected HTML elements.
"""
html = self.get_preview_html(xblock, view_name)
self.validate_html_for_add_buttons(html, can_add=can_add)
# Verify that there are no drag handles for public blocks
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
if can_reorder:
self.assertIn(drag_handle_html, html)
else:
self.assertNotIn(drag_handle_html, html)
# Verify that there are no action buttons for public blocks
expected_button_html = [
'<a href="#" class="edit-button action-button">',
'<a href="#" data-tooltip="Delete" class="delete-button action-button">',
'<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">'
]
for button_html in expected_button_html:
if can_edit:
self.assertIn(button_html, html)
else:
self.assertNotIn(button_html, html)
def validate_html_for_add_buttons(self, html, can_add=True):
"""
Validate that the specified HTML has the appropriate add actions for the current publish state.
"""
# Verify that there are no add buttons for public blocks
add_button_html = '<div class="add-xblock-component new-component-item adding"></div>'
if can_add:
self.assertIn(add_button_html, html)
else:
self.assertNotIn(add_button_html, html)
...@@ -85,9 +85,12 @@ FEATURES = { ...@@ -85,9 +85,12 @@ FEATURES = {
# Hide any Personally Identifiable Information from application logs # Hide any Personally Identifiable Information from application logs
'SQUELCH_PII_IN_LOGS': False, 'SQUELCH_PII_IN_LOGS': False,
# Toggles embargo functionality # Toggles the embargo functionality, which enable embargoing for particular courses
'EMBARGO': False, 'EMBARGO': False,
# Toggles the embargo site functionality, which enable embargoing for the whole site
'SITE_EMBARGOED': False,
# Turn on/off Microsites feature # Turn on/off Microsites feature
'USE_MICROSITES': False, 'USE_MICROSITES': False,
...@@ -99,12 +102,6 @@ FEATURES = { ...@@ -99,12 +102,6 @@ FEATURES = {
# Turn off Advanced Security by default # Turn off Advanced Security by default
'ADVANCED_SECURITY': False, 'ADVANCED_SECURITY': False,
# Temporary feature flag for duplicating xblock leaves
'ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT': False,
# Temporary feature flag for deleting xblock leaves
'ENABLE_DELETE_XBLOCK_LEAF_COMPONENT': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -302,6 +299,9 @@ LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/ ...@@ -302,6 +299,9 @@ LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/
# Messages # Messages
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
##### EMBARGO #####
EMBARGO_SITE_REDIRECT_URL = None
############################### Pipeline ####################################### ############################### Pipeline #######################################
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
...@@ -318,7 +318,7 @@ PIPELINE_CSS = { ...@@ -318,7 +318,7 @@ PIPELINE_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',
'js/vendor/markitup/sets/wiki/style.css' 'js/vendor/markitup/sets/wiki/style.css',
], ],
'output_filename': 'css/cms-style-vendor.css', 'output_filename': 'css/cms-style-vendor.css',
}, },
......
...@@ -220,6 +220,7 @@ define([ ...@@ -220,6 +220,7 @@ define([
"js/spec/views/baseview_spec", "js/spec/views/baseview_spec",
"js/spec/views/paging_spec", "js/spec/views/paging_spec",
"js/spec/views/assets_spec",
"js/spec/views/container_spec", "js/spec/views/container_spec",
"js/spec/views/unit_spec", "js/spec/views/unit_spec",
......
define ["jasmine", "js/spec_helpers/create_sinon", "squire"], define ["jquery", "jasmine", "js/spec_helpers/create_sinon", "squire"],
(jasmine, create_sinon, Squire) -> ($, jasmine, create_sinon, Squire) ->
feedbackTpl = readFixtures('system-feedback.underscore') feedbackTpl = readFixtures('system-feedback.underscore')
assetLibraryTpl = readFixtures('asset-library.underscore') assetLibraryTpl = readFixtures('asset-library.underscore')
...@@ -236,6 +236,33 @@ define ["jasmine", "js/spec_helpers/create_sinon", "squire"], ...@@ -236,6 +236,33 @@ define ["jasmine", "js/spec_helpers/create_sinon", "squire"],
create_sinon.respondWithJson(requests, @mockAssetsResponse) create_sinon.respondWithJson(requests, @mockAssetsResponse)
return requests return requests
$.fn.fileupload = ->
return ''
clickEvent = (html_selector) ->
$(html_selector).click()
it "should show upload modal on clicking upload asset button", ->
spyOn(@view, "showUploadModal")
setup.call(this)
expect(@view.showUploadModal).not.toHaveBeenCalled()
@view.showUploadModal(clickEvent(".upload-button"))
expect(@view.showUploadModal).toHaveBeenCalled()
it "should show file selection menu on choose file button", ->
spyOn(@view, "showFileSelectionMenu")
setup.call(this)
expect(@view.showFileSelectionMenu).not.toHaveBeenCalled()
@view.showFileSelectionMenu(clickEvent(".choose-file-button"))
expect(@view.showFileSelectionMenu).toHaveBeenCalled()
it "should hide upload modal on clicking close button", ->
spyOn(@view, "hideModal")
setup.call(this)
expect(@view.hideModal).not.toHaveBeenCalled()
@view.hideModal(clickEvent(".close-button"))
expect(@view.hideModal).toHaveBeenCalled()
it "should show a status indicator while loading", -> it "should show a status indicator while loading", ->
appendSetFixtures('<div class="ui-loading"/>') appendSetFixtures('<div class="ui-loading"/>')
expect($('.ui-loading').is(':visible')).toBe(true) expect($('.ui-loading').is(':visible')).toBe(true)
......
...@@ -19,7 +19,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit" ...@@ -19,7 +19,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit"
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a> <a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a> <a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
</div> </div>
<span class="drag-handle"></span> <span class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_stub" data-type="StubModule"> <section class="xblock xblock-student_view xmodule_display xmodule_stub" data-type="StubModule">
<div id="stub-module-content"/> <div id="stub-module-content"/>
</section> </section>
......
define(["backbone", "js/models/component_template"], function(Backbone, ComponentTemplate) {
return Backbone.Collection.extend({
model : ComponentTemplate
});
});
/**
* Simple model for adding a component of a given type (for example, "video" or "html").
*/
define(["backbone"], function (Backbone) {
return Backbone.Model.extend({
defaults: {
type: "",
// Each entry in the template array is an Object with the following keys:
// display_name
// category (may or may not match "type")
// boilerplate_name (may be null)
// is_common (only used for problems)
templates: []
},
parse: function (response) {
this.type = response.type;
this.templates = response.templates;
// Sort the templates.
this.templates.sort(function (a, b) {
// The entry without a boilerplate always goes first
if (!a.boilerplate_name || (a.display_name < b.display_name)) {
return -1;
}
else {
return (a.display_name > b.display_name) ? 1 : 0;
}
});
}
});
});
define([ "jquery", "js/spec_helpers/create_sinon", "js/views/asset", "js/views/assets",
"js/models/asset", "js/collections/asset" ],
function ($, create_sinon, AssetView, AssetsView, AssetModel, AssetCollection) {
describe("Assets", function() {
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse,
assetLibraryTpl, assetTpl, pagingFooterTpl, pagingHeaderTpl, uploadModalTpl;
assetLibraryTpl = readFixtures('asset-library.underscore');
assetTpl = readFixtures('asset.underscore');
pagingHeaderTpl = readFixtures('paging-header.underscore');
pagingFooterTpl = readFixtures('paging-footer.underscore');
uploadModalTpl = readFixtures('asset-upload-modal.underscore');
beforeEach(function () {
setFixtures($("<script>", { id: "asset-library-tpl", type: "text/template" }).text(assetLibraryTpl));
appendSetFixtures($("<script>", { id: "asset-tpl", type: "text/template" }).text(assetTpl));
appendSetFixtures($("<script>", { id: "paging-header-tpl", type: "text/template" }).text(pagingHeaderTpl));
appendSetFixtures($("<script>", { id: "paging-footer-tpl", type: "text/template" }).text(pagingFooterTpl));
appendSetFixtures(uploadModalTpl);
appendSetFixtures(sandbox({ id: "asset_table_body" }));
var collection = new AssetCollection();
collection.url = "assets-url";
assetsView = new AssetsView({
collection: collection,
el: $('#asset_table_body')
});
assetsView.render();
});
var mockAsset = {
display_name: "dummy.jpg",
url: 'actual_asset_url',
portable_url: 'portable_url',
date_added: 'date',
thumbnail: null,
locked: false,
id: 'id_1'
};
mockEmptyAssetsResponse = {
assets: [],
start: 0,
end: 0,
page: 0,
pageSize: 5,
totalCount: 0
};
mockAssetUploadResponse = {
asset: mockAsset,
msg: "Upload completed"
};
$.fn.fileupload = function() {
return '';
};
var event = {}
event.target = {"value": "dummy.jpg"};
describe("AssetsView", function () {
var setup;
setup = function() {
var requests;
requests = create_sinon.requests(this);
assetsView.setPage(0);
create_sinon.respondWithJson(requests, mockEmptyAssetsResponse);
return requests;
};
beforeEach(function () {
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy();
});
afterEach(function () {
delete window.analytics;
delete window.course_location_analytics;
});
it('shows the upload modal when clicked on "Upload your first asset" button', function () {
expect(assetsView).toBeDefined();
appendSetFixtures('<div class="ui-loading"/>');
expect($('.ui-loading').is(':visible')).toBe(true);
expect($('.upload-button').is(':visible')).toBe(false);
setup.call(this);
expect($('.ui-loading').is(':visible')).toBe(false);
expect($('.upload-button').is(':visible')).toBe(true);
expect($('.upload-modal').is(':visible')).toBe(false);
$('a:contains("Upload your first asset")').click();
expect($('.upload-modal').is(':visible')).toBe(true);
$('.close-button').click();
expect($('.upload-modal').is(':visible')).toBe(false);
});
it('uploads file properly', function () {
var requests = setup.call(this);
expect(assetsView).toBeDefined();
spyOn(assetsView, "addAsset").andCallFake(function () {
assetsView.collection.add(mockAssetUploadResponse.asset);
assetsView.renderPageItems();
assetsView.setPage(0);
});
$('a:contains("Upload your first asset")').click();
expect($('.upload-modal').is(':visible')).toBe(true);
$('.choose-file-button').click();
$("input[type=file]").change();
expect($('.upload-modal h1').text()).toContain("Uploading");
assetsView.showUploadFeedback(event, 100);
expect($('div.progress-bar').text()).toContain("100%");
assetsView.displayFinishedUpload(mockAssetUploadResponse);
expect($('div.progress-bar').text()).toContain("Upload completed");
$('.close-button').click();
expect($('.upload-modal').is(':visible')).toBe(false);
expect($('#asset_table_body').html()).toContain("dummy.jpg");
expect(assetsView.collection.length).toBe(1);
});
});
});
});
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"], define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon",
function ($, _, BaseView, IframeBinding, sinon) { "js/spec_helpers/edit_helpers"],
function ($, _, BaseView, IframeBinding, sinon, view_helpers) {
describe("BaseView", function() { describe("BaseView", function() {
var baseViewPrototype; var baseViewPrototype;
...@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin ...@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
describe("disabled element while running", function() { describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() { it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var viewWithLink, var link,
link,
deferred = new $.Deferred(), deferred = new $.Deferred(),
promise = deferred.promise(), promise = deferred.promise(),
view = new BaseView(); view = new BaseView();
...@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin ...@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
link = $("#link"); link = $("#link");
expect(link).not.toHaveClass("is-disabled"); expect(link).not.toHaveClass("is-disabled");
view.disableElementWhileRunning(link, function(){return promise}); view.disableElementWhileRunning(link, function() { return promise; });
expect(link).toHaveClass("is-disabled"); expect(link).toHaveClass("is-disabled");
deferred.resolve(); deferred.resolve();
expect(link).not.toHaveClass("is-disabled"); expect(link).not.toHaveClass("is-disabled");
}); });
}); });
describe("progress notification", function() {
it("shows progress notification and removes it upon success", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.resolve();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it("shows progress notification and leaves it showing upon failure", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.fail();
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
});
});
}); });
}); });
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
"js/views/container", "js/models/xblock_info", "js/views/feedback_notification", "jquery.simulate", "js/views/container", "js/models/xblock_info", "jquery.simulate",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) { function ($, create_sinon, view_helpers, ContainerView, XBlockInfo) {
describe("Container View", function () { describe("Container View", function () {
...@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent, var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
getDragHandle, dragComponentVertically, dragComponentAbove, getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest, verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF', rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
containerTestUrl = '/xblock/' + rootLocator, containerTestUrl = '/xblock/' + rootLocator,
...@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
beforeEach(function () { beforeEach(function () {
view_helpers.installViewTemplates(); view_helpers.installViewTemplates();
appendSetFixtures('<div class="wrapper-xblock level-page" data-locator="' + rootLocator + '"></div>'); appendSetFixtures('<div class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="' + rootLocator + '"></div>');
notificationSpy = view_helpers.createNotificationSpy();
model = new XBlockInfo({ model = new XBlockInfo({
id: rootLocator, id: rootLocator,
display_name: 'Test AB Test', display_name: 'Test AB Test',
...@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
}); });
$('body').append(containerView.$el); $('body').append(containerView.$el);
// Give the whole container enough height to contain everything.
$('.xblock[data-locator=locator-container]').css('height', 2000);
// Give the groups enough height to contain their child vertical elements.
$('.is-draggable[data-locator=locator-group-A]').css('height', 800);
$('.is-draggable[data-locator=locator-group-B]').css('height', 800);
// Give the leaf elements some height to mimic actual components. Otherwise
// drag and drop fails as the elements on bunched on top of each other.
$('.level-element').css('height', 200);
return requests; return requests;
}; };
getComponent = function(locator) { getComponent = function(locator) {
return containerView.$('[data-locator="' + locator + '"]'); return containerView.$('.studio-xblock-wrapper[data-locator="' + locator + '"]');
}; };
getDragHandle = function(locator) { getDragHandle = function(locator) {
var component = getComponent(locator); var component = getComponent(locator);
return component.prev(); return $(component.find('.drag-handle')[0]);
}; };
dragComponentVertically = function (locator, dy) { dragComponentVertically = function (locator, dy) {
...@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
}); });
describe("Shows a saving message", function () { describe("Shows a saving message", function () {
var savingSpies;
beforeEach(function () {
savingSpies = spyOnConstructor(Notification, "Mini",
["show", "hide"]);
savingSpies.show.andReturn(savingSpies);
});
it('hides saving message upon success', function () { it('hides saving message upon success', function () {
var requests, savingOptions; var requests, savingOptions;
requests = init(this); requests = init(this);
// Drag the first component in Group B to the first group. // Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1); dragComponentAbove(groupBComponent1, groupAComponent1);
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
expect(savingOptions.title).toMatch(/Saving/);
respondToRequest(requests, 0, 200); respondToRequest(requests, 0, 200);
expect(savingSpies.hide).not.toHaveBeenCalled(); view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
respondToRequest(requests, 1, 200); respondToRequest(requests, 1, 200);
expect(savingSpies.hide).toHaveBeenCalled(); view_helpers.verifyNotificationHidden(notificationSpy);
}); });
it('does not hide saving message if failure', function () { it('does not hide saving message if failure', function () {
...@@ -198,13 +198,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -198,13 +198,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
// Drag the first component in Group B to the first group. // Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1); dragComponentAbove(groupBComponent1, groupAComponent1);
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 0, 500); respondToRequest(requests, 0, 500);
expect(savingSpies.hide).not.toHaveBeenCalled(); view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
// Since the first reorder call failed, the removal will not be called. // Since the first reorder call failed, the removal will not be called.
verifyNumReorderCalls(requests, 1); verifyNumReorderCalls(requests, 1);
......
...@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper ...@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper
var mockXBlockEditorHtml; var mockXBlockEditorHtml;
beforeEach(function () { beforeEach(function () {
edit_helpers.installMockXBlock(mockSaveResponse); edit_helpers.installMockXBlock();
}); });
afterEach(function() { afterEach(function() {
......
define(["sinon"], function(sinon) { define(["sinon", "underscore"], function(sinon, _) {
var fakeServer, fakeRequests, respondWithJson, respondWithError; var fakeServer, fakeRequests, respondWithJson, respondWithError;
/* These utility methods are used by Jasmine tests to create a mock server or /* These utility methods are used by Jasmine tests to create a mock server or
...@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) { ...@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) {
}; };
respondWithJson = function(requests, jsonResponse, requestIndex) { respondWithJson = function(requests, jsonResponse, requestIndex) {
requestIndex = requestIndex || requests.length - 1; if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(200, requests[requestIndex].respond(200,
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
JSON.stringify(jsonResponse)); JSON.stringify(jsonResponse));
}; };
respondWithError = function(requests, requestIndex) { respondWithError = function(requests, requestIndex) {
requestIndex = requestIndex || requests.length - 1; if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(500, requests[requestIndex].respond(500,
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
JSON.stringify({ })); JSON.stringify({ }));
......
...@@ -2,22 +2,14 @@ ...@@ -2,22 +2,14 @@
* Provides helper methods for invoking Studio editors in Jasmine tests. * Provides helper methods for invoking Studio editors in Jasmine tests.
*/ */
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers", define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers",
"js/views/modals/edit_xblock", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/views/modals/edit_xblock", "js/collections/component_template",
function($, _, create_sinon, modal_helpers, EditXBlockModal) { "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function($, _, create_sinon, modal_helpers, EditXBlockModal, ComponentTemplates) {
var editorTemplate = readFixtures('metadata-editor.underscore'), var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
numberEntryTemplate = readFixtures('metadata-number-entry.underscore'), mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
stringEntryTemplate = readFixtures('metadata-string-entry.underscore'),
editXBlockModalTemplate = readFixtures('edit-xblock-modal.underscore'),
editorModeButtonTemplate = readFixtures('editor-mode-button.underscore'),
installMockXBlock,
uninstallMockXBlock,
installMockXModule,
uninstallMockXModule,
installEditTemplates,
showEditModal;
installMockXBlock = function(mockResult) { installMockXBlock = function() {
window.MockXBlock = function(runtime, element) { window.MockXBlock = function(runtime, element) {
return { return {
runtime: runtime runtime: runtime
...@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
window.MockDescriptor = null; window.MockDescriptor = null;
}; };
mockComponentTemplates = new ComponentTemplates([
{
templates: [
{
category: 'discussion',
display_name: 'Discussion'
}],
type: 'discussion'
}, {
"templates": [
{
"category": "html",
"boilerplate_name": null,
"display_name": "Text"
}, {
"category": "html",
"boilerplate_name": "announcement.yaml",
"display_name": "Announcement"
}, {
"category": "html",
"boilerplate_name": "raw.yaml",
"display_name": "Raw HTML"
}],
"type": "html"
}],
{
parse: true
});
installEditTemplates = function(append) { installEditTemplates = function(append) {
modal_helpers.installModalTemplates(append); modal_helpers.installModalTemplates(append);
// Add templates needed by the add XBlock menu
modal_helpers.installTemplate('add-xblock-component');
modal_helpers.installTemplate('add-xblock-component-button');
modal_helpers.installTemplate('add-xblock-component-menu');
modal_helpers.installTemplate('add-xblock-component-menu-problem');
// Add templates needed by the edit XBlock modal // Add templates needed by the edit XBlock modal
appendSetFixtures($("<script>", { id: "edit-xblock-modal-tpl", type: "text/template" }).text(editXBlockModalTemplate)); modal_helpers.installTemplate('edit-xblock-modal');
appendSetFixtures($("<script>", { id: "editor-mode-button-tpl", type: "text/template" }).text(editorModeButtonTemplate)); modal_helpers.installTemplate('editor-mode-button');
// Add templates needed by the settings editor // Add templates needed by the settings editor
appendSetFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate)); modal_helpers.installTemplate('metadata-editor');
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate)); modal_helpers.installTemplate('metadata-number-entry');
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate)); modal_helpers.installTemplate('metadata-string-entry');
}; };
showEditModal = function(requests, xblockElement, model, mockHtml, options) { showEditModal = function(requests, xblockElement, model, mockHtml, options) {
...@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
return modal; return modal;
}; };
verifyXBlockRequest = function (requests, expectedJson) {
var request = requests[requests.length - 1],
actualJson = JSON.parse(request.requestBody);
expect(request.url).toEqual("/xblock/");
expect(request.method).toEqual("POST");
expect(actualJson).toEqual(expectedJson);
};
return $.extend(modal_helpers, { return $.extend(modal_helpers, {
'installMockXBlock': installMockXBlock, 'installMockXBlock': installMockXBlock,
'uninstallMockXBlock': uninstallMockXBlock, 'uninstallMockXBlock': uninstallMockXBlock,
'installMockXModule': installMockXModule, 'installMockXModule': installMockXModule,
'uninstallMockXModule': uninstallMockXModule, 'uninstallMockXModule': uninstallMockXModule,
'mockComponentTemplates': mockComponentTemplates,
'installEditTemplates': installEditTemplates, 'installEditTemplates': installEditTemplates,
'showEditModal': showEditModal 'showEditModal': showEditModal,
'verifyXBlockRequest': verifyXBlockRequest
}); });
}); });
...@@ -3,10 +3,7 @@ ...@@ -3,10 +3,7 @@
*/ */
define(["jquery", "js/spec_helpers/view_helpers"], define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) { function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'), var installModalTemplates,
modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'),
installModalTemplates,
getModalElement, getModalElement,
isShowingModal, isShowingModal,
hideModalIfShowing, hideModalIfShowing,
...@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"], ...@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"],
installModalTemplates = function(append) { installModalTemplates = function(append) {
view_helpers.installViewTemplates(append); view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate)); view_helpers.installTemplate('basic-modal');
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate)); view_helpers.installTemplate('modal-button');
}; };
getModalElement = function(modal) { getModalElement = function(modal) {
......
/** /**
* Provides helper methods for invoking Studio modal windows in Jasmine tests. * Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/ */
define(["jquery"], define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sinon"],
function($) { function($, NotificationView, create_sinon) {
var feedbackTemplate = readFixtures('system-feedback.underscore'), var installTemplate, installViewTemplates, createNotificationSpy, verifyNotificationShowing,
installViewTemplates; verifyNotificationHidden;
installViewTemplates = function(append) { installTemplate = function(templateName, isFirst) {
if (append) { var template = readFixtures(templateName + '.underscore'),
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate)); templateId = templateName + '-tpl';
if (isFirst) {
setFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
} else { } else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate)); appendSetFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
} }
}; };
installViewTemplates = function(append) {
installTemplate('system-feedback', !append);
appendSetFixtures('<div id="page-notification"></div>');
};
createNotificationSpy = function() {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
notificationSpy.show.andReturn(notificationSpy);
return notificationSpy;
};
verifyNotificationShowing = function(notificationSpy, text) {
expect(notificationSpy.constructor).toHaveBeenCalled();
expect(notificationSpy.show).toHaveBeenCalled();
expect(notificationSpy.hide).not.toHaveBeenCalled();
var options = notificationSpy.constructor.mostRecentCall.args[0];
expect(options.title).toMatch(text);
};
verifyNotificationHidden = function(notificationSpy) {
expect(notificationSpy.hide).toHaveBeenCalled();
};
return { return {
'installViewTemplates': installViewTemplates 'installTemplate': installTemplate,
'installViewTemplates': installViewTemplates,
'createNotificationSpy': createNotificationSpy,
'verifyNotificationShowing': verifyNotificationShowing,
'verifyNotificationHidden': verifyNotificationHidden
}; };
}); });
...@@ -7,15 +7,15 @@ ...@@ -7,15 +7,15 @@
* getUpdateUrl: a utility method that returns the xblock update URL, appending * getUpdateUrl: a utility method that returns the xblock update URL, appending
* the location if passed in. * the location if passed in.
*/ */
define([], function () { define(["underscore"], function (_) {
var urlRoot = '/xblock'; var urlRoot = '/xblock';
var getUpdateUrl = function (locator) { var getUpdateUrl = function (locator) {
if (locator === undefined) { if (_.isUndefined(locator)) {
return urlRoot + "/"; return urlRoot + '/';
} }
else { else {
return urlRoot + "/" + locator; return urlRoot + '/' + locator;
} }
}; };
return { return {
...@@ -23,4 +23,3 @@ define([], function () { ...@@ -23,4 +23,3 @@ define([], function () {
getUpdateUrl: getUpdateUrl getUpdateUrl: getUpdateUrl
}; };
}); });
define(["jquery", "underscore"], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @returns The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
if (!templateText) {
console.error("Failed to load " + name + " template");
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});
define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", "js/views/paging_header", "js/views/paging_footer"], define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", "js/views/asset",
function($, _, gettext, PagingView, AssetView, PagingHeader, PagingFooter) { "js/views/paging_header", "js/views/paging_footer", "js/utils/modal"],
function($, _, gettext, AssetModel, PagingView, AssetView, PagingHeader, PagingFooter, ModalUtils) {
var AssetsView = PagingView.extend({ var AssetsView = PagingView.extend({
// takes AssetCollection as model // takes AssetCollection as model
events : { events : {
"click .column-sort-link": "onToggleColumn" "click .column-sort-link": "onToggleColumn",
"click .upload-button": "showUploadModal"
}, },
initialize : function() { initialize : function() {
...@@ -17,6 +19,8 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", ...@@ -17,6 +19,8 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc'); this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc');
this.setInitialSortColumn('js-asset-date-col'); this.setInitialSortColumn('js-asset-date-col');
this.showLoadingIndicator(); this.showLoadingIndicator();
this.setPage(0);
assetsView = this;
}, },
render: function() { render: function() {
...@@ -24,6 +28,14 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", ...@@ -24,6 +28,14 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
return this; return this;
}, },
afterRender: function(){
// Bind events with html elements
$('li a.upload-button').on('click', this.showUploadModal);
$('.upload-modal .close-button').on('click', this.hideModal);
$('.upload-modal .choose-file-button').on('click', this.showFileSelectionMenu);
return this;
},
getTableBody: function() { getTableBody: function() {
var tableBody = this.tableBody; var tableBody = this.tableBody;
if (!tableBody) { if (!tableBody) {
...@@ -47,9 +59,9 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", ...@@ -47,9 +59,9 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
renderPageItems: function() { renderPageItems: function() {
var self = this, var self = this,
assets = this.collection, assets = this.collection,
hasAssets = assets.length > 0, hasAssets = assets.length > 0,
tableBody = this.getTableBody(); tableBody = this.getTableBody();
tableBody.empty(); tableBody.empty();
if (hasAssets) { if (hasAssets) {
assets.each( assets.each(
...@@ -91,6 +103,92 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", ...@@ -91,6 +103,92 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
onToggleColumn: function(event) { onToggleColumn: function(event) {
var columnName = event.target.id; var columnName = event.target.id;
this.toggleSortOrder(columnName); this.toggleSortOrder(columnName);
},
hideModal: function (event) {
if (event) {
event.preventDefault();
}
$('.file-input').unbind('change.startUpload');
ModalUtils.hideModal();
},
showUploadModal: function (event) {
var self = assetsView;
event.preventDefault();
self.resetUploadModal();
ModalUtils.showModal();
$('.file-input').bind('change', self.startUpload);
$('.upload-modal .file-chooser').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 100 * 1000 * 1000, // 100 MB
autoUpload: true,
progressall: function(event, data) {
var percentComplete = parseInt((100 * data.loaded) / data.total, 10);
self.showUploadFeedback(event, percentComplete);
},
maxFileSize: 100 * 1000 * 1000, // 100 MB
maxNumberofFiles: 100,
add: function(event, data) {
data.process().done(function () {
data.submit();
});
},
done: function(event, data) {
self.displayFinishedUpload(data.result);
}
});
},
showFileSelectionMenu: function(event) {
event.preventDefault();
$('.file-input').click();
},
startUpload: function (event) {
var file = event.target.value;
$('.upload-modal h1').text(gettext('Uploading…'));
$('.upload-modal .file-name').html(file.substring(file.lastIndexOf("\\") + 1));
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
},
resetUploadModal: function () {
// Reset modal so it no longer displays information about previously
// completed uploads.
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
$('.upload-modal .progress-bar').hide();
$('.upload-modal .file-name').show();
$('.upload-modal .file-name').html('');
$('.upload-modal .choose-file-button').text(gettext('Choose File'));
$('.upload-modal .embeddable-xml-input').val('');
$('.upload-modal .embeddable').hide();
},
showUploadFeedback: function (event, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
},
displayFinishedUpload: function (resp) {
var asset = resp.asset;
$('.upload-modal h1').text(gettext('Upload New File'));
$('.upload-modal .embeddable-xml-input').val(asset.portable_url);
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').text(gettext('Load Another File')).show();
$('.upload-modal .progress-fill').width('100%');
assetsView.addAsset(new AssetModel(asset));
} }
}); });
......
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_binding", "js/utils/templates",
function ($, _, Backbone, IframeUtils) { "js/views/feedback_notification", "js/views/feedback_prompt"],
function ($, _, Backbone, gettext, IframeUtils, TemplateUtils, NotificationView, PromptView) {
/* /*
This view is extended from backbone to provide useful functionality for all Studio views. This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes: This functionality includes:
...@@ -61,15 +62,59 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], ...@@ -61,15 +62,59 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
}, },
/** /**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
confirmThenRunOperation: function(title, message, actionLabel, operation) {
var self = this;
return new PromptView.Warning({
title: title,
message: message,
actions: {
primary: {
text: actionLabel,
click: function(prompt) {
prompt.hide();
operation();
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
},
/**
* Shows a progress message for the duration of an asynchronous operation.
* Note: this does not remove the notification upon failure because an error
* will be shown that shouldn't be removed.
* @param message The message to show.
* @param operation A function that returns a promise representing the operation.
*/
runOperationShowingMessage: function(message, operation) {
var notificationView;
notificationView = new NotificationView.Mini({
title: gettext(message)
});
notificationView.show();
return operation().done(function() {
notificationView.hide();
});
},
/**
* Disables a given element when a given operation is running. * Disables a given element when a given operation is running.
* @param {jQuery} element: the element to be disabled. * @param {jQuery} element: the element to be disabled.
* @param operation: the operation during whose duration the * @param operation: the operation during whose duration the
* element should be disabled. The operation should return * element should be disabled. The operation should return
* a jquery promise. * a JQuery promise.
*/ */
disableElementWhileRunning: function(element, operation) { disableElementWhileRunning: function(element, operation) {
element.addClass("is-disabled"); element.addClass("is-disabled");
operation().always(function() { return operation().always(function() {
element.removeClass("is-disabled"); element.removeClass("is-disabled");
}); });
}, },
...@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], ...@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
* @returns The loaded template. * @returns The loaded template.
*/ */
loadTemplate: function(name) { loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl", return TemplateUtils.loadTemplate(name);
templateText = $(templateSelector).text(); },
if (!templateText) {
console.error("Failed to load " + name + " template"); /**
} * Returns the relative position that the element is scrolled from the top of the view port.
return _.template(templateText); * @param element The element in question.
*/
getScrollOffset: function(element) {
var elementTop = element.offset().top;
return elementTop - $(window).scrollTop();
},
/**
* Scrolls the window so that the element is scrolled down to the specified relative position
* from the top of the view port.
* @param element The element in question.
* @param offset The amount by which the element should be scrolled from the top of the view port.
*/
setScrollOffset: function(element, offset) {
var elementTop = element.offset().top,
newScrollTop = elementTop - offset;
this.setScrollTop(newScrollTop);
},
/**
* Performs an animated scroll so that the window has the specified scroll top.
* @param scrollTop The desired scroll top for the window.
*/
setScrollTop: function(scrollTop) {
$('html, body').animate({
scrollTop: scrollTop
}, 500);
} }
}); });
......
/**
* This is a simple component that renders add buttons for all available XBlock template types.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/components/add_xblock_button",
"js/views/components/add_xblock_menu"],
function ($, _, gettext, BaseView, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({
events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type a.single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent',
'click .new-component-templates .new-component-template a': 'createNewComponent',
'click .new-component-templates .cancel-button': 'closeNewComponent'
},
initialize: function(options) {
BaseView.prototype.initialize.call(this, options);
this.template = this.loadTemplate('add-xblock-component');
},
render: function () {
if (!this.$el.html()) {
var that = this;
this.$el.html(this.template({}));
this.collection.each(
function (componentModel) {
var view, menu;
view = new AddXBlockButton({model: componentModel});
that.$el.find('.new-component-type').append(view.render().el);
menu = new AddXBlockMenu({model: componentModel});
that.$el.append(menu.render().el);
}
);
}
},
showComponentTemplates: function(event) {
var type;
event.preventDefault();
event.stopPropagation();
type = $(event.currentTarget).data('type');
this.$('.new-component').slideUp(250);
this.$('.new-component-' + type).slideDown(250);
},
closeNewComponent: function(event) {
event.preventDefault();
event.stopPropagation();
this.$('.new-component').slideDown(250);
this.$('.new-component-templates').slideUp(250);
},
createNewComponent: function(event) {
var self = this,
element = $(event.currentTarget),
saveData = element.data(),
oldOffset = this.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
this.runOperationShowingMessage(
gettext('Adding&hellip;'),
_.bind(this.options.createComponent, this, saveData, element)
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
self.setScrollOffset(self.$el, oldOffset);
});
}
});
return AddXBlockComponent;
}); // end define();
define(["js/views/baseview"],
function (BaseView) {
return BaseView.extend({
tagName: "li",
initialize: function () {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate("add-xblock-component-button");
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
}
});
}); // end define();
define(["jquery", "js/views/baseview"],
function ($, BaseView) {
return BaseView.extend({
className: function () {
return "new-component-templates new-component-" + this.model.type;
},
initialize: function () {
BaseView.prototype.initialize.call(this);
var template_name = this.model.type === "problem" ? "add-xblock-component-menu-problem" :
"add-xblock-component-menu";
this.template = this.loadTemplate(template_name);
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
// Make the tabs on problems into "real tabs"
this.$('.tab-group').tabs();
}
});
}); // end define();
\ No newline at end of file
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"], define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) { function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var reorderableClass = '.reorderable-container',
studioXBlockWrapperClass = '.studio-xblock-wrapper';
var ContainerView = XBlockView.extend({ var ContainerView = XBlockView.extend({
xblockReady: function () { xblockReady: function () {
XBlockView.prototype.xblockReady.call(this); XBlockView.prototype.xblockReady.call(this);
var verticalContainer = this.$('.vertical-container'), var reorderableContainer = this.$(reorderableClass),
alreadySortable = this.$('.ui-sortable'), alreadySortable = this.$('.ui-sortable'),
newParent, newParent,
oldParent, oldParent,
...@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
alreadySortable.sortable("destroy"); alreadySortable.sortable("destroy");
verticalContainer.sortable({ reorderableContainer.sortable({
handle: '.drag-handle', handle: '.drag-handle',
stop: function (event, ui) { stop: function (event, ui) {
var saving, hideSaving, removeFromParent; var saving, hideSaving, removeFromParent;
if (oldParent === undefined) { if (_.isUndefined(oldParent)) {
// If no actual change occurred, // If no actual change occurred,
// oldParent will never have been set. // oldParent will never have been set.
return; return;
...@@ -38,12 +41,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -38,12 +41,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// avoid creating an orphan if the addition fails. // avoid creating an orphan if the addition fails.
if (newParent) { if (newParent) {
removeFromParent = oldParent; removeFromParent = oldParent;
self.reorder(newParent, function () { self.updateChildren(newParent, function () {
self.reorder(removeFromParent, hideSaving); self.updateChildren(removeFromParent, hideSaving);
}); });
} else { } else {
// No new parent, only reordering within same container. // No new parent, only reordering within same container.
self.reorder(oldParent, hideSaving); self.updateChildren(oldParent, hideSaving);
} }
oldParent = undefined; oldParent = undefined;
...@@ -55,7 +58,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -55,7 +58,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// be null if the change is related to the list the element // be null if the change is related to the list the element
// was originally in (the case of a move within the same container // was originally in (the case of a move within the same container
// or the deletion from a container when moving to a new container). // or the deletion from a container when moving to a new container).
var parent = $(event.target).closest('.wrapper-xblock'); var parent = $(event.target).closest(studioXBlockWrapperClass);
if (ui.sender) { if (ui.sender) {
// Move to a new container (the addition part). // Move to a new container (the addition part).
newParent = parent; newParent = parent;
...@@ -69,20 +72,20 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -69,20 +72,20 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
placeholder: 'component-placeholder', placeholder: 'component-placeholder',
forcePlaceholderSize: true, forcePlaceholderSize: true,
axis: 'y', axis: 'y',
items: '> .vertical-element', items: '> .is-draggable',
connectWith: ".vertical-container", connectWith: reorderableClass,
tolerance: "pointer" tolerance: "pointer"
}); });
}, },
reorder: function (targetParent, successCallback) { updateChildren: function (targetParent, successCallback) {
var children, childLocators; var children, childLocators;
// Find descendants with class "wrapper-xblock" whose parent == targetParent. // Find descendants with class "studio-xblock-wrapper" whose parent === targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc. // This is necessary to filter our grandchildren, great-grandchildren, etc.
children = targetParent.find('.wrapper-xblock').filter(function () { children = targetParent.find(studioXBlockWrapperClass).filter(function () {
var parent = $(this).parent().closest('.wrapper-xblock'); var parent = $(this).parent().closest(studioXBlockWrapperClass);
return parent.data('locator') === targetParent.data('locator'); return parent.data('locator') === targetParent.data('locator');
}); });
...@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
} }
} }
}); });
},
refresh: function() {
this.$(reorderableClass).sortable('refresh');
} }
}); });
......
...@@ -36,7 +36,10 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe ...@@ -36,7 +36,10 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe
}; };
var closeModalNew = function () { var closeModalNew = function (e) {
if (e) {
e.preventDefault();
};
$('body').removeClass('modal-window-is-shown'); $('body').removeClass('modal-window-is-shown');
$('.edit-section-publish-settings').removeClass('is-shown'); $('.edit-section-publish-settings').removeClass('is-shown');
}; };
......
...@@ -74,13 +74,24 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], ...@@ -74,13 +74,24 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
if (!element) { if (!element) {
element = this.$el; element = this.$el;
} }
// First render the HTML as the scripts might depend upon it
element.html(html); // Render the HTML first as the scripts might depend upon it, and then
// Now asynchronously add the resources to the page // asynchronously add the resources to the page.
this.updateHtml(element, html);
return this.addXBlockFragmentResources(resources); return this.addXBlockFragmentResources(resources);
}, },
/** /**
* Updates an element to have the specified HTML. The default method sets the HTML
* as child content, but this can be overridden.
* @param element The element to be updated
* @param html The desired HTML.
*/
updateHtml: function(element, html) {
element.html(html);
},
/**
* Dynamically loads all of an XBlock's dependent resources. This is an asynchronous * Dynamically loads all of an XBlock's dependent resources. This is an asynchronous
* process so a promise is returned. * process so a promise is returned.
* @param resources The resources to be rendered * @param resources The resources to be rendered
......
...@@ -190,7 +190,7 @@ ...@@ -190,7 +190,7 @@
@include transition(all $tmg-f3 ease-in-out 0s); @include transition(all $tmg-f3 ease-in-out 0s);
position: fixed; position: fixed;
top: 0; top: 0;
background: $black-t0; background: $black-t1;
width: 100%; width: 100%;
height: 100%; height: 100%;
text-align: center; text-align: center;
...@@ -676,11 +676,6 @@ ...@@ -676,11 +676,6 @@
// prompt showing // prompt showing
&.prompt-is-shown { &.prompt-is-shown {
.wrapper-view {
-webkit-filter: blur(($baseline/10)) grayscale(25%);
filter: blur(($baseline/10)) grayscale(25%);
}
.wrapper-prompt.is-shown { .wrapper-prompt.is-shown {
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
...@@ -694,11 +689,6 @@ ...@@ -694,11 +689,6 @@
// prompt hiding // prompt hiding
&.prompt-is-hiding { &.prompt-is-hiding {
.wrapper-view {
-webkit-filter: blur(($baseline/10)) grayscale(25%);
filter: blur(($baseline/10)) grayscale(25%);
}
.wrapper-prompt { .wrapper-prompt {
.prompt { .prompt {
......
...@@ -48,13 +48,18 @@ ...@@ -48,13 +48,18 @@
// UI: xblocks - calls-to-action // UI: xblocks - calls-to-action
.wrapper-xblock .header-actions { .wrapper-xblock .header-actions {
@extend %actions-header; @extend %actions-header;
.action-button [class^="icon-"] {
font-style: normal;
}
} }
// UI: xblock is collapsible // UI: xblock is collapsible
.wrapper-xblock.is-collapsible, .wrapper-xblock.xblock-type-container { .wrapper-xblock.is-collapsible,
.wrapper-xblock.xblock-type-container {
[class^="icon-"] { [class^="icon-"] {
font-style: normal; font-style: normal;
} }
.expand-collapse { .expand-collapse {
......
...@@ -116,40 +116,6 @@ body.view-container .content-primary { ...@@ -116,40 +116,6 @@ body.view-container .content-primary {
border: 2px dashed $gray-l2; border: 2px dashed $gray-l2;
} }
.vert-mod {
// min-height to allow drop when empty
.vertical-container {
min-height: ($baseline*2.5);
}
.vert {
position: relative;
.drag-handle {
display: none; // only show when vert is draggable
position: absolute;
top: 0;
right: ($baseline/2); // equal to margin on component
width: ($baseline*1.5);
height: ($baseline*2.5);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat scroll center center;
}
}
.is-draggable {
.xblock-header {
padding-right: ($baseline*1.5); // make room for drag handle
}
.drag-handle {
display: block;
}
}
}
.wrapper-xblock { .wrapper-xblock {
@extend %wrap-xblock; @extend %wrap-xblock;
...@@ -165,18 +131,17 @@ body.view-container .content-primary { ...@@ -165,18 +131,17 @@ body.view-container .content-primary {
// CASE: nesting level xblock rendering // CASE: nesting level xblock rendering
&.level-nesting { &.level-nesting {
@include transition(all $tmg-f2 linear 0s); @include transition(all $tmg-f2 linear 0s);
border: none; border: 1px solid $gray-l3;
padding-bottom: $baseline; padding-bottom: $baseline;
box-shadow: none;
&:hover { // min-height to allow drop when empty
background-color: $gray-l6; .reorderable-container {
box-shadow: 0 0 1px $shadow-d2 inset; min-height: $baseline;
} }
.xblock-header { .xblock-header {
@include ui-flexbox(); @include ui-flexbox();
margin-bottom: ($baseline/2); margin-bottom: 0;
border-bottom: none; border-bottom: none;
background: none; background: none;
} }
...@@ -230,6 +195,24 @@ body.view-container .content-primary { ...@@ -230,6 +195,24 @@ body.view-container .content-primary {
} }
} }
} }
// add a new component menu override - most styles currently live in _unit.scss
.new-component-item {
margin: $baseline ($baseline/2);
border: 1px solid $gray-l3;
border-radius: ($baseline/4);
box-shadow: 0 1px 3px $shadow inset;
background-color: $gray-l5;
padding: ($baseline/2);
h5 {
margin-bottom: ($baseline*.75);
}
.new-component-type a {
margin-bottom: ($baseline/2);
}
}
} }
// ==================== // ====================
......
...@@ -517,7 +517,7 @@ ...@@ -517,7 +517,7 @@
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
display: none; display: none;
position: absolute; position: fixed;
top: 0; top: 0;
overflow: scroll; overflow: scroll;
background: $black-t2; background: $black-t2;
......
...@@ -19,112 +19,16 @@ ...@@ -19,112 +19,16 @@
<%block name="jsextra"> <%block name="jsextra">
<script type="text/javascript"> <script type="text/javascript">
require(["domReady", "jquery", "js/models/asset", "js/collections/asset", require(["jquery", "js/collections/asset", "js/views/assets", "jquery.fileupload"],
"js/views/assets", "js/views/feedback_prompt", function($, AssetCollection, AssetsView) {
"js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer",
"js/utils/modal", "jquery.fileupload"], var assets = new AssetCollection();
function(domReady, $, AssetModel, AssetCollection, AssetsView, PromptView, NotificationView, assets.url = "${asset_callback_url}";
PagingHeader, PagingFooter, ModalUtils) { var assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')});
var assets = new AssetCollection(); assetsView.render();
assets.url = "${asset_callback_url}"; }); // end of require()
var assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')}); </script>
assetsView.render();
assetsView.setPage(0);
var hideModal = function (e) {
if (e) {
e.preventDefault();
}
$('.file-input').unbind('change.startUpload');
ModalUtils.hideModal();
};
var showUploadModal = function (e) {
e.preventDefault();
resetUploadModal();
ModalUtils.showModal();
$('.file-input').bind('change', startUpload);
$('.upload-modal .file-chooser').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 100 * 1000 * 1000, // 100 MB
autoUpload: true,
progressall: function(e, data) {
var percentComplete = parseInt((100 * data.loaded) / data.total, 10);
showUploadFeedback(e, percentComplete);
},
maxFileSize: 100 * 1000 * 1000, // 100 MB
maxNumberofFiles: 100,
add: function(e, data) {
data.process().done(function () {
data.submit();
});
},
done: function(e, data) {
displayFinishedUpload(data.result);
}
});
};
var showFileSelectionMenu = function(e) {
e.preventDefault();
$('.file-input').click();
};
var startUpload = function (e) {
var file = e.target.value;
$('.upload-modal h1').text("${_(u'Uploading…')}");
$('.upload-modal .file-name').html(file.substring(file.lastIndexOf("\\") + 1));
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
};
var resetUploadModal = function () {
// Reset modal so it no longer displays information about previously
// completed uploads.
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
$('.upload-modal .progress-bar').hide();
$('.upload-modal .file-name').show();
$('.upload-modal .file-name').html('');
$('.upload-modal .choose-file-button').text("${_('Choose File')}");
$('.upload-modal .embeddable-xml-input').val('');
$('.upload-modal .embeddable').hide();
};
var showUploadFeedback = function (event, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
};
var displayFinishedUpload = function (resp) {
var asset = resp.asset;
$('.upload-modal h1').text("${_('Upload New File')}");
$('.upload-modal .embeddable-xml-input').val(asset.portable_url);
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').text("${_('Load Another File')}").show();
$('.upload-modal .progress-fill').width('100%');
assetsView.addAsset(new AssetModel(asset));
};
domReady(function() {
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
});
}); // end of require()
</script>
</%block> </%block>
<%block name="content"> <%block name="content">
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
%> %>
<%block name="title">Course Checklists</%block> <%block name="title">${_("Course Checklists")}</%block>
<%block name="bodyclass">is-signedin course view-checklists</%block> <%block name="bodyclass">is-signedin course view-checklists</%block>
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
...@@ -64,7 +64,7 @@ require(["domReady!", "jquery", "js/collections/checklist", "js/views/checklist" ...@@ -64,7 +64,7 @@ require(["domReady!", "jquery", "js/collections/checklist", "js/views/checklist"
</div> </div>
<div class="bit"> <div class="bit">
<h3 class="title title-3">Studio checklists</h3> <h3 class="title title-3">${_("Studio checklists")}</h3>
<nav class="nav-page checklists-current"> <nav class="nav-page checklists-current">
<ol> <ol>
% for checklist in checklists: % for checklist in checklists:
......
...@@ -26,7 +26,5 @@ ...@@ -26,7 +26,5 @@
</li> </li>
</ul> </ul>
</div> </div>
% if not xblock_context['read_only']: <span data-tooltip="${_("Drag to reorder")}" class="drag-handle action"></span>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
${preview} ${preview}
...@@ -31,15 +31,18 @@ main_xblock_info = { ...@@ -31,15 +31,18 @@ main_xblock_info = {
%> %>
<script type='text/javascript'> <script type='text/javascript'>
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container", require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage) { function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates) {
var view, mainXBlockInfo; var view, mainXBlockInfo;
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n}); mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n});
view = new ContainerPage({ view = new ContainerPage({
el: $('#content'), el: $('#content'),
model: mainXBlockInfo model: mainXBlockInfo,
templates: templates
}); });
view.render(); view.render();
}); });
...@@ -80,7 +83,7 @@ main_xblock_info = { ...@@ -80,7 +83,7 @@ main_xblock_info = {
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<section class="wrapper-xblock level-page is-hidden" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}"> <section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
</section> </section>
<div class="no-container-content is-hidden"> <div class="no-container-content is-hidden">
<p>${_("This page has no content yet.")}</p> <p>${_("This page has no content yet.")}</p>
......
...@@ -18,10 +18,10 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -18,10 +18,10 @@ from contentstore.views.helpers import xblock_studio_url
<i class="icon-arrow-right"></i> <i class="icon-arrow-right"></i>
</a> </a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
</section> </section>
...@@ -125,7 +125,7 @@ ...@@ -125,7 +125,7 @@
</div> </div>
% if tab.is_movable: % if tab.is_movable:
<div class="drag-handle" data-tooltip="${_('Drag to reorder')}"> <div class="drag-handle action" data-tooltip="${_('Drag to reorder')}">
<span class="sr">${_("Drag to reorder")}</span> <span class="sr">${_("Drag to reorder")}</span>
</div> </div>
% else: % else:
......
<% if (type === 'advanced' || templates.length > 1) { %>
<a href="#" class="multiple-templates" data-type="<%= type %>">
<% } else { %>
<a href="#" class="single-template" data-type="<%= type %>" data-category="<%= templates[0].category %>">
<% } %>
<span class="large-template-icon large-<%= type %>-icon"></span>
<span class="name"><%= type %></span>
</a>
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li>
<li>
<a class="link-tab" href="#tab2"><%= gettext("Advanced") %></a>
</li>
</ul>
<div class="tab current" id="tab1">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].is_common) { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
<% } %>
</ul>
</div>
<div class="tab" id="tab2">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].is_common) { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% if (type === 'advanced' || templates.length > 1) { %>
<div class="tab current" id="tab1">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% } %>
<div class="new-component">
<h5><%= gettext("Add New Component") %></h5>
<ul class="new-component-type">
</ul>
</div>
<div class="upload-modal modal" style="display: none;">
<a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr"><%= gettext('close') %></span></a>
<div class="modal-body">
<h1 class="title"><%= gettext("Upload New File") %></h1>
<p class="file-name">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="embeddable">
<label>URL:</label>
<input type="text" class="embeddable-xml-input" value='' readonly>
</div>
<form class="file-chooser" action="asset-url"
method="post" enctype="multipart/form-data">
<a href="#" class="choose-file-button"><%= gettext("Choose File") %></a>
<input type="file" class="file-input" name="file">
</form>
</div>
</div>
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
<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">
<a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a> <a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a>
<a href="#" class="navigation-link navigation-current">Nested Vertical Test</a> <a href="#" class="navigation-link navigation-current">Nested Vertical Test</a>
</small> </small>
...@@ -23,7 +22,7 @@ ...@@ -23,7 +22,7 @@
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<section class="wrapper-xblock level-page" data-locator="TestCourse/branch/draft/block/vertical131"> <section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="TestCourse/branch/draft/block/vertical131">
</section> </section>
<div class="no-container-content is-hidden"> <div class="no-container-content is-hidden">
<p>This page has no content yet.</p> <p>This page has no content yet.</p>
...@@ -37,6 +36,4 @@ ...@@ -37,6 +36,4 @@
</section> </section>
</div> </div>
</div> </div>
</div>
<div id="page-notification"></div>
</div>
\ No newline at end of file
...@@ -14,9 +14,7 @@ ...@@ -14,9 +14,7 @@
</header> </header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule xblock-initialized" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-usage-id="i4x:;_;_AndyA;_ABT101;_vertical;_131a499ddaa3474194c1aa2eced34455" data-type="None" data-block-type="vertical"> <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule xblock-initialized" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-usage-id="i4x:;_;_AndyA;_ABT101;_vertical;_131a499ddaa3474194c1aa2eced34455" data-type="None" data-block-type="vertical">
<div class="vert-mod"> <ol class="reorderable-container">
<div class="vert vert-0" data-id="i4x://AndyA/ABT101/vertical/2758bbc495dd40d59050da15b40bd9a5"> </ol>
</div>
</div>
</div> </div>
</article> </article>
<div class="wrapper wrapper-component-action-header">
<div class="component-header">Mock Component</div>
<ul class="component-actions">
<li class="action-item action-edit">
<a href="#" class="edit-button action-button">
<i class="icon-pencil"></i>
<span class="action-button-text">Edit</span>
</a>
</li>
<li class="action-item action-duplicate">
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">
<i class="icon-copy"></i>
<span class="sr">Duplicate this component</span>
</a>
</li>
<li class="action-item action-delete">
<a href="#" data-tooltip="Delete" class="delete-button action-button">
<i class="icon-trash"></i>
<span class="sr">Delete this component</span>
</a>
</li>
</ul>
</div>
<div class="xblock xblock-student_view" data-runtime-version="1" data-usage-id="i4x:;_;_edX;_mock"
data-init="MockXBlock" data-runtime-class="StudioRuntime" data-block-type="mock" tabindex="0">
<h2>Mock Component</h2>
</div>
<div id="content">
<div class="main-wrapper edit-state-draft" data-locator="unit_locator">
<div class="inner-wrapper">
<div class="alert editing-draft-alert">
<p class="alert-message"><strong>You are editing a draft.</strong></p>
<a href="#" target="_blank" class="alert-action secondary">View the Live Version</a>
</div>
<div class="main-column">
<article class="unit-body window">
<p class="unit-name-input"><label for="unit-display-name-input">Display Name:</label><input type="text" value="Mock Unit" id="unit-display-name-input" class="unit-display-name-input"></p>
<ol class="components ui-sortable">
<li class="component" data-locator="loc_1"></li>
<li class="component" data-locator="loc_2"></li>
<li class="add-xblock-component new-component-item adding"></li>
</ol>
</article>
</div>
<div class="sidebar">
<div class="unit-settings window">
<h4 class="header">Unit Settings</h4>
<div class="window-contents">
<div class="row visibility">
<label for="visibility-select" class="inline-label">Visibility:</label>
<select name="visibility-select" id="visibility-select" class="visibility-select">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
<div class="row published-alert">
<p class="edit-draft-message">This unit has been published. To make changes, you must <a href="#" class="create-draft">edit a draft</a>.</p>
<p class="publish-draft-message">This is a draft of the published unit. To update the live version, you must <a href="#" class="publish-draft">replace it with this draft</a>.</p>
</div>
<div class="row status">
<p>
This unit is scheduled to be released to <strong>students</strong> on <strong>Jan 01, 2030 at 00:00 UTC</strong> with the subsection <a href="/subsection/AndyA.EBT1.EBT1/branch/draft/block/sequential544">Lesson 1</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<header class="xblock-header"> <li class="studio-xblock-wrapper is-draggable">
<div class="header-details"> <header class="xblock-header">
<span>Mock XBlock</span> <div class="header-details">
</div> <span>Mock XBlock</span>
<div class="header-actions"> </div>
<ul class="actions-list"> <div class="header-actions">
<li class="sr action-item">No Actions</li> <ul class="actions-list">
</ul> <li class="sr action-item">No Actions</li>
</div> </ul>
</header> </div>
<article class="xblock-render"> </header>
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule" <article class="xblock-render">
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
data-type="None"> data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
<div class="mock-updated-content">Mock Update</div> data-type="None">
</div> <div class="mock-updated-content">Mock Update</div>
</article> </div>
</article>
</li>
<header class="xblock-header"> <li class="studio-xblock-wrapper is-draggable">
<div class="header-details"> <header class="xblock-header">
<span>Mock XBlock</span> <div class="header-details">
</div> <span>Mock XBlock</span>
<div class="header-actions"> </div>
<ul class="actions-list"> <div class="header-actions">
<li class="sr action-item">No Actions</li> <ul class="actions-list">
</ul> <li class="action-item action-drag">
</div> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</header> </li>
<article class="xblock-render"> </ul>
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule" </div>
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" </header>
data-type="None"> <article class="xblock-render">
<p>Mock XBlock</p> <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
</div> data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
</article> data-type="None">
<p>Mock XBlock</p>
</div>
</article>
</li>
...@@ -83,7 +83,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -83,7 +83,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a href="#" data-tooltip="${_('Delete this section')}" class="delete-button delete-section-button"><span class="delete-icon"></span></a> <a href="#" data-tooltip="${_('Delete this section')}" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<span data-tooltip="${_('Drag to re-order')}" class="drag-handle"></span> <span data-tooltip="${_('Drag to re-order')}" class="drag-handle action"></span>
</div> </div>
</header> </header>
</section> </section>
...@@ -193,7 +193,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -193,7 +193,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<a href="#" data-tooltip="${_('Delete this section')}" class="action delete-section-button"><i class="icon-trash"></i> <span class="sr">${_('Delete section')}</span></a> <a href="#" data-tooltip="${_('Delete this section')}" class="action delete-section-button"><i class="icon-trash"></i> <span class="sr">${_('Delete section')}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle section-drag-handle action"><span class="sr"> ${_("Drag to reorder section")}</span></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle section-drag-handle"><span class="sr"> ${_("Drag to reorder section")}</span></span>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -228,7 +228,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -228,7 +228,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<a href="#" data-tooltip="${_('Delete this subsection')}" class="action delete-subsection-button"><i class="icon-trash"></i> <span class="sr">${_("Delete subsection")}</span></a> <a href="#" data-tooltip="${_('Delete this subsection')}" class="action delete-subsection-button"><i class="icon-trash"></i> <span class="sr">${_("Delete subsection")}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle subsection-drag-handle action"></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle subsection-drag-handle"></span>
</li> </li>
</ul> </ul>
</div> </div>
......
<%!
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
%>
<%namespace name='static' file='static_content.html'/>
% if is_reorderable:
<li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location}">
% else:
<div class="studio-xblock-wrapper">
% endif
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${xblock.location}">
<header class="xblock-header">
<div class="header-details">
${xblock.display_name_with_default}
</div>
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-view">
<a href="${xblock_studio_url(xblock)}" class="action-button">
## Translators: this is a verb describing the action of viewing more details
<span class="action-button-text">${_('View')}</span>
<i class="icon-arrow-right"></i>
</a>
</li>
% if not xblock_context['read_only'] and is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
</ul>
</div>
</header>
</section>
% if is_reorderable:
</li>
% else:
</div>
% endif
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from django.conf import settings %>
% if xblock.location != xblock_context['root_xblock'].location: % if not is_root:
<% section_class = "level-nesting" if xblock.has_children else "level-element" %> % if is_reorderable:
<section class="wrapper-xblock ${section_class}" data-locator="${xblock.location}" data-display-name="${xblock.display_name_with_default | h}" data-category="${xblock.category | h}" data-course-key="${xblock.location.course_key}"> <li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location}">
% else:
<div class="studio-xblock-wrapper" data-locator="${xblock.location}">
% endif
<%
section_class = "level-nesting" if xblock.has_children else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else ""
%>
<section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}">
% endif % endif
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
${xblock.display_name_with_default | h} % if xblock.has_children:
<a href="#" data-tooltip="${_('Expand or Collapse')}" class="action expand-collapse collapse">
<i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">${_('Expand or Collapse')}</span>
</a>
% endif
<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">
% if not xblock_context['read_only']: % if not xblock_context['read_only']:
% if not xblock.has_children:
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <a href="#" class="edit-button action-button">
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span> <span class="action-button-text">${_("Edit")}</span>
</a> </a>
</li> </li>
% endif
%if settings.FEATURES.get('ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'):
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button"> <a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
<i class="icon-copy"></i> <i class="icon-copy"></i>
<span class="sr">${_("Duplicate")}</span> <span class="sr">${_("Duplicate")}</span>
</a> </a>
</li> </li>
% endif
%if settings.FEATURES.get('ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'):
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button"> <a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i> <i class="icon-trash"></i>
<span class="sr">${_("Delete")}</span> <span class="sr">${_("Delete")}</span>
</a> </a>
</li> </li>
% endif
% if not is_root and is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
% endif % endif
</ul> </ul>
</div> </div>
...@@ -43,6 +60,11 @@ ...@@ -43,6 +60,11 @@
${content} ${content}
</article> </article>
% if xblock.location != xblock_context['root_xblock'].location: % if not is_root:
</section> </section>
% if is_reorderable:
</li>
% else:
</div>
% endif
% endif % endif
...@@ -20,21 +20,21 @@ from django.utils.translation import ugettext as _ ...@@ -20,21 +20,21 @@ from django.utils.translation import ugettext as _
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <script type='text/javascript'>
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "jquery.ui", require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "js/collections/component_template",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "jquery.ui", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, ModuleModel, UnitEditView, ui) { function(doc, $, ModuleModel, UnitEditView, ComponentTemplates) {
window.unit_location_analytics = '${unit_locator}'; window.unit_location_analytics = '${unit_usage_key}';
// tabs var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
$('.tab-group').tabs();
new UnitEditView({ new UnitEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
view: 'unit', view: 'unit',
model: new ModuleModel({ model: new ModuleModel({
id: '${unit_locator}', id: '${unit_usage_key}',
state: '${unit_state}' state: '${unit_state}'
}) }),
templates: templates
}); });
$('.new-component-template').each(function(){ $('.new-component-template').each(function(){
...@@ -46,7 +46,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -46,7 +46,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
</%block> </%block>
<%block name="content"> <%block name="content">
<div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_locator}" data-course-key="${unit_locator.course_key}"> <div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_usage_key}" data-course-key="${unit_usage_key.course_key}">
<div class="inner-wrapper"> <div class="inner-wrapper">
<div class="alert editing-draft-alert"> <div class="alert editing-draft-alert">
<p class="alert-message"><strong>${_("You are editing a draft.")}</strong> <p class="alert-message"><strong>${_("You are editing a draft.")}</strong>
...@@ -60,90 +60,11 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -60,90 +60,11 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
<article class="unit-body window"> <article class="unit-body window">
<p class="unit-name-input"><label for="unit-display-name-input">${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" id="unit-display-name-input" class="unit-display-name-input" /></p> <p class="unit-name-input"><label for="unit-display-name-input">${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" id="unit-display-name-input" class="unit-display-name-input" /></p>
<ol class="components"> <ol class="components">
% for xblock in xblocks: % for usage_key in child_usage_keys:
<li class="component" data-locator="${xblock.location}" data-course-key="${xblock.location.course_key}"/> <li class="component" data-locator="${usage_key}" data-course-key="${usage_key.course_key}"/>
% endfor % endfor
<li class="new-component-item adding">
<div class="new-component">
<h5>${_("Add New Component")}</h5>
<ul class="new-component-type">
% for type, templates in sorted(component_templates.items()):
<li>
% if type == 'advanced' or len(templates) > 1:
<a href="#" class="multiple-templates" data-type="${type}">
% else:
% for __, category, __, __ in templates:
<a href="#" class="single-template" data-type="${type}" data-category="${category}">
% endfor
% endif
<span class="large-template-icon large-${type}-icon"></span>
<span class="name">${type}</span>
</a>
</li>
% endfor
</ul>
</div>
% for type, templates in sorted(component_templates.items()):
% if len(templates) > 1 or type == 'advanced':
<div class="new-component-templates new-component-${type}">
% if type == "problem":
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
</li>
<li>
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
</li>
</ul>
% endif
<div class="tab current" id="tab1">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if has_markdown or type != "problem":
% if boilerplate_name is None:
<li class="editor-md empty">
<a href="#" data-category="${category}">
<span class="name">${name}</span>
</a>
</li>
% else:
<li class="editor-md">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endif
%endfor
</ul>
</div>
% if type == "problem":
<div class="tab" id="tab2">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if not has_markdown:
<li class="editor-manual">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endfor
</ul>
</div>
</div>
% endif
<a href="#" class="cancel-button">Cancel</a>
</div>
% endif
% endfor
</li>
</ol> </ol>
<div class="add-xblock-component new-component-item adding"></div>
</article> </article>
</div> </div>
......
...@@ -127,7 +127,7 @@ from django.utils.translation import ugettext as _ ...@@ -127,7 +127,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_HtmlModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_html;_c8fb4780eb554aec95c6231680eb82cf/handler" data-type="HTMLModule" data-block-type="html"> <section class="xblock xblock-student_view xmodule_display xmodule_HtmlModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_html;_c8fb4780eb554aec95c6231680eb82cf/handler" data-type="HTMLModule" data-block-type="html">
<ol> <ol>
<li> <li>
...@@ -306,7 +306,7 @@ from django.utils.translation import ugettext as _ ...@@ -306,7 +306,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_VideoModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_video;_da30d8c1da6d43268152e19089ecc2fa/handler" data-type="Video" data-block-type="video"> <section class="xblock xblock-student_view xmodule_display xmodule_VideoModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_video;_da30d8c1da6d43268152e19089ecc2fa/handler" data-type="Video" data-block-type="video">
...@@ -561,7 +561,7 @@ from django.utils.translation import ugettext as _ ...@@ -561,7 +561,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_CapaModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler" data-type="Problem" data-block-type="problem"> <section class="xblock xblock-student_view xmodule_display xmodule_CapaModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler" data-type="Problem" data-block-type="problem">
<section id="problem_i4x-andya-AA101-problem-2fa3ab8048514b73b36e8807a42b3525" class="problems-wrapper" data-problem-id="i4x://andya/AA101/problem/2fa3ab8048514b73b36e8807a42b3525" data-url="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler/xmodule_handler" data-progress_status="0" data-progress_detail="0"> <section id="problem_i4x-andya-AA101-problem-2fa3ab8048514b73b36e8807a42b3525" class="problems-wrapper" data-problem-id="i4x://andya/AA101/problem/2fa3ab8048514b73b36e8807a42b3525" data-url="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler/xmodule_handler" data-progress_status="0" data-progress_detail="0">
......
...@@ -34,7 +34,7 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -34,7 +34,7 @@ This def will enumerate through a passed in subsection and list all of the units
<a href="#" data-tooltip="${_("Delete this unit")}" class="delete-unit-button action" data-locator="${unit.location}"><i class="icon-trash"></i><span class="sr">${_("Delete unit")}</span></a> <a href="#" data-tooltip="${_("Delete this unit")}" class="delete-unit-button action" data-locator="${unit.location}"><i class="icon-trash"></i><span class="sr">${_("Delete unit")}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_("Drag to sort")}" class="drag-handle unit-drag-handle action"><span class="sr"> ${_("Drag to reorder unit")}</span></span> <span data-tooltip="${_("Drag to sort")}" class="drag-handle unit-drag-handle"><span class="sr"> ${_("Drag to reorder unit")}</span></span>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -53,6 +53,3 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -53,6 +53,3 @@ This def will enumerate through a passed in subsection and list all of the units
</li> </li>
</ol> </ol>
</%def> </%def>
...@@ -7,7 +7,7 @@ from django import forms ...@@ -7,7 +7,7 @@ from django import forms
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
from embargo.fixtures.country_codes import COUNTRY_CODES from embargo.fixtures.country_codes import COUNTRY_CODES
import socket import ipaddr
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
...@@ -82,21 +82,12 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol ...@@ -82,21 +82,12 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol
class Meta: # pylint: disable=missing-docstring class Meta: # pylint: disable=missing-docstring
model = IPFilter model = IPFilter
def _is_valid_ipv4(self, address): def _is_valid_ip(self, address):
"""Whether or not address is a valid ipv4 address""" """Whether or not address is a valid ipv4 address or ipv6 address"""
try: try:
# Is this an ipv4 address? # Is this an valid ip address?
socket.inet_pton(socket.AF_INET, address) ipaddr.IPNetwork(address)
except socket.error: except ValueError:
return False
return True
def _is_valid_ipv6(self, address):
"""Whether or not address is a valid ipv6 address"""
try:
# Is this an ipv6 address?
socket.inet_pton(socket.AF_INET6, address)
except socket.error:
return False return False
return True return True
...@@ -111,7 +102,7 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol ...@@ -111,7 +102,7 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol
error_addresses = [] error_addresses = []
for addr in addresses.split(','): for addr in addresses.split(','):
address = addr.strip() address = addr.strip()
if not (self._is_valid_ipv4(address) or self._is_valid_ipv6(address)): if not self._is_valid_ip(address):
error_addresses.append(address) error_addresses.append(address)
if error_addresses: if error_addresses:
msg = 'Invalid IP Address(es): {0}'.format(error_addresses) msg = 'Invalid IP Address(es): {0}'.format(error_addresses)
......
""" """Middleware for embargoing site and courses.
Middleware for embargoing courses.
IMPORTANT NOTE: This code WILL NOT WORK if you have a misconfigured proxy IMPORTANT NOTE: This code WILL NOT WORK if you have a misconfigured proxy
server. If you are configuring embargo functionality, or if you are server. If you are configuring embargo functionality, or if you are
experiencing mysterious problems with embargoing, please check that your experiencing mysterious problems with embargoing, please check that your
reverse proxy is setting any of the well known client IP address headers (ex., reverse proxy is setting any of the well known client IP address headers (ex.,
HTTP_X_FORWARDED_FOR). HTTP_X_FORWARDED_FOR).
This middleware allows you to:
* Embargoing courses (access restriction by courses)
* Embargoing site (access restriction of the main site)
Embargo can restrict by states and whitelist/blacklist (IP Addresses
(ie. 10.0.0.0) or Networks (ie. 10.0.0.0/24)).
Usage:
# Enable the middleware in your settings
# To enable Embargo for particular courses, set:
FEATURES['EMBARGO'] = True # blocked ip will be redirected to /embargo
# To enable the Embargo feature for the whole site, set:
FEATURES['SITE_EMBARGOED'] = True
# With SITE_EMBARGOED, you can define an external url to redirect with:
EMBARGO_SITE_REDIRECT_URL = 'https://www.edx.org/'
# if EMBARGO_SITE_REDIRECT_URL is missing, a HttpResponseForbidden is returned.
""" """
import logging import logging
import pygeoip import pygeoip
...@@ -13,6 +36,7 @@ import pygeoip ...@@ -13,6 +36,7 @@ import pygeoip
from django.core.exceptions import MiddlewareNotUsed from django.core.exceptions import MiddlewareNotUsed
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django.http import HttpResponseRedirect, HttpResponseForbidden
from ipware.ip import get_ip from ipware.ip import get_ip
from util.request import course_id_from_url from util.request import course_id_from_url
...@@ -23,14 +47,16 @@ log = logging.getLogger(__name__) ...@@ -23,14 +47,16 @@ log = logging.getLogger(__name__)
class EmbargoMiddleware(object): class EmbargoMiddleware(object):
""" """
Middleware for embargoing courses Middleware for embargoing site and courses
This is configured by creating ``EmbargoedCourse``, ``EmbargoedState``, and This is configured by creating ``EmbargoedCourse``, ``EmbargoedState``, and
optionally ``IPFilter`` rows in the database, using the django admin site. optionally ``IPFilter`` rows in the database, using the django admin site.
""" """
def __init__(self): def __init__(self):
self.site_enabled = settings.FEATURES.get('SITE_EMBARGOED', False)
# If embargoing is turned off, make this middleware do nothing # If embargoing is turned off, make this middleware do nothing
if not settings.FEATURES.get('EMBARGO', False): if not settings.FEATURES.get('EMBARGO', False) and \
not self.site_enabled:
raise MiddlewareNotUsed() raise MiddlewareNotUsed()
def process_request(self, request): def process_request(self, request):
...@@ -39,23 +65,41 @@ class EmbargoMiddleware(object): ...@@ -39,23 +65,41 @@ class EmbargoMiddleware(object):
""" """
url = request.path url = request.path
course_id = course_id_from_url(url) course_id = course_id_from_url(url)
course_is_embargoed = EmbargoedCourse.is_embargoed(course_id)
# If they're trying to access a course that cares about embargoes # If they're trying to access a course that cares about embargoes
if EmbargoedCourse.is_embargoed(course_id): if self.site_enabled or course_is_embargoed:
response = redirect('embargo')
# Set the proper response if site is enabled
if self.site_enabled:
redirect_url = getattr(settings, 'EMBARGO_SITE_REDIRECT_URL', None)
response = HttpResponseRedirect(redirect_url) if redirect_url \
else HttpResponseForbidden('Access Denied')
# If we're having performance issues, add caching here # If we're having performance issues, add caching here
ip_addr = get_ip(request) ip_addr = get_ip(request)
# if blacklisted, immediately fail # if blacklisted, immediately fail
if ip_addr in IPFilter.current().blacklist_ips: if ip_addr in IPFilter.current().blacklist_ips:
log.info("Embargo: Restricting IP address %s to course %s because IP is blacklisted.", ip_addr, course_id) if course_is_embargoed:
return redirect('embargo') msg = "Embargo: Restricting IP address %s to course %s because IP is blacklisted." % \
(ip_addr, course_id)
else:
msg = "Embargo: Restricting IP address %s because IP is blacklisted." % ip_addr
log.info(msg)
return response
country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr) country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
is_embargoed = country_code_from_ip in EmbargoedState.current().embargoed_countries_list is_embargoed = country_code_from_ip in EmbargoedState.current().embargoed_countries_list
# Fail if country is embargoed and the ip address isn't explicitly whitelisted # Fail if country is embargoed and the ip address isn't explicitly whitelisted
if is_embargoed and ip_addr not in IPFilter.current().whitelist_ips: if is_embargoed and ip_addr not in IPFilter.current().whitelist_ips:
log.info( if course_is_embargoed:
"Embargo: Restricting IP address %s to course %s because IP is from country %s.", msg = "Embargo: Restricting IP address %s to course %s because IP is from country %s." % \
ip_addr, course_id, country_code_from_ip (ip_addr, course_id, country_code_from_ip)
) else:
return redirect('embargo') msg = "Embargo: Restricting IP address %s because IP is from country %s." % \
(ip_addr, country_code_from_ip)
log.info(msg)
return response
...@@ -10,6 +10,9 @@ file and check it in at the same time as your model changes. To do that, ...@@ -10,6 +10,9 @@ file and check it in at the same time as your model changes. To do that,
2. ./manage.py lms schemamigration embargo --auto description_of_your_change 2. ./manage.py lms schemamigration embargo --auto description_of_your_change
3. Add the migration file created in edx-platform/common/djangoapps/embargo/migrations/ 3. Add the migration file created in edx-platform/common/djangoapps/embargo/migrations/
""" """
import ipaddr
from django.db import models from django.db import models
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
...@@ -83,6 +86,30 @@ class IPFilter(ConfigurationModel): ...@@ -83,6 +86,30 @@ class IPFilter(ConfigurationModel):
help_text="A comma-separated list of IP addresses that should fall under embargo restrictions." help_text="A comma-separated list of IP addresses that should fall under embargo restrictions."
) )
class IPFilterList(object):
"""
Represent a list of IP addresses with support of networks.
"""
def __init__(self, ips):
self.networks = [ipaddr.IPNetwork(ip) for ip in ips]
def __iter__(self):
for network in self.networks:
yield network
def __contains__(self, ip):
try:
ip = ipaddr.IPAddress(ip)
except ValueError:
return False
for network in self.networks:
if network.Contains(ip):
return True
return False
@property @property
def whitelist_ips(self): def whitelist_ips(self):
""" """
...@@ -90,7 +117,7 @@ class IPFilter(ConfigurationModel): ...@@ -90,7 +117,7 @@ class IPFilter(ConfigurationModel):
""" """
if self.whitelist == '': if self.whitelist == '':
return [] return []
return [addr.strip() for addr in self.whitelist.split(',')] # pylint: disable=no-member return self.IPFilterList([addr.strip() for addr in self.whitelist.split(',')]) # pylint: disable=no-member
@property @property
def blacklist_ips(self): def blacklist_ips(self):
...@@ -99,4 +126,4 @@ class IPFilter(ConfigurationModel): ...@@ -99,4 +126,4 @@ class IPFilter(ConfigurationModel):
""" """
if self.blacklist == '': if self.blacklist == '':
return [] return []
return [addr.strip() for addr in self.blacklist.split(',')] # pylint: disable=no-member return self.IPFilterList([addr.strip() for addr in self.blacklist.split(',')]) # pylint: disable=no-member
...@@ -156,8 +156,8 @@ class IPFilterFormTest(TestCase): ...@@ -156,8 +156,8 @@ class IPFilterFormTest(TestCase):
# should be able to do both ipv4 and ipv6 # should be able to do both ipv4 and ipv6
# spacing should not matter # spacing should not matter
form_data = { form_data = {
'whitelist': '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101', 'whitelist': '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101, 1.1.0.1/32, 1.0.0.0/24',
'blacklist': ' 18.244.1.5 , 2002:c0a8:101::42, 18.36.22.1' 'blacklist': ' 18.244.1.5 , 2002:c0a8:101::42, 18.36.22.1, 1.0.0.0/16'
} }
form = IPFilterForm(data=form_data) form = IPFilterForm(data=form_data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
...@@ -169,6 +169,20 @@ class IPFilterFormTest(TestCase): ...@@ -169,6 +169,20 @@ class IPFilterFormTest(TestCase):
for addr in '18.244.1.5, 2002:c0a8:101::42, 18.36.22.1'.split(','): for addr in '18.244.1.5, 2002:c0a8:101::42, 18.36.22.1'.split(','):
self.assertIn(addr.strip(), blacklist) self.assertIn(addr.strip(), blacklist)
# Network tests
# ips not in whitelist network
for addr in ['1.1.0.2', '1.0.1.0']:
self.assertNotIn(addr.strip(), whitelist)
# ips in whitelist network
for addr in ['1.1.0.1', '1.0.0.100']:
self.assertIn(addr.strip(), whitelist)
# ips not in blacklist network
for addr in ['2.0.0.0', '1.1.0.0']:
self.assertNotIn(addr.strip(), blacklist)
# ips in blacklist network
for addr in ['1.0.100.0', '1.0.0.10']:
self.assertIn(addr.strip(), blacklist)
# Test clearing by adding an empty list is OK too # Test clearing by adding an empty list is OK too
form_data = { form_data = {
'whitelist': '', 'whitelist': '',
...@@ -183,15 +197,15 @@ class IPFilterFormTest(TestCase): ...@@ -183,15 +197,15 @@ class IPFilterFormTest(TestCase):
def test_add_invalid_ips(self): def test_add_invalid_ips(self):
# test adding invalid ip addresses # test adding invalid ip addresses
form_data = { form_data = {
'whitelist': '.0.0.1, :dead:beef:::', 'whitelist': '.0.0.1, :dead:beef:::, 1.0.0.0/55',
'blacklist': ' 18.244.* , 999999:c0a8:101::42' 'blacklist': ' 18.244.* , 999999:c0a8:101::42, 1.0.0.0/'
} }
form = IPFilterForm(data=form_data) form = IPFilterForm(data=form_data)
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
wmsg = "Invalid IP Address(es): [u'.0.0.1', u':dead:beef:::'] Please fix the error(s) and try again." wmsg = "Invalid IP Address(es): [u'.0.0.1', u':dead:beef:::', u'1.0.0.0/55'] Please fix the error(s) and try again."
self.assertEquals(wmsg, form._errors['whitelist'][0]) # pylint: disable=protected-access self.assertEquals(wmsg, form._errors['whitelist'][0]) # pylint: disable=protected-access
bmsg = "Invalid IP Address(es): [u'18.244.*', u'999999:c0a8:101::42'] Please fix the error(s) and try again." bmsg = "Invalid IP Address(es): [u'18.244.*', u'999999:c0a8:101::42', u'1.0.0.0/'] Please fix the error(s) and try again."
self.assertEquals(bmsg, form._errors['blacklist'][0]) # pylint: disable=protected-access self.assertEquals(bmsg, form._errors['blacklist'][0]) # pylint: disable=protected-access
with self.assertRaisesRegexp(ValueError, "The IPFilter could not be created because the data didn't validate."): with self.assertRaisesRegexp(ValueError, "The IPFilter could not be created because the data didn't validate."):
......
...@@ -130,6 +130,62 @@ class EmbargoMiddlewareTests(TestCase): ...@@ -130,6 +130,62 @@ class EmbargoMiddlewareTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_ip_network_exceptions(self):
# Explicitly whitelist/blacklist some IP networks
IPFilter(
whitelist='1.0.0.1/24',
blacklist='5.0.0.0/16,1.1.0.0/24',
changed_by=self.user,
enabled=True
).save()
# Accessing an embargoed page from a blocked IP that's been whitelisted with a network
# should succeed
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
self.assertEqual(response.status_code, 200)
# Accessing a regular course from a blocked IP that's been whitelisted with a network
# should succeed
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
self.assertEqual(response.status_code, 200)
# Accessing an embargoed course from non-embargoed IP that's been blacklisted with a network
# should cause a redirect
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='5.0.0.100', REMOTE_ADDR='5.0.0.100')
self.assertEqual(response.status_code, 302)
# Following the redirect should give us the embargo page
response = self.client.get(
self.embargoed_page,
HTTP_X_FORWARDED_FOR='5.0.0.100',
REMOTE_ADDR='5.0.0.100',
follow=True
)
self.assertIn(self.embargo_text, response.content)
# Accessing an embargoed course from non-embargoed IP that's been blaclisted with a network
# should cause a redirect
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.1.0.1', REMOTE_ADDR='1.1.0.1')
self.assertEqual(response.status_code, 302)
# Following the redirect should give us the embargo page
response = self.client.get(
self.embargoed_page,
HTTP_X_FORWARDED_FOR='1.1.0.0',
REMOTE_ADDR='1.1.0.0',
follow=True
)
self.assertIn(self.embargo_text, response.content)
# Accessing an embargoed from a blocked IP that's not blacklisted by the network rule.
# should succeed
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.1.1.0', REMOTE_ADDR='1.1.1.0')
self.assertEqual(response.status_code, 200)
# Accessing a regular course from a non-embargoed IP that's been blacklisted
# should succeed
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
self.assertEqual(response.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False}) @mock.patch.dict(settings.FEATURES, {'EMBARGO': False})
def test_countries_embargo_off(self): def test_countries_embargo_off(self):
# When the middleware is turned off, all requests should go through # When the middleware is turned off, all requests should go through
...@@ -157,3 +213,25 @@ class EmbargoMiddlewareTests(TestCase): ...@@ -157,3 +213,25 @@ class EmbargoMiddlewareTests(TestCase):
# Accessing a regular course from a non-embargoed IP that's been blacklisted should succeed # Accessing a regular course from a non-embargoed IP that's been blacklisted should succeed
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0') response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False, 'SITE_EMBARGOED': True})
def test_embargo_off_embargo_site_on(self):
# When the middleware is turned on with SITE, main site access should be restricted
# Accessing a regular page from a blocked IP is denied.
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
self.assertEqual(response.status_code, 403)
# Accessing a regular page from a non blocked IP should succeed
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
self.assertEqual(response.status_code, 200)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False, 'SITE_EMBARGOED': True})
@override_settings(EMBARGO_SITE_REDIRECT_URL='https://www.edx.org/')
def test_embargo_off_embargo_site_on_with_redirect_url(self):
# When the middleware is turned on with SITE_EMBARGOED, main site access
# should be restricted. Accessing a regular page from a blocked IP is
# denied, and redirected to EMBARGO_SITE_REDIRECT_URL rather than returning a 403.
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
self.assertEqual(response.status_code, 302)
...@@ -79,3 +79,19 @@ class EmbargoModelsTest(TestCase): ...@@ -79,3 +79,19 @@ class EmbargoModelsTest(TestCase):
self.assertTrue(whitelist in cwhitelist) self.assertTrue(whitelist in cwhitelist)
cblacklist = IPFilter.current().blacklist_ips cblacklist = IPFilter.current().blacklist_ips
self.assertTrue(blacklist in cblacklist) self.assertTrue(blacklist in cblacklist)
def test_ip_network_blocking(self):
whitelist = '1.0.0.0/24'
blacklist = '1.1.0.0/16'
IPFilter(whitelist=whitelist, blacklist=blacklist).save()
cwhitelist = IPFilter.current().whitelist_ips
self.assertTrue('1.0.0.100' in cwhitelist)
self.assertTrue('1.0.0.10' in cwhitelist)
self.assertFalse('1.0.1.0' in cwhitelist)
cblacklist = IPFilter.current().blacklist_ips
self.assertTrue('1.1.0.0' in cblacklist)
self.assertTrue('1.1.0.1' in cblacklist)
self.assertTrue('1.1.1.0' in cblacklist)
self.assertFalse('1.2.0.0' in cblacklist)
...@@ -208,6 +208,58 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -208,6 +208,58 @@ class ShibSPTest(ModuleStoreTestCase):
# no audit logging calls # no audit logging calls
self.assertEquals(len(audit_log_calls), 0) self.assertEquals(len(audit_log_calls), 0)
def _base_test_extauth_auto_activate_user_with_flag(self, log_user_string="inactive@stanford.edu"):
"""
Tests that FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] means extauth automatically
linked users, activates them, and logs them in
"""
inactive_user = UserFactory.create(email='inactive@stanford.edu')
inactive_user.is_active = False
inactive_user.save()
request = self.request_factory.get('/shib-login')
request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session
request.META.update({
'Shib-Identity-Provider': 'https://idp.stanford.edu/',
'REMOTE_USER': 'inactive@stanford.edu',
'mail': 'inactive@stanford.edu'
})
request.user = AnonymousUser()
with patch('external_auth.views.AUDIT_LOG') as mock_audit_log:
response = shib_login(request)
audit_log_calls = mock_audit_log.method_calls
# reload user from db, since the view function works via db side-effects
inactive_user = User.objects.get(id=inactive_user.id)
self.assertIsNotNone(ExternalAuthMap.objects.get(user=inactive_user))
self.assertTrue(inactive_user.is_active)
self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(request.user, inactive_user)
self.assertEqual(response['Location'], '/')
# verify logging:
self.assertEquals(len(audit_log_calls), 3)
self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string)
method_name, args, _kwargs = audit_log_calls[2]
self.assertEquals(method_name, 'info')
self.assertEquals(len(args), 1)
self.assertIn(u'Login success', args[0])
self.assertIn(log_user_string, args[0])
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
@patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': False})
def test_extauth_auto_activate_user_with_flag_no_squelch(self):
"""
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': False}
"""
self._base_test_extauth_auto_activate_user_with_flag(log_user_string="inactive@stanford.edu")
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
@patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': True})
def test_extauth_auto_activate_user_with_flag_squelch(self):
"""
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': True}
"""
self._base_test_extauth_auto_activate_user_with_flag(log_user_string="user.id: 1")
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set") @unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
def test_registration_form(self): def test_registration_form(self):
""" """
......
...@@ -216,13 +216,23 @@ def _external_login_or_signup(request, ...@@ -216,13 +216,23 @@ def _external_login_or_signup(request,
return _signup(request, eamap, retfun) return _signup(request, eamap, retfun)
if not user.is_active: if not user.is_active:
if settings.FEATURES['SQUELCH_PII_IN_LOGS']: if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
AUDIT_LOG.warning('User {0} is not active after external login'.format(user.id)) # if BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH, we trust external auth and activate any users
# that aren't already active
user.is_active = True
user.save()
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
AUDIT_LOG.info('Activating user {0} due to external auth'.format(user.id))
else:
AUDIT_LOG.info('Activating user "{0}" due to external auth'.format(uname))
else: else:
AUDIT_LOG.warning('User "{0}" is not active after external login'.format(uname)) if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
# TODO: improve error page AUDIT_LOG.warning('User {0} is not active after external login'.format(user.id))
msg = 'Account not yet activated: please look for link in your email' else:
return default_render_failure(request, msg) AUDIT_LOG.warning('User "{0}" is not active after external login'.format(uname))
# TODO: improve error page
msg = 'Account not yet activated: please look for link in your email'
return default_render_failure(request, msg)
login(request, user) login(request, user)
request.session.set_expiry(0) request.session.set_expiry(0)
......
...@@ -6,7 +6,7 @@ from pipeline.utils import guess_type ...@@ -6,7 +6,7 @@ from pipeline.utils import guess_type
from static_replace import try_staticfiles_lookup from static_replace import try_staticfiles_lookup
def compressed_css(package_name): def compressed_css(package_name, raw=False):
package = settings.PIPELINE_CSS.get(package_name, {}) package = settings.PIPELINE_CSS.get(package_name, {})
if package: if package:
package = {package_name: package} package = {package_name: package}
...@@ -15,17 +15,19 @@ def compressed_css(package_name): ...@@ -15,17 +15,19 @@ def compressed_css(package_name):
package = packager.package_for('css', package_name) package = packager.package_for('css', package_name)
if settings.PIPELINE: if settings.PIPELINE:
return render_css(package, package.output_filename) return render_css(package, package.output_filename, raw=raw)
else: else:
paths = packager.compile(package.paths) paths = packager.compile(package.paths)
return render_individual_css(package, paths) return render_individual_css(package, paths, raw=raw)
def render_css(package, path): def render_css(package, path, raw=False):
template_name = package.template_name or "mako/css.html" template_name = package.template_name or "mako/css.html"
context = package.extra_context context = package.extra_context
url = try_staticfiles_lookup(path) url = try_staticfiles_lookup(path)
if raw:
url += "?raw"
context.update({ context.update({
'type': guess_type(path, 'text/css'), 'type': guess_type(path, 'text/css'),
'url': url, 'url': url,
...@@ -33,8 +35,8 @@ def render_css(package, path): ...@@ -33,8 +35,8 @@ def render_css(package, path):
return render_to_string(template_name, context) return render_to_string(template_name, context)
def render_individual_css(package, paths): def render_individual_css(package, paths, raw=False):
tags = [render_css(package, path) for path in paths] tags = [render_css(package, path, raw) for path in paths]
return '\n'.join(tags) return '\n'.join(tags)
......
...@@ -3,19 +3,19 @@ from staticfiles.storage import staticfiles_storage ...@@ -3,19 +3,19 @@ from staticfiles.storage import staticfiles_storage
from pipeline_mako import compressed_css, compressed_js from pipeline_mako import compressed_css, compressed_js
%> %>
<%def name='url(file)'><% <%def name='url(file, raw=False)'><%
try: try:
url = staticfiles_storage.url(file) url = staticfiles_storage.url(file)
except: except:
url = file url = file
%>${url}</%def> %>${url}${"?raw" if raw else ""}</%def>
<%def name='css(group)'> <%def name='css(group, raw=False)'>
% if settings.FEATURES['USE_DJANGO_PIPELINE']: % if settings.FEATURES['USE_DJANGO_PIPELINE']:
${compressed_css(group)} ${compressed_css(group, raw=raw)}
% else: % else:
% for filename in settings.PIPELINE_CSS[group]['source_filenames']: % for filename in settings.PIPELINE_CSS[group]['source_filenames']:
<link rel="stylesheet" href="${staticfiles_storage.url(filename.replace('.scss', '.css'))}" type="text/css" media="all" / > <link rel="stylesheet" href="${staticfiles_storage.url(filename.replace('.scss', '.css'))}${"?raw" if raw else ""}" type="text/css" media="all" / >
% endfor % endfor
%endif %endif
</%def> </%def>
......
'''
Firebase - library to generate a token
License: https://github.com/firebase/firebase-token-generator-python/blob/master/LICENSE
Tweaked and Edited by @danielcebrianr and @lduarte1991
This library will take either objects or strings and use python's built-in encoding
system as specified by RFC 3548. Thanks to the firebase team for their open-source
library. This was made specifically for speaking with the annotation_storage_url and
can be used and expanded, but not modified by anyone else needing such a process.
'''
from base64 import urlsafe_b64encode
import hashlib
import hmac
import sys
try:
import json
except ImportError:
import simplejson as json
__all__ = ['create_token']
TOKEN_SEP = '.'
def create_token(secret, data):
'''
Simply takes in the secret key and the data and
passes it to the local function _encode_token
'''
return _encode_token(secret, data)
if sys.version_info < (2, 7):
def _encode(bytes_data):
'''
Takes a json object, string, or binary and
uses python's urlsafe_b64encode to encode data
and make it safe pass along in a url.
To make sure it does not conflict with variables
we make sure equal signs are removed.
More info: docs.python.org/2/library/base64.html
'''
encoded = urlsafe_b64encode(bytes(bytes_data))
return encoded.decode('utf-8').replace('=', '')
else:
def _encode(bytes_info):
'''
Same as above function but for Python 2.7 or later
'''
encoded = urlsafe_b64encode(bytes_info)
return encoded.decode('utf-8').replace('=', '')
def _encode_json(obj):
'''
Before a python dict object can be properly encoded,
it must be transformed into a jason object and then
transformed into bytes to be encoded using the function
defined above.
'''
return _encode(bytearray(json.dumps(obj), 'utf-8'))
def _sign(secret, to_sign):
'''
This function creates a sign that goes at the end of the
message that is specific to the secret and not the actual
content of the encoded body.
More info on hashing: http://docs.python.org/2/library/hmac.html
The function creates a hashed values of the secret and to_sign
and returns the digested values based the secure hash
algorithm, 256
'''
def portable_bytes(string):
'''
Simply transforms a string into a bytes object,
which is a series of immutable integers 0<=x<=256.
Always try to encode as utf-8, unless it is not
compliant.
'''
try:
return bytes(string, 'utf-8')
except TypeError:
return bytes(string)
return _encode(hmac.new(portable_bytes(secret), portable_bytes(to_sign), hashlib.sha256).digest()) # pylint: disable=E1101
def _encode_token(secret, claims):
'''
This is the main function that takes the secret token and
the data to be transmitted. There is a header created for decoding
purposes. Token_SEP means that a period/full stop separates the
header, data object/message, and signatures.
'''
encoded_header = _encode_json({'typ': 'JWT', 'alg': 'HS256'})
encoded_claims = _encode_json(claims)
secure_bits = '%s%s%s' % (encoded_header, TOKEN_SEP, encoded_claims)
sig = _sign(secret, secure_bits)
return '%s%s%s' % (secure_bits, TOKEN_SEP, sig)
...@@ -879,10 +879,14 @@ class CourseEnrollment(models.Model): ...@@ -879,10 +879,14 @@ class CourseEnrollment(models.Model):
`user` is a Django User object `user` is a Django User object
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
Returns the mode for both inactive and active users.
Returns None if the courseenrollment record does not exist.
""" """
try: try:
record = CourseEnrollment.objects.get(user=user, course_id=course_id) record = CourseEnrollment.objects.get(user=user, course_id=course_id)
if record.is_active:
if hasattr(record, 'mode'):
return record.mode return record.mode
else: else:
return None return None
......
"""
This test will run for firebase_token_generator.py.
"""
from django.test import TestCase
from student.firebase_token_generator import _encode, _encode_json, _encode_token, create_token
class TokenGenerator(TestCase):
"""
Tests for the file firebase_token_generator.py
"""
def test_encode(self):
"""
This tests makes sure that no matter what version of python
you have, the _encode function still returns the appropriate result
for a string.
"""
expected = "dGVzdDE"
result = _encode("test1")
self.assertEqual(expected, result)
def test_encode_json(self):
"""
Same as above, but this one focuses on a python dict type
transformed into a json object and then encoded.
"""
expected = "eyJ0d28iOiAidGVzdDIiLCAib25lIjogInRlc3QxIn0"
result = _encode_json({'one': 'test1', 'two': 'test2'})
self.assertEqual(expected, result)
def test_create_token(self):
"""
Unlike its counterpart in student/views.py, this function
just checks for the encoding of a token. The other function
will test depending on time and user.
"""
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.-p1sr7uwCapidTQ0qB7DdU2dbF-hViKpPNN_5vD10t8"
result1 = _encode_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
result2 = create_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
self.assertEqual(expected, result1)
self.assertEqual(expected, result2)
...@@ -27,7 +27,7 @@ from mock import Mock, patch ...@@ -27,7 +27,7 @@ from mock import Mock, patch
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
from student.views import (process_survey_link, _cert_info, from student.views import (process_survey_link, _cert_info,
change_enrollment, complete_course_mode_info, token) change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
import shoppingcart import shoppingcart
...@@ -491,26 +491,3 @@ class AnonymousLookupTable(TestCase): ...@@ -491,26 +491,3 @@ class AnonymousLookupTable(TestCase):
anonymous_id = anonymous_id_for_user(self.user, self.course.id) anonymous_id = anonymous_id_for_user(self.user, self.course.id)
real_user = user_by_anonymous_id(anonymous_id) real_user = user_by_anonymous_id(anonymous_id)
self.assertEqual(self.user, real_user) self.assertEqual(self.user, real_user)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class Token(ModuleStoreTestCase):
"""
Test for the token generator. This creates a random course and passes it through the token file which generates the
token that will be passed in to the annotation_storage_url.
"""
request_factory = RequestFactory()
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "edx"
def setUp(self):
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.user = User.objects.create(username="username", email="username")
self.req = self.request_factory.post('/token?course_id=edx/100/test_course', {'user': self.user})
self.req.user = self.user
def test_token(self):
expected = HttpResponse("eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAxLTIzVDE5OjM1OjE3LjUyMjEwNC01OjAwIiwgImNvbnN1bWVyS2V5IjogInh4eHh4eHh4LXh4eHgteHh4eC14eHh4LXh4eHh4eHh4eHh4eCIsICJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.OjWz9mzqJnYuzX-f3uCBllqJUa8PVWJjcDy_McfxLvc", mimetype="text/plain")
response = token(self.req)
self.assertEqual(expected.content.split('.')[0], response.content.split('.')[0])
...@@ -44,7 +44,6 @@ from student.models import ( ...@@ -44,7 +44,6 @@ from student.models import (
create_comments_service_user, PasswordHistory create_comments_service_user, PasswordHistory
) )
from student.forms import PasswordResetFormNoActive from student.forms import PasswordResetFormNoActive
from student.firebase_token_generator import create_token
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
from certificates.models import CertificateStatuses, certificate_status_for_student from certificates.models import CertificateStatuses, certificate_status_for_student
...@@ -1857,26 +1856,3 @@ def change_email_settings(request): ...@@ -1857,26 +1856,3 @@ def change_email_settings(request):
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
return JsonResponse({"success": True}) return JsonResponse({"success": True})
@login_required
def token(request):
'''
Return a token for the backend of annotations.
It uses the course id to retrieve a variable that contains the secret
token found in inheritance.py. It also contains information of when
the token was issued. This will be stored with the user along with
the id for identification purposes in the backend.
'''
course_id = SlashSeparatedCourseKey.from_deprecated_string(request.GET.get("course_id"))
course = course_from_id(course_id)
dtnow = datetime.datetime.now()
dtutcnow = datetime.datetime.utcnow()
delta = dtnow - dtutcnow
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
secret = course.annotation_token_secret
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": request.user.email, "ttl": 86400}
newtoken = create_token(secret, custom_data)
response = HttpResponse(newtoken, mimetype="text/plain")
return response
...@@ -1388,7 +1388,7 @@ class StringResponse(LoncapaResponse): ...@@ -1388,7 +1388,7 @@ class StringResponse(LoncapaResponse):
result = re.search(regexp, given) result = re.search(regexp, given)
except Exception as err: except Exception as err:
msg = u'[courseware.capa.responsetypes.stringresponse] {error}: {message}'.format( msg = u'[courseware.capa.responsetypes.stringresponse] {error}: {message}'.format(
error=_(u'error'), error=_('error'),
message=err.message message=err.message
) )
log.error(msg, exc_info=True) log.error(msg, exc_info=True)
...@@ -1415,7 +1415,8 @@ class StringResponse(LoncapaResponse): ...@@ -1415,7 +1415,8 @@ class StringResponse(LoncapaResponse):
def get_answers(self): def get_answers(self):
_ = self.capa_system.i18n.ugettext _ = self.capa_system.i18n.ugettext
separator = u' <b>{}</b> '.format(_(u'or')) # Translators: Separator used in StringResponse to display multiple answers. Example: "Answer: Answer_1 or Answer_2 or Answer_3".
separator = u' <b>{}</b> '.format(_('or'))
return {self.answer_id: separator.join(self.correct_answer)} return {self.answer_id: separator.join(self.correct_answer)}
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -1521,17 +1522,18 @@ class CustomResponse(LoncapaResponse): ...@@ -1521,17 +1522,18 @@ class CustomResponse(LoncapaResponse):
# ordered list of answers # ordered list of answers
submission = [student_answers[k] for k in idset] submission = [student_answers[k] for k in idset]
except Exception as err: except Exception as err:
msg = _( msg = u"[courseware.capa.responsetypes.customresponse] {message}\n idset = {idset}, error = {err}".format(
"[courseware.capa.responsetypes.customresponse] error getting" message= _("error getting student answer from {student_answers}").format(student_answers=student_answers),
" student answer from {student_answers}"
"\n idset = {idset}, error = {err}"
).format(
student_answers=student_answers,
idset=idset, idset=idset,
err=err err=err
); )
log.error(msg) log.error(
"[courseware.capa.responsetypes.customresponse] error getting"
" student answer from %s"
"\n idset = %s, error = %s",
student_answers, idset, err
)
raise Exception(msg) raise Exception(msg)
# global variable in context which holds the Presentation MathML from dynamic math input # global variable in context which holds the Presentation MathML from dynamic math input
......
<section id="inputtype_${id}" class="capa_inputtype" > <div id="inputtype_${id}" class="capa_inputtype">
<div class="drag_and_drop_problem_div" id="drag_and_drop_div_${id}" <div class="drag_and_drop_problem_div" id="drag_and_drop_div_${id}"
data-plain-id="${id}"> data-plain-id="${id}">
</div> </div>
...@@ -29,4 +29,4 @@ ...@@ -29,4 +29,4 @@
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
</div> </div>
% endif % endif
</section> </div>
...@@ -20,6 +20,7 @@ from capa.responsetypes import LoncapaProblemError, \ ...@@ -20,6 +20,7 @@ from capa.responsetypes import LoncapaProblemError, \
StudentInputError, ResponseError StudentInputError, ResponseError
from capa.correctmap import CorrectMap from capa.correctmap import CorrectMap
from capa.util import convert_files_to_filenames from capa.util import convert_files_to_filenames
from capa.util import compare_with_tolerance
from capa.xqueue_interface import dateformat from capa.xqueue_interface import dateformat
from pytz import UTC from pytz import UTC
...@@ -1120,7 +1121,6 @@ class NumericalResponseTest(ResponseTest): ...@@ -1120,7 +1121,6 @@ class NumericalResponseTest(ResponseTest):
# We blend the line between integration (using evaluator) and exclusively # We blend the line between integration (using evaluator) and exclusively
# unit testing the NumericalResponse (mocking out the evaluator) # unit testing the NumericalResponse (mocking out the evaluator)
# For simple things its not worth the effort. # For simple things its not worth the effort.
def test_grade_range_tolerance(self): def test_grade_range_tolerance(self):
problem_setup = [ problem_setup = [
# [given_asnwer, [list of correct responses], [list of incorrect responses]] # [given_asnwer, [list of correct responses], [list of incorrect responses]]
...@@ -1177,9 +1177,20 @@ class NumericalResponseTest(ResponseTest): ...@@ -1177,9 +1177,20 @@ class NumericalResponseTest(ResponseTest):
self.assert_multiple_grade(problem, correct_responses, incorrect_responses) self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_percent_tolerance(self): def test_grade_percent_tolerance(self):
# Positive only range
problem = self.build_problem(answer=4, tolerance="10%") problem = self.build_problem(answer=4, tolerance="10%")
correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"] correct_responses = ["4.0", "4.00", "4.39", "3.61"]
incorrect_responses = ["", "4.5", "3.5", "0"] incorrect_responses = ["", "4.41", "3.59", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
# Negative only range
problem = self.build_problem(answer=-4, tolerance="10%")
correct_responses = ["-4.0", "-4.00", "-4.39", "-3.61"]
incorrect_responses = ["", "-4.41", "-3.59", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
# Mixed negative/positive range
problem = self.build_problem(answer=1, tolerance="200%")
correct_responses = ["1", "1.00", "2.99", "0.99"]
incorrect_responses = ["", "3.01", "-1.01"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses) self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_floats(self): def test_floats(self):
......
"""Tests capa util"""
import unittest
import textwrap
from . import test_capa_system
from capa.util import compare_with_tolerance
class UtilTest(unittest.TestCase):
"""Tests for util"""
def setUp(self):
super(UtilTest, self).setUp()
self.system = test_capa_system()
def test_compare_with_tolerance(self):
# Test default tolerance '0.001%' (it is relative)
result = compare_with_tolerance(100.0, 100.0)
self.assertTrue(result)
result = compare_with_tolerance(100.001, 100.0)
self.assertTrue(result)
result = compare_with_tolerance(101.0, 100.0)
self.assertFalse(result)
# Test absolute percentage tolerance
result = compare_with_tolerance(109.9, 100.0, '10%', False)
self.assertTrue(result)
result = compare_with_tolerance(110.1, 100.0, '10%', False)
self.assertFalse(result)
# Test relative percentage tolerance
result = compare_with_tolerance(111.0, 100.0, '10%', True)
self.assertTrue(result)
result = compare_with_tolerance(112.0, 100.0, '10%', True)
self.assertFalse(result)
# Test absolute tolerance (string)
result = compare_with_tolerance(109.9, 100.0, '10.0', False)
self.assertTrue(result)
result = compare_with_tolerance(110.1, 100.0, '10.0', False)
self.assertFalse(result)
# Test relative tolerance (string)
result = compare_with_tolerance(111.0, 100.0, '0.1', True)
self.assertTrue(result)
result = compare_with_tolerance(112.0, 100.0, '0.1', True)
self.assertFalse(result)
# Test absolute tolerance (float)
result = compare_with_tolerance(109.9, 100.0, 10.0, False)
self.assertTrue(result)
result = compare_with_tolerance(110.1, 100.0, 10.0, False)
self.assertFalse(result)
# Test relative tolerance (float)
result = compare_with_tolerance(111.0, 100.0, 0.1, True)
self.assertTrue(result)
result = compare_with_tolerance(112.0, 100.0, 0.1, True)
self.assertFalse(result)
##### Infinite values #####
infinity = float('Inf')
# Test relative tolerance (float)
result = compare_with_tolerance(infinity, 100.0, 1.0, True)
self.assertFalse(result)
result = compare_with_tolerance(100.0, infinity, 1.0, True)
self.assertFalse(result)
result = compare_with_tolerance(infinity, infinity, 1.0, True)
self.assertTrue(result)
# Test absolute tolerance (float)
result = compare_with_tolerance(infinity, 100.0, 1.0, False)
self.assertFalse(result)
result = compare_with_tolerance(100.0, infinity, 1.0, False)
self.assertFalse(result)
result = compare_with_tolerance(infinity, infinity, 1.0, False)
self.assertTrue(result)
# Test relative tolerance (string)
result = compare_with_tolerance(infinity, 100.0, '1.0', True)
self.assertFalse(result)
result = compare_with_tolerance(100.0, infinity, '1.0', True)
self.assertFalse(result)
result = compare_with_tolerance(infinity, infinity, '1.0', True)
self.assertTrue(result)
# Test absolute tolerance (string)
result = compare_with_tolerance(infinity, 100.0, '1.0', False)
self.assertFalse(result)
result = compare_with_tolerance(100.0, infinity, '1.0', False)
self.assertFalse(result)
result = compare_with_tolerance(infinity, infinity, '1.0', False)
self.assertTrue(result)
...@@ -7,16 +7,29 @@ from cmath import isinf ...@@ -7,16 +7,29 @@ from cmath import isinf
default_tolerance = '0.001%' default_tolerance = '0.001%'
def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, relative_tolerance=False): def compare_with_tolerance(student_complex, instructor_complex, tolerance=default_tolerance, relative_tolerance=False):
""" """
Compare complex1 to complex2 with maximum tolerance tol. Compare student_complex to instructor_complex with maximum tolerance tolerance.
If tolerance is type string, then it is counted as relative if it ends in %; otherwise, it is absolute. - student_complex : student result (float complex number)
- instructor_complex : instructor result (float complex number)
- tolerance : float, or string (representing a float or a percentage)
- relative_tolerance: bool, to explicitly use passed tolerance as relative
- complex1 : student result (float complex number) Note: when a tolerance is a percentage (i.e. '10%'), it will compute that
- complex2 : instructor result (float complex number) percentage of the instructor result and yield a number.
- tolerance : string representing a number or float
- relative_tolerance: bool, used when`tolerance` is float to explicitly use passed tolerance as relative. If relative_tolerance is set to False, it will use that value and the
instructor result to define the bounds of valid student result:
instructor_complex = 10, tolerance = '10%' will give [9.0, 11.0].
If relative_tolerance is set to True, it will use that value and both
instructor result and student result to define the bounds of valid student
result:
instructor_complex = 10, student_complex = 20, tolerance = '10%' will give
[8.0, 12.0].
This is typically used internally to compare float, with a
default_tolerance = '0.001%'.
Default tolerance of 1e-3% is added to compare two floats for Default tolerance of 1e-3% is added to compare two floats for
near-equality (to handle machine representation errors). near-equality (to handle machine representation errors).
...@@ -29,23 +42,28 @@ def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, rela ...@@ -29,23 +42,28 @@ def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, rela
In [212]: 1.9e24 - 1.9*10**24 In [212]: 1.9e24 - 1.9*10**24
Out[212]: 268435456.0 Out[212]: 268435456.0
""" """
if isinstance(tolerance, str):
if tolerance == default_tolerance:
relative_tolerance = True
if tolerance.endswith('%'):
tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
if not relative_tolerance:
tolerance = tolerance * abs(instructor_complex)
else:
tolerance = evaluator(dict(), dict(), tolerance)
if relative_tolerance: if relative_tolerance:
tolerance = tolerance * max(abs(complex1), abs(complex2)) tolerance = tolerance * max(abs(student_complex), abs(instructor_complex))
elif tolerance.endswith('%'):
tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
tolerance = tolerance * max(abs(complex1), abs(complex2))
else:
tolerance = evaluator(dict(), dict(), tolerance)
if isinf(complex1) or isinf(complex2): if isinf(student_complex) or isinf(instructor_complex):
# If an input is infinite, we can end up with `abs(complex1-complex2)` and # If an input is infinite, we can end up with `abs(student_complex-instructor_complex)` and
# `tolerance` both equal to infinity. Then, below we would have # `tolerance` both equal to infinity. Then, below we would have
# `inf <= inf` which is a fail. Instead, compare directly. # `inf <= inf` which is a fail. Instead, compare directly.
return complex1 == complex2 return student_complex == instructor_complex
else: else:
# v1 and v2 are, in general, complex numbers: # v1 and v2 are, in general, complex numbers:
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()). # there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
return abs(complex1 - complex2) <= tolerance return abs(student_complex - instructor_complex) <= tolerance
def contextualize_text(text, context): # private def contextualize_text(text, context): # private
......
...@@ -10,9 +10,12 @@ import textwrap ...@@ -10,9 +10,12 @@ import textwrap
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
class AnnotatableFields(object): class AnnotatableFields(object):
data = String(help="XML data for the annotation", scope=Scope.content, data = String(help=_("XML data for the annotation"), scope=Scope.content,
default=textwrap.dedent( default=textwrap.dedent(
"""\ """\
<annotatable> <annotatable>
...@@ -32,8 +35,8 @@ class AnnotatableFields(object): ...@@ -32,8 +35,8 @@ class AnnotatableFields(object):
</annotatable> </annotatable>
""")) """))
display_name = String( display_name = String(
display_name="Display Name", display_name=_("Display Name"),
help="Display name for this module", help=_("Display name for this module"),
scope=Scope.settings, scope=Scope.settings,
default='Annotation', default='Annotation',
) )
......
"""
This file contains a function used to retrieve the token for the annotation backend
without having to create a view, but just returning a string instead.
It can be called from other files by using the following:
from xmodule.annotator_token import retrieve_token
"""
import datetime
from firebase_token_generator import create_token
def retrieve_token(userid, secret):
'''
Return a token for the backend of annotations.
It uses the course id to retrieve a variable that contains the secret
token found in inheritance.py. It also contains information of when
the token was issued. This will be stored with the user along with
the id for identification purposes in the backend.
'''
# the following five lines of code allows you to include the default timezone in the iso format
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
dtnow = datetime.datetime.now()
dtutcnow = datetime.datetime.utcnow()
delta = dtnow - dtutcnow
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
# federated system in the annotation backend server
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
newtoken = create_token(secret, custom_data)
return newtoken
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