Commit 9a106a32 by Ned Batchelder

Merged master to rc/2013-11-21

parents 32941969 31ee6f09
...@@ -17,6 +17,7 @@ cms/envs/private.py ...@@ -17,6 +17,7 @@ cms/envs/private.py
/nbproject /nbproject
.idea/ .idea/
.redcar/ .redcar/
codekit-config.json
### OS X artifacts ### OS X artifacts
*.DS_Store *.DS_Store
...@@ -48,14 +49,18 @@ reports/ ...@@ -48,14 +49,18 @@ reports/
.prereqs_cache .prereqs_cache
.vagrant/ .vagrant/
node_modules node_modules
.bundle/
bin/
### Static assets pipeline artifacts ### Static assets pipeline artifacts
*.scssc *.scssc
lms/static/css/
lms/static/sass/*.css lms/static/sass/*.css
lms/static/sass/application.scss lms/static/sass/application.scss
lms/static/sass/application-extend1.scss lms/static/sass/application-extend1.scss
lms/static/sass/application-extend2.scss lms/static/sass/application-extend2.scss
lms/static/sass/course.scss lms/static/sass/course.scss
cms/static/css/
cms/static/sass/*.css cms/static/sass/*.css
### Logging artifacts ### Logging artifacts
......
...@@ -97,3 +97,4 @@ Iain Dunning <idunning@mit.edu> ...@@ -97,3 +97,4 @@ Iain Dunning <idunning@mit.edu>
Olivier Marquez <oliviermarquez@gmail.com> Olivier Marquez <oliviermarquez@gmail.com>
Florian Dufour <neurolit@gmail.com> Florian Dufour <neurolit@gmail.com>
Manuel Freire <manuel.freire@fdi.ucm.es> Manuel Freire <manuel.freire@fdi.ucm.es>
Daniel Cebrián Robles <danielcebrianr@gmail.com>
...@@ -9,9 +9,37 @@ LMS: Add feature for providing background grade report generation via Celery ...@@ -9,9 +9,37 @@ LMS: Add feature for providing background grade report generation via Celery
instructor task, with reports uploaded to S3. Feature is visible on the beta instructor task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58 instructor dashboard. LMS-58
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid LMS: Beta-tester status is now set on a per-course-run basis, rather than being
across all runs with the same course name. Old group membership will still work valid across all runs with the same course name. Old group membership will
across runs, but new beta-testers will only be added to a single course run. still work across runs, but new beta-testers will only be added to a single
course run.
Blades: Enabled several Video Jasmine tests. BLD-463.
Studio: Continued modification of Studio pages to follow a RESTful framework.
includes Settings pages, edit page for Subsection and Unit, and interfaces
for updating xblocks (xmodules) and getting their editing HTML.
Blades: Put 2nd "Hide output" button at top of test box & increase text size for
code response questions. BLD-126.
Blades: Update the calculator hints tooltip with full information. BLD-400.
Blades: Fix transcripts 500 error in studio (BLD-530)
LMS: Add error recovery when a user loads or switches pages in an
inline discussion.
Blades: Allow multiple strings as the correct answer to a string response
question. BLD-474.
Blades: a11y - Videos will alert screenreaders when the video is over.
LMS: Trap focus on the loading element when a user loads more threads
in the forum sidebar to improve accessibility.
LMS: Add error recovery when a user loads more threads in the forum sidebar.
>>>>>>> origin/master
LMS: Add a user-visible alert modal when a forums AJAX request fails. LMS: Add a user-visible alert modal when a forums AJAX request fails.
...@@ -32,7 +60,8 @@ text like with bold or italics. (BLD-449) ...@@ -32,7 +60,8 @@ text like with bold or italics. (BLD-449)
LMS: Beta instructor dashboard will only count actively enrolled students for LMS: Beta instructor dashboard will only count actively enrolled students for
course enrollment numbers. course enrollment numbers.
Blades: Fix speed menu that is not rendered correctly when YouTube is unavailable. (BLD-457). Blades: Fix speed menu that is not rendered correctly when YouTube is
unavailable. (BLD-457).
LMS: Users with is_staff=True no longer have the STAFF label appear on LMS: Users with is_staff=True no longer have the STAFF label appear on
their forum posts. their forum posts.
......
...@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611 ...@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
def _is_expected_element_count(css, expected_number):
"""
Returns whether the number of elements found on the page by css locator
the same number that you expected.
"""
return len(world.css_find(css)) == expected_number
@world.absorb @world.absorb
def create_component_instance(step, category, component_type=None, is_advanced=False): def create_component_instance(step, category, component_type=None, is_advanced=False):
""" """
...@@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F ...@@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F
world.wait_for_invisible(component_button_css) world.wait_for_invisible(component_button_css)
click_component_from_menu(category, component_type, is_advanced) click_component_from_menu(category, component_type, is_advanced)
world.wait_for(lambda _: _is_expected_element_count(module_css, expected_count = module_count_before + 1
module_count_before + 1)) world.wait_for(
lambda _: len(world.css_find(module_css)) == expected_count,
timeout=20
)
@world.absorb @world.absorb
......
...@@ -76,3 +76,17 @@ Feature: CMS.Course updates ...@@ -76,3 +76,17 @@ Feature: CMS.Course updates
Then I see the handout "/c4x/MITx/999/asset/modified.jpg" Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
And when I reload the page And when I reload the page
Then I see the handout "/c4x/MITx/999/asset/modified.jpg" Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
Scenario: Users cannot save handouts with bad html until edit or update it properly
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<p><a href=>[LINK TEXT]</a></p>"
Then I see the handout error text
And I see handout save button disabled
When I edit the handout to "<p><a href='https://www.google.com.pk/'>home</a></p>"
Then I see handout save button re-enabled
When I save handout edit
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I see the handout "https://www.google.com.pk/"
And when I reload the page
Then I see the handout "https://www.google.com.pk/"
...@@ -90,6 +90,35 @@ def check_handout(_step, handout): ...@@ -90,6 +90,35 @@ def check_handout(_step, handout):
assert handout in world.css_html(handout_css) assert handout in world.css_html(handout_css)
@step(u'I see the handout error text')
def check_handout_error(_step):
handout_error_css = 'div#handout_error'
assert world.css_has_class(handout_error_css, 'is-shown')
@step(u'I see handout save button disabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form a.save-button'
assert world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I edit the handout to "([^"]*)"$')
def edit_handouts(_step, text):
type_in_codemirror(0, text)
@step(u'I see handout save button re-enabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form a.save-button'
assert not world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I save handout edit')
def check_handout_error(_step):
save_css = 'a.save-button'
world.css_click(save_css)
def change_text(text): def change_text(text):
type_in_codemirror(0, text) type_in_codemirror(0, text)
save_css = 'a.save-button' save_css = 'a.save-button'
......
...@@ -9,10 +9,8 @@ Feature: CMS.Static Pages ...@@ -9,10 +9,8 @@ Feature: CMS.Static Pages
Then I should see a static page named "Empty" Then I should see a static page named "Empty"
Scenario: Users can delete static pages Scenario: Users can delete static pages
Given I have opened a new course in Studio Given I have created a static page
And I go to the static pages page When I "delete" the static page
And I add a new page
And I "delete" the static page
Then I am shown a prompt Then I am shown a prompt
When I confirm the prompt When I confirm the prompt
Then I should not see any static pages Then I should not see any static pages
...@@ -20,9 +18,16 @@ Feature: CMS.Static Pages ...@@ -20,9 +18,16 @@ Feature: CMS.Static Pages
# Safari won't update the name properly # Safari won't update the name properly
@skip_safari @skip_safari
Scenario: Users can edit static pages Scenario: Users can edit static pages
Given I have opened a new course in Studio Given I have created a static page
And I go to the static pages page
And I add a new page
When I "edit" the static page When I "edit" the static page
And I change the name to "New" And I change the name to "New"
Then I should see a static page named "New" Then I should see a static page named "New"
# Safari won't update the name properly
@skip_safari
Scenario: Users can reorder static pages
Given I have created two different static pages
When I reorder the tabs
Then the tabs are in the reverse order
And I reload the page
Then the tabs are in the reverse order
...@@ -48,3 +48,47 @@ def change_name(step, new_name): ...@@ -48,3 +48,47 @@ def change_name(step, new_name):
world.trigger_event(input_css) world.trigger_event(input_css)
save_button = 'a.save-button' save_button = 'a.save-button'
world.css_click(save_button) world.css_click(save_button)
@step(u'I reorder the tabs')
def reorder_tabs(_step):
# For some reason, the drag_and_drop method did not work in this case.
draggables = world.css_find('.drag-handle')
source = draggables.first
target = draggables.last
source.action_chains.click_and_hold(source._element).perform()
source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform()
source.action_chains.release().perform()
@step(u'I have created a static page')
def create_static_page(step):
step.given('I have opened a new course in Studio')
step.given('I go to the static pages page')
step.given('I add a new page')
@step(u'I have created two different static pages')
def create_two_pages(step):
step.given('I have created a static page')
step.given('I "edit" the static page')
step.given('I change the name to "First"')
step.given('I add a new page')
# Verify order of tabs
_verify_tab_names('First', 'Empty')
@step(u'the tabs are in the reverse order')
def tabs_in_reverse_order(step):
_verify_tab_names('Empty', 'First')
def _verify_tab_names(first, second):
world.wait_for(
func=lambda _: len(world.css_find('.xmodule_StaticTabModule')) == 2,
timeout=200,
timeout_msg="Timed out waiting for two tabs to be present"
)
tabs = world.css_find('.xmodule_StaticTabModule')
assert tabs[0].text == first
assert tabs[1].text == second
...@@ -641,6 +641,7 @@ Feature: Video Component Editor ...@@ -641,6 +641,7 @@ Feature: Video Component Editor
And I save changes And I save changes
Then when I view the video it does show the captions Then when I view the video it does show the captions
And I see "好 各位同学" text in the captions
And I edit the component And I edit the component
And I open tab "Advanced" And I open tab "Advanced"
......
...@@ -116,6 +116,7 @@ def i_see_status_message(_step, status): ...@@ -116,6 +116,7 @@ def i_see_status_message(_step, status):
world.wait(DELAY) world.wait(DELAY)
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
assert not world.css_visible(SELECTORS['error_bar'])
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
......
...@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index): ...@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index):
@step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$') @step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$')
def caption_line_has_class(_step, index, className): def caption_line_has_class(_step, index, className):
SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip())) SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip()))
world.css_has_class(SELECTOR, className.strip()) assert world.css_has_class(SELECTOR, className.strip())
@step('I see a range on slider$') @step('I see a range on slider$')
......
...@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline. ...@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline.
""" """
import json import json
import lxml import lxml
from django.core.urlresolvers import reverse
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
...@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase): ...@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase):
""" """
Test the error conditions for the access Test the error conditions for the access
""" """
locator = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True) outline_url = self.course_locator.url_reverse('course/', '')
outline_url = locator.url_reverse('course/', '')
# register a non-staff member and try to delete the course branch # register a non-staff member and try to delete the course branch
non_staff_client, _ = self.createNonStaffAuthedUserClient() non_staff_client, _ = self.createNonStaffAuthedUserClient()
response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json') response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json')
......
...@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase): ...@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase):
parent_location=vertical.location, parent_location=vertical.location,
category='aawefawef' category='aawefawef'
) )
self._verify_export_failure('/edit/i4x://MITx/999/vertical/foo') self._verify_export_failure(u'/unit/MITx.999.Robot_Super_Course/branch/draft/block/foo')
def _verify_export_failure(self, expectedText): def _verify_export_failure(self, expectedText):
""" Export failure helper method. """ """ Export failure helper method. """
......
...@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor ...@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.modulestore.exceptions import ItemNotFoundError
class ItemTest(CourseTestCase): class ItemTest(CourseTestCase):
...@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase): ...@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase):
""" """
Get the item referenced by the locator from the modulestore Get the item referenced by the locator from the modulestore
""" """
store = modulestore('draft') if draft else modulestore() store = modulestore('draft') if draft else modulestore('direct')
return store.get_item(self.get_old_id(locator)) return store.get_item(self.get_old_id(locator))
def response_locator(self, response): def response_locator(self, response):
...@@ -251,3 +252,105 @@ class TestEditItem(ItemTest): ...@@ -251,3 +252,105 @@ class TestEditItem(ItemTest):
self.assertEqual(self.get_old_id(self.problem_locator).url(), children[0]) self.assertEqual(self.get_old_id(self.problem_locator).url(), children[0])
self.assertEqual(self.get_old_id(unit1_locator).url(), children[2]) self.assertEqual(self.get_old_id(unit1_locator).url(), children[2])
self.assertEqual(self.get_old_id(unit2_locator).url(), children[1]) self.assertEqual(self.get_old_id(unit2_locator).url(), children[1])
def test_make_public(self):
""" Test making a private problem public (publishing it). """
# When the problem is first created, it is only in draft (because of its category).
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
def test_make_private(self):
""" Test making a public problem private (un-publishing it). """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it private
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_private'}
)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
def test_make_draft(self):
""" Test creating a draft version of a public problem. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'create_draft'}
)
# Update the draft version and check that published is different.
self.client.ajax_post(
self.problem_update_url,
data={'metadata': {'due': '2077-10-10T04:00Z'}}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_public_with_update(self):
""" Update a problem and make it public at the same time. """
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'make_public'
}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertEqual(published.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_private_with_update(self):
""" Make a problem private and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'make_private'
}
)
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_create_draft_with_update(self):
""" Create a draft and update it at the same time. """
# Make problem public.
self.client.ajax_post(
self.problem_update_url,
data={'publish': 'make_public'}
)
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
# Now make it draft, which means both versions will exist.
self.client.ajax_post(
self.problem_update_url,
data={
'metadata': {'due': '2077-10-10T04:00Z'},
'publish': 'create_draft'
}
)
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
...@@ -20,6 +20,7 @@ from xmodule.contentstore.django import contentstore, _CONTENTSTORE ...@@ -20,6 +20,7 @@ from xmodule.contentstore.django import contentstore, _CONTENTSTORE
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.locator import BlockUsageLocator
from contentstore.tests.modulestore_config import TEST_MODULESTORE from contentstore.tests.modulestore_config import TEST_MODULESTORE
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
...@@ -59,7 +60,7 @@ class Basetranscripts(CourseTestCase): ...@@ -59,7 +60,7 @@ class Basetranscripts(CourseTestCase):
'type': 'video' 'type': 'video'
} }
resp = self.client.ajax_post('/xblock', data) resp = self.client.ajax_post('/xblock', data)
self.item_location = json.loads(resp.content).get('id') self.item_location = self._get_location(resp)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# hI10vDNYz4M - valid Youtube ID with transcripts. # hI10vDNYz4M - valid Youtube ID with transcripts.
...@@ -72,6 +73,11 @@ class Basetranscripts(CourseTestCase): ...@@ -72,6 +73,11 @@ class Basetranscripts(CourseTestCase):
# Remove all transcripts for current module. # Remove all transcripts for current module.
self.clear_subs_content() self.clear_subs_content()
def _get_location(self, resp):
""" Returns the location (as a string) from the response returned by a create operation. """
locator = json.loads(resp.content).get('locator')
return loc_mapper().translate_locator_to_location(BlockUsageLocator(locator)).url()
def get_youtube_ids(self): def get_youtube_ids(self):
"""Return youtube speeds and ids.""" """Return youtube speeds and ids."""
item = modulestore().get_item(self.item_location) item = modulestore().get_item(self.item_location)
...@@ -205,7 +211,7 @@ class TestUploadtranscripts(Basetranscripts): ...@@ -205,7 +211,7 @@ class TestUploadtranscripts(Basetranscripts):
'type': 'non_video' 'type': 'non_video'
} }
resp = self.client.ajax_post('/xblock', data) resp = self.client.ajax_post('/xblock', data)
item_location = json.loads(resp.content).get('id') item_location = self._get_location(resp)
data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />' data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />'
modulestore().update_item(item_location, data) modulestore().update_item(item_location, data)
...@@ -416,7 +422,7 @@ class TestDownloadtranscripts(Basetranscripts): ...@@ -416,7 +422,7 @@ class TestDownloadtranscripts(Basetranscripts):
'type': 'videoalpha' 'type': 'videoalpha'
} }
resp = self.client.ajax_post('/xblock', data) resp = self.client.ajax_post('/xblock', data)
item_location = json.loads(resp.content).get('id') item_location = self._get_location(resp)
subs_id = str(uuid4()) subs_id = str(uuid4())
data = textwrap.dedent(""" data = textwrap.dedent("""
<videoalpha youtube="" sub="{}"> <videoalpha youtube="" sub="{}">
...@@ -666,7 +672,7 @@ class TestChecktranscripts(Basetranscripts): ...@@ -666,7 +672,7 @@ class TestChecktranscripts(Basetranscripts):
'type': 'not_video' 'type': 'not_video'
} }
resp = self.client.ajax_post('/xblock', data) resp = self.client.ajax_post('/xblock', data)
item_location = json.loads(resp.content).get('id') item_location = self._get_location(resp)
subs_id = str(uuid4()) subs_id = str(uuid4())
data = textwrap.dedent(""" data = textwrap.dedent("""
<not_video youtube="" sub="{}"> <not_video youtube="" sub="{}">
......
...@@ -10,8 +10,9 @@ from django.test.client import Client ...@@ -10,8 +10,9 @@ from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from contentstore.tests.modulestore_config import TEST_MODULESTORE from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore.django import loc_mapper
def parse_json(response): def parse_json(response):
...@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client): ...@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client):
if not isinstance(data, basestring): if not isinstance(data, basestring):
data = json.dumps(data or {}) data = json.dumps(data or {})
kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest") kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest")
kwargs.setdefault("HTTP_ACCEPT", "application/json")
return self.post(path=path, data=data, content_type=content_type, **kwargs) return self.post(path=path, data=data, content_type=content_type, **kwargs)
def get_html(self, path, data=None, follow=False, **extra): def get_html(self, path, data=None, follow=False, **extra):
...@@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase):
display_name='Robot Super Course', display_name='Robot Super Course',
) )
self.course_location = self.course.location self.course_location = self.course.location
self.course_locator = loc_mapper().translate_location(
self.course.location.course_id, self.course.location, False, True
)
def createNonStaffAuthedUserClient(self): def createNonStaffAuthedUserClient(self):
""" """
...@@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase):
client = Client() client = Client()
client.login(username=uname, password=password) client.login(username=uname, password=password)
return client, nonstaff return client, nonstaff
def populateCourse(self):
"""
Add 2 chapters, 4 sections, 8 verticals, 16 problems to self.course (branching 2)
"""
def descend(parent, stack):
xblock_type = stack.pop(0)
for _ in range(2):
child = ItemFactory.create(category=xblock_type, parent_location=parent.location)
if stack:
descend(child, stack)
descend(self.course, ['chapter', 'sequential', 'vertical', 'problem'])
...@@ -5,7 +5,6 @@ from util.json_request import JsonResponse ...@@ -5,7 +5,6 @@ from util.json_request import JsonResponse
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.core.urlresolvers import reverse
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
...@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator ...@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator
__all__ = ['checklists_handler'] __all__ = ['checklists_handler']
# pylint: disable=unused-argument
@require_http_methods(("GET", "POST", "PUT")) @require_http_methods(("GET", "POST", "PUT"))
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -85,7 +86,7 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g ...@@ -85,7 +86,7 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g
return JsonResponse(expanded_checklist) return JsonResponse(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 "
"was out of range or unspecified."), "was out of range or unspecified."),
content_type="text/plain" content_type="text/plain"
) )
...@@ -113,14 +114,12 @@ def expand_checklist_action_url(course_module, checklist): ...@@ -113,14 +114,12 @@ def expand_checklist_action_url(course_module, checklist):
The method does a copy of the input checklist and does not modify the input argument. The method does a copy of the input checklist and does not modify the input argument.
""" """
expanded_checklist = copy.deepcopy(checklist) expanded_checklist = copy.deepcopy(checklist)
oldurlconf_map = {
"SettingsDetails": "settings_details",
"SettingsGrading": "settings_grading"
}
urlconf_map = { urlconf_map = {
"ManageUsers": "course_team", "ManageUsers": "course_team",
"CourseOutline": "course" "CourseOutline": "course",
"SettingsDetails": "settings/details",
"SettingsGrading": "settings/grading",
} }
for item in expanded_checklist.get('items'): for item in expanded_checklist.get('items'):
...@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist): ...@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist):
ctx_loc = course_module.location ctx_loc = course_module.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
item['action_url'] = location.url_reverse(url_prefix, '') item['action_url'] = location.url_reverse(url_prefix, '')
elif action_url in oldurlconf_map:
urlconf_name = oldurlconf_map[action_url]
item['action_url'] = reverse(urlconf_name, kwargs={
'org': course_module.location.org,
'course': course_module.location.course,
'name': course_module.location.name,
})
return expanded_checklist return expanded_checklist
...@@ -14,7 +14,6 @@ from django.conf import settings ...@@ -14,7 +14,6 @@ from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.core.urlresolvers import reverse
from django.core.servers.basehttp import FileWrapper from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile from django.core.files.temp import NamedTemporaryFile
from django.core.exceptions import SuspiciousOperation, PermissionDenied from django.core.exceptions import SuspiciousOperation, PermissionDenied
...@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
"size": size, "size": size,
"deleteUrl": "", "deleteUrl": "",
"deleteType": "", "deleteType": "",
"url": location.url_reverse('import/', ''), "url": location.url_reverse('import'),
"thumbnailUrl": "" "thumbnailUrl": ""
}] }]
}) })
...@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
course_module = modulestore().get_item(old_location) course_module = modulestore().get_item(old_location)
return render_to_response('import.html', { return render_to_response('import.html', {
'context_course': course_module, 'context_course': course_module,
'successful_import_redirect_url': location.url_reverse("course/", ""), 'successful_import_redirect_url': location.url_reverse("course"),
'import_status_url': location.url_reverse("import_status/", "fillerName"), 'import_status_url': location.url_reverse("import_status", "fillerName"),
}) })
else: else:
return HttpResponseNotFound() return HttpResponseNotFound()
...@@ -313,7 +312,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -313,7 +312,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
# an _accept URL parameter will be preferred over HTTP_ACCEPT in the header. # an _accept URL parameter will be preferred over HTTP_ACCEPT in the header.
requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html')) requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html'))
export_url = location.url_reverse('export/', '') + '?_accept=application/x-tgz' export_url = location.url_reverse('export') + '?_accept=application/x-tgz'
if 'application/x-tgz' in requested_format: if 'application/x-tgz' in requested_format:
name = old_location.name name = old_location.name
export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz")
...@@ -339,16 +338,16 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -339,16 +338,16 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
# if we have a nested exception, then we'll show the more generic error message # if we have a nested exception, then we'll show the more generic error message
pass pass
unit_locator = loc_mapper().translate_location(old_location.course_id, parent.location, False, True)
return render_to_response('export.html', { return render_to_response('export.html', {
'context_course': course_module, 'context_course': course_module,
'in_err': True, 'in_err': True,
'raw_err_msg': str(e), 'raw_err_msg': str(e),
'failed_module': failed_item, 'failed_module': failed_item,
'unit': unit, 'unit': unit,
'edit_unit_url': reverse('edit_unit', kwargs={ 'edit_unit_url': unit_locator.url_reverse("unit") if parent else "",
'location': parent.location 'course_home_url': location.url_reverse("course"),
}) if parent else '',
'course_home_url': location.url_reverse("course/", ""),
'export_url': export_url 'export_url': export_url
}) })
...@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
'in_err': True, 'in_err': True,
'unit': None, 'unit': None,
'raw_err_msg': str(e), 'raw_err_msg': str(e),
'course_home_url': location.url_reverse("course/", ""), 'course_home_url': location.url_reverse("course"),
'export_url': export_url 'export_url': export_url
}) })
......
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
import logging import logging
from uuid import uuid4 from uuid import uuid4
from functools import partial
from static_replace import replace_static_urls from static_replace import replace_static_urls
from xmodule_modifiers import wrap_xblock
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
...@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator ...@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator
from student.models import CourseEnrollment from student.models import CourseEnrollment
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from xblock.fields import Scope from xblock.fields import Scope
from preview import handler_prefix, get_preview_html
from mitxmako.shortcuts import render_to_response, render_to_string
from models.settings.course_grading import CourseGradingModel
__all__ = ['orphan_handler', 'xblock_handler'] __all__ = ['orphan_handler', 'xblock_handler']
...@@ -51,17 +56,21 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -51,17 +56,21 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
all children and "all_versions" to delete from all (mongo) versions. all children and "all_versions" to delete from all (mongo) versions.
GET GET
json: returns representation of the xblock (locator id, data, and metadata). json: returns representation of the xblock (locator id, data, and metadata).
if ?fields=graderType, it returns the graderType for the unit instead of the above.
html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
PUT or POST PUT or POST
json: if xblock location is specified, update the xblock instance. The json payload can contain json: if xblock locator is specified, update the xblock instance. The json payload can contain
these fields, all optional: these fields, all optional:
:data: the new value for the data. :data: the new value for the data.
:children: the locator ids of children for this xblock. :children: the locator ids of children for this xblock.
:metadata: new values for the metadata fields. Any whose values are None will be deleted not set :metadata: new values for the metadata fields. Any whose values are None will be deleted not set
to None! Absent ones will be left alone. to None! Absent ones will be left alone.
:nullout: which metadata fields to set to None :nullout: which metadata fields to set to None
:graderType: change how this unit is graded
:publish: can be one of three values, 'make_public, 'make_private', or 'create_draft'
The JSON representation on the updated xblock (minus children) is returned. The JSON representation on the updated xblock (minus children) is returned.
if xblock location is not specified, create a new xblock instance. The json playload can contain if xblock locator is not specified, create a new xblock instance. The json playload can contain
these fields: these fields:
:parent_locator: parent for new xblock, required :parent_locator: parent for new xblock, required
:category: type of xblock, required :category: type of xblock, required
...@@ -70,14 +79,38 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -70,14 +79,38 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
The locator (and old-style id) for the created xblock (minus children) is returned. The locator (and old-style id) for the created xblock (minus children) is returned.
""" """
if course_id is not None: if course_id is not None:
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
if not has_access(request.user, location): if not has_access(request.user, locator):
raise PermissionDenied() raise PermissionDenied()
old_location = loc_mapper().translate_locator_to_location(location) old_location = loc_mapper().translate_locator_to_location(locator)
if request.method == 'GET': if request.method == 'GET':
rsp = _get_module_info(location) if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
fields = request.REQUEST.get('fields', '').split(',')
if 'graderType' in fields:
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
return JsonResponse(CourseGradingModel.get_section_grader_type(locator))
# TODO: pass fields to _get_module_info and only return those
rsp = _get_module_info(locator)
return JsonResponse(rsp) return JsonResponse(rsp)
else:
component = modulestore().get_item(old_location)
# Wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix))
try:
content = component.render('studio_view').content
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=W0703
content = render_to_string('html_error.html', {'message': str(exc)})
return render_to_response('component.html', {
'preview': get_preview_html(request, component),
'editor': content
})
elif request.method == 'DELETE': elif request.method == 'DELETE':
delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_children = str_to_bool(request.REQUEST.get('recurse', 'False'))
delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False')) delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False'))
...@@ -85,12 +118,15 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -85,12 +118,15 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
return _delete_item_at_location(old_location, delete_children, delete_all_versions) return _delete_item_at_location(old_location, delete_children, delete_all_versions)
else: # Since we have a course_id, we are updating an existing xblock. else: # Since we have a course_id, we are updating an existing xblock.
return _save_item( return _save_item(
location, request,
locator,
old_location, old_location,
data=request.json.get('data'), data=request.json.get('data'),
children=request.json.get('children'), children=request.json.get('children'),
metadata=request.json.get('metadata'), metadata=request.json.get('metadata'),
nullout=request.json.get('nullout') nullout=request.json.get('nullout'),
grader_type=request.json.get('graderType'),
publish=request.json.get('publish'),
) )
elif request.method in ('PUT', 'POST'): elif request.method in ('PUT', 'POST'):
return _create_item(request) return _create_item(request)
...@@ -101,11 +137,14 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ...@@ -101,11 +137,14 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid=
) )
def _save_item(usage_loc, item_location, data=None, children=None, metadata=None, nullout=None): def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None,
grader_type=None, publish=None):
""" """
Saves certain properties (data, children, metadata, nullout) for a given xblock item. Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
to default).
The item_location is still the old-style location. The item_location is still the old-style location whereas usage_loc is a BlockUsageLocator
""" """
store = get_modulestore(item_location) store = get_modulestore(item_location)
...@@ -123,6 +162,14 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None ...@@ -123,6 +162,14 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
log.error("Can't find item by location.") log.error("Can't find item by location.")
return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404) return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404)
if publish:
if publish == 'make_private':
_xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location))
elif publish == 'create_draft':
# This clones the existing item location to a draft location (the draft is
# implicit, because modulestore is a Draft modulestore)
modulestore().convert_to_draft(item_location)
if data: if data:
store.update_item(item_location, data) store.update_item(item_location, data)
else: else:
...@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None ...@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
if existing_item.category == 'video': if existing_item.category == 'video':
manage_video_subtitles_save(existing_item, existing_item) manage_video_subtitles_save(existing_item, existing_item)
# Note that children aren't being returned until we have a use case. result = {
return JsonResponse({
'id': unicode(usage_loc), 'id': unicode(usage_loc),
'data': data, 'data': data,
'metadata': own_metadata(existing_item) 'metadata': own_metadata(existing_item)
}) }
if grader_type is not None:
result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type))
# Make public after updating the xblock, in case the caller asked
# for both an update and a publish.
if publish and publish == 'make_public':
_xmodule_recurse(
existing_item,
lambda i: modulestore().publish(i.location, request.user.id)
)
# Note that children aren't being returned until we have a use case.
return JsonResponse(result)
@login_required @login_required
...@@ -192,10 +252,7 @@ def _create_item(request): ...@@ -192,10 +252,7 @@ def _create_item(request):
raise PermissionDenied() raise PermissionDenied()
parent = get_modulestore(category).get_item(parent_location) parent = get_modulestore(category).get_item(parent_location)
# Necessary to set revision=None or else metadata inheritance does not work dest_location = parent_location.replace(category=category, name=uuid4().hex)
# (the ID with @draft will be used as the key in the inherited metadata map,
# and that is not expected by the code that later references it).
dest_location = parent_location.replace(category=category, name=uuid4().hex, revision=None)
# get the metadata, display_name, and definition from the request # get the metadata, display_name, and definition from the request
metadata = {} metadata = {}
...@@ -224,7 +281,7 @@ def _create_item(request): ...@@ -224,7 +281,7 @@ def _create_item(request):
course_location = loc_mapper().translate_locator_to_location(parent_locator, get_course=True) course_location = loc_mapper().translate_locator_to_location(parent_locator, get_course=True)
locator = loc_mapper().translate_location(course_location.course_id, dest_location, False, True) locator = loc_mapper().translate_location(course_location.course_id, dest_location, False, True)
return JsonResponse({'id': dest_location.url(), "locator": unicode(locator)}) return JsonResponse({"locator": unicode(locator)})
def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False): def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False):
......
...@@ -3,7 +3,7 @@ from functools import partial ...@@ -3,7 +3,7 @@ from functools import partial
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden from django.http import Http404, HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
...@@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code ...@@ -24,10 +24,9 @@ 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
from .access import has_access
from ..utils import get_course_for_item from ..utils import get_course_for_item
__all__ = ['preview_handler', 'preview_component'] __all__ = ['preview_handler']
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''): ...@@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''):
usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes` usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes`
handler: The handler to execute handler: The handler to execute
suffix: The remaineder of the url to be passed to the handler suffix: The remainder of the url to be passed to the handler
""" """
location = unquote_slashes(usage_id) location = unquote_slashes(usage_id)
descriptor = modulestore().get_item(location) descriptor = modulestore().get_item(location)
instance = load_preview_module(request, descriptor) instance = _load_preview_module(request, descriptor)
# Let the module handle the AJAX # Let the module handle the AJAX
req = django_to_webob_request(request) req = django_to_webob_request(request)
try: try:
...@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''): ...@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''):
return webob_to_django_response(resp) return webob_to_django_response(resp)
@login_required
def preview_component(request, location):
"Return the HTML preview of a component"
# TODO (vshnayder): change name from id to location in coffee+html as well.
if not has_access(request.user, location):
return HttpResponseForbidden()
component = modulestore().get_item(location)
# Wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix))
try:
content = component.render('studio_view').content
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=W0703
content = render_to_string('html_error.html', {'message': str(exc)})
return render_to_response('component.html', {
'preview': get_preview_html(request, component),
'editor': content
})
class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
""" """
An XModule ModuleSystem for use in Studio previews An XModule ModuleSystem for use in Studio previews
...@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
return handler_prefix(block, handler_name, suffix) + '?' + query return handler_prefix(block, handler_name, suffix) + '?' + query
def preview_module_system(request, descriptor): def _preview_module_system(request, descriptor):
""" """
Returns a ModuleSystem for the specified descriptor that is specialized for Returns a ModuleSystem for the specified descriptor that is specialized for
rendering module previews. rendering module previews.
...@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor): ...@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor):
# TODO (cpennington): Do we want to track how instructors are using the preview problems? # TODO (cpennington): Do we want to track how instructors are using the preview problems?
track_function=lambda event_type, event: None, track_function=lambda event_type, event: None,
filestore=descriptor.runtime.resources_fs, filestore=descriptor.runtime.resources_fs,
get_module=partial(load_preview_module, request), get_module=partial(_load_preview_module, request),
render_template=render_from_lms, render_template=render_from_lms,
debug=True, debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id), replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
...@@ -162,7 +135,7 @@ def preview_module_system(request, descriptor): ...@@ -162,7 +135,7 @@ def preview_module_system(request, descriptor):
) )
def load_preview_module(request, descriptor): def _load_preview_module(request, descriptor):
""" """
Return a preview XModule instantiated from the supplied descriptor. Return a preview XModule instantiated from the supplied descriptor.
...@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor): ...@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor):
""" """
student_data = DbModel(SessionKeyValueStore(request)) student_data = DbModel(SessionKeyValueStore(request))
descriptor.bind_for_student( descriptor.bind_for_student(
preview_module_system(request, descriptor), _preview_module_system(request, descriptor),
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
) )
return descriptor return descriptor
...@@ -182,7 +155,7 @@ def get_preview_html(request, descriptor): ...@@ -182,7 +155,7 @@ def get_preview_html(request, descriptor):
Returns the HTML returned by the XModule's student_view, Returns the HTML returned by the XModule's student_view,
specified by the descriptor and idx. specified by the descriptor and idx.
""" """
module = load_preview_module(request, descriptor) module = _load_preview_module(request, descriptor)
try: try:
content = module.render("student_view").content content = module.render("student_view").content
except Exception as exc: # pylint: disable=W0703 except Exception as exc: # pylint: disable=W0703
......
...@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response ...@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response
from external_auth.views import ssl_login_shortcut from external_auth.views import ssl_login_shortcut
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] __all__ = ['signup', 'login_page', 'howitworks']
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -22,13 +22,6 @@ def signup(request): ...@@ -22,13 +22,6 @@ def signup(request):
return render_to_response('signup.html', {'csrf': csrf_token}) return render_to_response('signup.html', {'csrf': csrf_token})
def old_login_redirect(request):
'''
Redirect to the active login url.
'''
return redirect('login', permanent=True)
@ssl_login_shortcut @ssl_login_shortcut
@ensure_csrf_cookie @ensure_csrf_cookie
def login_page(request): def login_page(request):
......
...@@ -2,12 +2,13 @@ ...@@ -2,12 +2,13 @@
Views related to course tabs Views related to course tabs
""" """
from access import has_access from access import has_access
from util.json_request import expect_json from util.json_request import expect_json, JsonResponse
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponseNotFound
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
...@@ -19,7 +20,7 @@ from ..utils import get_modulestore ...@@ -19,7 +20,7 @@ from ..utils import get_modulestore
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
__all__ = ['edit_tabs', 'reorder_static_tabs'] __all__ = ['tabs_handler']
def initialize_course_tabs(course): def initialize_course_tabs(course):
...@@ -43,75 +44,86 @@ def initialize_course_tabs(course): ...@@ -43,75 +44,86 @@ def initialize_course_tabs(course):
modulestore('direct').update_metadata(course.location.url(), own_metadata(course)) modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
@login_required
@expect_json @expect_json
def reorder_static_tabs(request): @login_required
"Order the static tabs in the requested order" @ensure_csrf_cookie
def get_location_for_tab(tab): @require_http_methods(("GET", "POST", "PUT"))
tab_locator = BlockUsageLocator(tab) def tabs_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
return loc_mapper().translate_locator_to_location(tab_locator) """
The restful handler for static tabs.
tabs = request.json['tabs'] GET
course_location = loc_mapper().translate_locator_to_location(BlockUsageLocator(tabs[0]), get_course=True) html: return page for editing static tabs
json: not supported
PUT or POST
json: update the tab order. It is expected that the request body contains a JSON-encoded dict with entry "tabs".
The value for "tabs" is an array of tab locators, indicating the desired order of the tabs.
if not has_access(request.user, course_location): Creating a tab, deleting a tab, or changing its contents is not supported through this method.
Instead use the general xblock URL (see item.xblock_handler).
"""
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
if not has_access(request.user, locator):
raise PermissionDenied() raise PermissionDenied()
course = get_modulestore(course_location).get_item(course_location) old_location = loc_mapper().translate_locator_to_location(locator)
store = get_modulestore(old_location)
course_item = store.get_item(old_location)
if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
if request.method == 'GET':
raise NotImplementedError('coming soon')
else:
if 'tabs' in request.json:
def get_location_for_tab(tab):
""" Returns the location (old-style) for a tab. """
return loc_mapper().translate_locator_to_location(BlockUsageLocator(tab))
tabs = request.json['tabs']
# get list of existing static tabs in course # get list of existing static tabs in course
# make sure they are the same lengths (i.e. the number of passed in tabs equals the number # make sure they are the same lengths (i.e. the number of passed in tabs equals the number
# that we know about) otherwise we can drop some! # that we know about) otherwise we will inadvertently drop some!
existing_static_tabs = [t for t in course_item.tabs if t['type'] == 'static_tab']
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
if len(existing_static_tabs) != len(tabs): if len(existing_static_tabs) != len(tabs):
return HttpResponseBadRequest() return JsonResponse(
{"error": "number of tabs must be {}".format(len(existing_static_tabs))}, status=400
)
# load all reference tabs, return BadRequest if we can't find any of them # load all reference tabs, return BadRequest if we can't find any of them
tab_items = [] tab_items = []
for tab in tabs: for tab in tabs:
item = modulestore('direct').get_item(get_location_for_tab(tab)) item = modulestore('direct').get_item(get_location_for_tab(tab))
if item is None: if item is None:
return HttpResponseBadRequest() return JsonResponse(
{"error": "no tab for found location {}".format(tab)}, status=400
)
tab_items.append(item) tab_items.append(item)
# now just go through the existing course_tabs and re-order the static tabs # now just go through the existing course_tabs and re-order the static tabs
reordered_tabs = [] reordered_tabs = []
static_tab_idx = 0 static_tab_idx = 0
for tab in course.tabs: for tab in course_item.tabs:
if tab['type'] == 'static_tab': if tab['type'] == 'static_tab':
reordered_tabs.append({'type': 'static_tab', reordered_tabs.append(
{'type': 'static_tab',
'name': tab_items[static_tab_idx].display_name, 'name': tab_items[static_tab_idx].display_name,
'url_slug': tab_items[static_tab_idx].location.name}) 'url_slug': tab_items[static_tab_idx].location.name,
}
)
static_tab_idx += 1 static_tab_idx += 1
else: else:
reordered_tabs.append(tab) reordered_tabs.append(tab)
# OK, re-assemble the static tabs in the new order # OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs course_item.tabs = reordered_tabs
# Save the data that we've just changed to the underlying modulestore('direct').update_metadata(course_item.location, own_metadata(course_item))
# MongoKeyValueStore before we update the mongo datastore. return JsonResponse()
course.save() else:
modulestore('direct').update_metadata(course.location, own_metadata(course)) raise NotImplementedError('Creating or changing tab content is not supported.')
# TODO: above two lines are used for the primitive-save case. Maybe factor them out? elif request.method == 'GET': # assume html
return HttpResponse() # see tabs have been uninitialized (e.g. supporting courses created before tab support in studio)
@login_required
@ensure_csrf_cookie
def edit_tabs(request, org, course, coursename):
"Edit tabs"
location = ['i4x', org, course, 'course', coursename]
store = get_modulestore(location)
course_item = store.get_item(location)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# see tabs have been uninitialized (e.g. supporing courses created before tab support in studio)
if course_item.tabs is None or len(course_item.tabs) == 0: if course_item.tabs is None or len(course_item.tabs) == 0:
initialize_course_tabs(course_item) initialize_course_tabs(course_item)
...@@ -121,29 +133,24 @@ def edit_tabs(request, org, course, coursename): ...@@ -121,29 +133,24 @@ def edit_tabs(request, org, course, coursename):
static_tabs = [] static_tabs = []
for static_tab_ref in static_tabs_refs: for static_tab_ref in static_tabs_refs:
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug']) static_tab_loc = old_location.replace(category='static_tab', name=static_tab_ref['url_slug'])
static_tabs.append(modulestore('direct').get_item(static_tab_loc)) static_tabs.append(modulestore('direct').get_item(static_tab_loc))
components = [ components = [
[
static_tab.location.url(),
loc_mapper().translate_location( loc_mapper().translate_location(
course_item.location.course_id, static_tab.location, False, True course_item.location.course_id, static_tab.location, False, True
) )
]
for static_tab for static_tab
in static_tabs in static_tabs
] ]
course_locator = loc_mapper().translate_location(
course_item.location.course_id, course_item.location, False, True
)
return render_to_response('edit-tabs.html', { return render_to_response('edit-tabs.html', {
'context_course': course_item, 'context_course': course_item,
'components': components, 'components': components,
'locator': course_locator 'course_locator': locator
}) })
else:
return HttpResponseNotFound()
# "primitive" tab edit functions driven by the command line. # "primitive" tab edit functions driven by the command line.
...@@ -167,7 +174,7 @@ def primitive_delete(course, num): ...@@ -167,7 +174,7 @@ def primitive_delete(course, num):
# Note for future implementations: if you delete a static_tab, then Chris Dodge # Note for future implementations: if you delete a static_tab, then Chris Dodge
# points out that there's other stuff to delete beyond this element. # points out that there's other stuff to delete beyond this element.
# This code happens to not delete static_tab so it doesn't come up. # This code happens to not delete static_tab so it doesn't come up.
primitive_save(course) modulestore('direct').update_metadata(course.location, own_metadata(course))
def primitive_insert(course, num, tab_type, name): def primitive_insert(course, num, tab_type, name):
...@@ -176,11 +183,5 @@ def primitive_insert(course, num, tab_type, name): ...@@ -176,11 +183,5 @@ def primitive_insert(course, num, tab_type, name):
new_tab = {u'type': unicode(tab_type), u'name': unicode(name)} new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
tabs = course.tabs tabs = course.tabs
tabs.insert(num, new_tab) tabs.insert(num, new_tab)
primitive_save(course)
def primitive_save(course):
"Saves the course back to modulestore."
# This code copied from reorder_static_tabs above
course.save()
modulestore('direct').update_metadata(course.location, own_metadata(course)) modulestore('direct').update_metadata(course.location, own_metadata(course))
import re
import logging
import datetime
import json
from json.encoder import JSONEncoder
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
import json
from json.encoder import JSONEncoder
from contentstore.utils import get_modulestore, course_image_url from contentstore.utils import get_modulestore, course_image_url
from models.settings import course_grading from models.settings import course_grading
from contentstore.utils import update_item from contentstore.utils import update_item
from xmodule.fields import Date from xmodule.fields import Date
import re from xmodule.modulestore.django import loc_mapper
import logging
import datetime
class CourseDetails(object): class CourseDetails(object):
def __init__(self, location): def __init__(self, org, course_id, run):
self.course_location = location # a Location obj # still need these for now b/c the client's screen shows these 3 fields
self.org = org
self.course_id = course_id
self.run = run
self.start_date = None # 'start' self.start_date = None # 'start'
self.end_date = None # 'end' self.end_date = None # 'end'
self.enrollment_start = None self.enrollment_start = None
...@@ -31,12 +36,9 @@ class CourseDetails(object): ...@@ -31,12 +36,9 @@ class CourseDetails(object):
""" """
Fetch the course details for the given course from persistence and return a CourseDetails model. Fetch the course details for the given course from persistence and return a CourseDetails model.
""" """
if not isinstance(course_location, Location): course_old_location = loc_mapper().translate_locator_to_location(course_location)
course_location = Location(course_location) descriptor = get_modulestore(course_old_location).get_item(course_old_location)
course = cls(course_old_location.org, course_old_location.course, course_old_location.name)
course = cls(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
course.start_date = descriptor.start course.start_date = descriptor.start
course.end_date = descriptor.end course.end_date = descriptor.end
...@@ -45,7 +47,7 @@ class CourseDetails(object): ...@@ -45,7 +47,7 @@ class CourseDetails(object):
course.course_image_name = descriptor.course_image course.course_image_name = descriptor.course_image
course.course_image_asset_path = course_image_url(descriptor) course.course_image_asset_path = course_image_url(descriptor)
temploc = course_location.replace(category='about', name='syllabus') temploc = course_old_location.replace(category='about', name='syllabus')
try: try:
course.syllabus = get_modulestore(temploc).get_item(temploc).data course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
...@@ -73,14 +75,12 @@ class CourseDetails(object): ...@@ -73,14 +75,12 @@ class CourseDetails(object):
return course return course
@classmethod @classmethod
def update_from_json(cls, jsondict): def update_from_json(cls, course_location, jsondict):
""" """
Decode the json into CourseDetails and save any changed attrs to the db Decode the json into CourseDetails and save any changed attrs to the db
""" """
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore course_old_location = loc_mapper().translate_locator_to_location(course_location)
course_location = Location(jsondict['course_location']) descriptor = get_modulestore(course_old_location).get_item(course_old_location)
# Will probably want to cache the inflight courses because every blur generates an update
descriptor = get_modulestore(course_location).get_item(course_location)
dirty = False dirty = False
...@@ -134,11 +134,11 @@ class CourseDetails(object): ...@@ -134,11 +134,11 @@ class CourseDetails(object):
# MongoKeyValueStore before we update the mongo datastore. # MongoKeyValueStore before we update the mongo datastore.
descriptor.save() descriptor.save()
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) get_modulestore(course_old_location).update_metadata(course_old_location, own_metadata(descriptor))
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed. # to make faster, could compare against db or could have client send over a list of which fields changed.
temploc = Location(course_location).replace(category='about', name='syllabus') temploc = Location(course_old_location).replace(category='about', name='syllabus')
update_item(temploc, jsondict['syllabus']) update_item(temploc, jsondict['syllabus'])
temploc = temploc.replace(name='overview') temploc = temploc.replace(name='overview')
...@@ -151,7 +151,7 @@ class CourseDetails(object): ...@@ -151,7 +151,7 @@ class CourseDetails(object):
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag) update_item(temploc, recomposed_video_tag)
# Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # Could just return jsondict w/o doing any db reads, but I put the reads in as a means to confirm
# it persisted correctly # it persisted correctly
return CourseDetails.fetch(course_location) return CourseDetails.fetch(course_location)
...@@ -188,6 +188,9 @@ class CourseDetails(object): ...@@ -188,6 +188,9 @@ class CourseDetails(object):
# TODO move to a more general util? # TODO move to a more general util?
class CourseSettingsEncoder(json.JSONEncoder): class CourseSettingsEncoder(json.JSONEncoder):
"""
Serialize CourseDetails, CourseGradingModel, datetime, and old Locations
"""
def default(self, obj): def default(self, obj):
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)): if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__ return obj.__dict__
......
...@@ -166,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') ...@@ -166,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY: if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
if AWS_ACCESS_KEY_ID == "":
AWS_ACCESS_KEY_ID = None
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
if AWS_SECRET_ACCESS_KEY == "":
AWS_SECRET_ACCESS_KEY = None
DATABASES = AUTH_TOKENS['DATABASES'] DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = AUTH_TOKENS['MODULESTORE'] MODULESTORE = AUTH_TOKENS['MODULESTORE']
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
......
...@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ...@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
################################# LMS INTEGRATION ############################# ################################# LMS INTEGRATION #############################
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" LMS_BASE = "localhost:8000"
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE
################################# CELERY ###################################### ################################# CELERY ######################################
......
...@@ -197,7 +197,8 @@ define([ ...@@ -197,7 +197,8 @@ define([
"js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_spec", "js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_spec",
"js/spec/transcripts/file_uploader_spec", "js/spec/transcripts/file_uploader_spec",
"js/spec/utils/module_spec" "js/spec/utils/module_spec",
"js/spec/models/explicit_url_spec"
# these tests are run separate in the cms-squire suite, due to process # these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js # isolation issues with Squire.js
......
...@@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model ...@@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@handoutsEdit.$el.find('.edit-button').click() @handoutsEdit.$el.find('.edit-button').click()
expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg') expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg')
it "can open course handouts with bad html on edit", ->
# Enter some bad html in handouts section, verifying that the
# model/handoutform opens when "Edit" is clicked
@model = new ModuleInfo({
id: 'handouts-id',
data: '<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>'
})
@handoutsEdit = new CourseInfoHandoutsView({
el: $('#course-handouts-view'),
model: @model,
base_asset_url: 'base-asset-url/'
});
@handoutsEdit.render()
expect($('.edit-handouts-form').is(':hidden')).toEqual(true)
@handoutsEdit.$el.find('.edit-button').click()
expect(@handoutsEdit.$codeMirror.getValue()).toEqual('<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>')
expect($('.edit-handouts-form').is(':hidden')).toEqual(false)
\ No newline at end of file
define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) ->
describe "ModuleEdit", -> describe "ModuleEdit", ->
beforeEach -> beforeEach ->
@stubModule = jasmine.createSpy("Module") @stubModule = new ModuleModel
@stubModule.id = 'stub-id' id: "stub-id"
@stubModule.get = (param)->
if param == 'old_id'
return 'stub-old-id'
setFixtures """ setFixtures """
<li class="component" id="stub-id"> <li class="component" id="stub-id">
...@@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> ...@@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
@moduleEdit.render() @moduleEdit.render()
it "loads the module preview and editor via ajax on the view element", -> it "loads the module preview and editor via ajax on the view element", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.get('old_id')}", jasmine.any(Function)) expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/xblock/#{@moduleEdit.model.id}", jasmine.any(Function))
@moduleEdit.$el.load.mostRecentCall.args[1]() @moduleEdit.$el.load.mostRecentCall.args[1]()
expect(@moduleEdit.loadDisplay).toHaveBeenCalled() expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
expect(@moduleEdit.delegateEvents).toHaveBeenCalled() expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
......
...@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base ...@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
appendSetFixtures """ appendSetFixtures """
<section class="courseware-section branch" data-locator="a-location-goes-here"> <section class="courseware-section branch" data-locator="a-location-goes-here">
<li class="branch collapsed id-holder" data-id="an-id-goes-here" data-locator="an-id-goes-here"> <li class="branch collapsed id-holder" data-locator="an-id-goes-here">
<a href="#" class="delete-section-button"></a> <a href="#" class="delete-section-button"></a>
</li> </li>
</section> </section>
......
...@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", ...@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
payload payload
(data) => (data) =>
@model.set(id: data.locator) @model.set(id: data.locator)
@model.set(old_id: data.id)
@$el.data('id', data.id)
@$el.data('locator', data.locator) @$el.data('locator', data.locator)
@render() @render()
) )
render: -> render: ->
if @model.get('old_id') if @model.id
@$el.load("/preview_component/#{@model.get('old_id')}", => @$el.load(@model.url(), =>
@loadDisplay() @loadDisplay()
@delegateEvents() @delegateEvents()
) )
......
...@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
initialize: => initialize: =>
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
model = new ModuleModel({ model = new ModuleModel({
id: $(element).data('locator'), id: $(element).data('locator')
old_id:$(element).data('id')
}) })
new ModuleEditView( new ModuleEditView(
...@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views ...@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
analytics.track "Reordered Static Pages", analytics.track "Reordered Static Pages",
course: course_location_analytics course: course_location_analytics
saving = new NotificationView.Mini({title: gettext("Saving&hellip;")})
saving.show()
$.ajax({ $.ajax({
type:'POST', type:'POST',
url: '/reorder_static_tabs', url: @model.url(),
data: JSON.stringify({ data: JSON.stringify({
tabs : tabs tabs : tabs
}), }),
contentType: 'application/json' contentType: 'application/json'
}) }).success(=> saving.hide())
addNewTab: (event) => addNewTab: (event) =>
event.preventDefault() event.preventDefault()
......
...@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@$('.component').each (idx, element) => @$('.component').each (idx, element) =>
model = new ModuleModel model = new ModuleModel
id: $(element).data('locator') id: $(element).data('locator')
old_id: $(element).data('id')
new ModuleEditView new ModuleEditView
el: element, el: element,
onDelete: @deleteComponent, onDelete: @deleteComponent,
...@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@wait(true) @wait(true)
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
url: @model.urlRoot + "/" + @$el.data('locator') + "?" + $.param({recurse: true}) url: @model.url() + "?" + $.param({recurse: true})
}).success(=> }).success(=>
analytics.track "Deleted Draft", analytics.track "Deleted Draft",
...@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
createDraft: (event) -> createDraft: (event) ->
@wait(true) @wait(true)
$.postJSON('/create_draft', { $.postJSON(@model.url(), {
id: @$el.data('id') publish: 'create_draft'
}, => }, =>
analytics.track "Created Draft", analytics.track "Created Draft",
course: course_location_analytics course: course_location_analytics
...@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@wait(true) @wait(true)
@saveDraft() @saveDraft()
$.postJSON('/publish_draft', { $.postJSON(@model.url(), {
id: @$el.data('id') publish: 'make_public'
}, => }, =>
analytics.track "Published Draft", analytics.track "Published Draft",
course: course_location_analytics course: course_location_analytics
...@@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
setVisibility: (event) -> setVisibility: (event) ->
if @$('.visibility-select').val() == 'private' if @$('.visibility-select').val() == 'private'
target_url = '/unpublish_unit' action = 'make_private'
visibility = "private" visibility = "private"
else else
target_url = '/publish_draft' action = 'make_public'
visibility = "public" visibility = "public"
@wait(true) @wait(true)
$.postJSON(target_url, { $.postJSON(@model.url(), {
id: @$el.data('id') publish: action
}, => }, =>
analytics.track "Set Unit Visibility", analytics.track "Set Unit Visibility",
course: course_location_analytics course: course_location_analytics
......
...@@ -237,7 +237,7 @@ function createNewUnit(e) { ...@@ -237,7 +237,7 @@ function createNewUnit(e) {
function(data) { function(data) {
// redirect to the edit page // redirect to the edit page
window.location = "/edit/" + data['id']; window.location = "/unit/" + data['locator'];
}); });
} }
......
...@@ -2,10 +2,6 @@ define(["backbone", "js/models/settings/course_grader"], function(Backbone, Cour ...@@ -2,10 +2,6 @@ define(["backbone", "js/models/settings/course_grader"], function(Backbone, Cour
var CourseGraderCollection = Backbone.Collection.extend({ var CourseGraderCollection = Backbone.Collection.extend({
model : CourseGrader, model : CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/';
},
sumWeights : function() { sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
} }
......
define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) { define(["backbone", "underscore"], function(Backbone, _) {
var AssignmentGrade = Backbone.Model.extend({ var AssignmentGrade = Backbone.Model.extend({
defaults : { defaults : {
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral graderType : null, // the type label (string). May be "Not Graded" which implies None.
location : null // A location object locator : null // locator for the block
}, },
initialize : function(attrs) { idAttribute: 'locator',
if (attrs['assignmentUrl']) { urlRoot : '/xblock/',
this.set('location', new Location(attrs['assignmentUrl'], {parse: true})); url: function() {
} // add ?fields=graderType to the request url (only needed for fetch, but innocuous for others)
}, return Backbone.Model.prototype.url.apply(this) + '?' + $.param({fields: 'graderType'});
parse : function(attrs) {
if (attrs && attrs['location']) {
attrs.location = new Location(attrs['location'], {parse: true});
}
},
urlRoot : function() {
if (this.has('location')) {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/'
+ location.get('name') + '/gradeas/';
}
else return "";
} }
}); });
return AssignmentGrade; return AssignmentGrade;
......
...@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) { ...@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) {
url: '', url: '',
defaults: { defaults: {
"courseId": "", // the location url
"updates" : null, // UpdateCollection "updates" : null, // UpdateCollection
"handouts": null // HandoutCollection "handouts": null // HandoutCollection
}, }
idAttribute : "courseId"
}); });
return CourseInfo; return CourseInfo;
}); });
/**
* A model that simply allows the update URL to be passed
* in as an argument.
*/
define(["backbone"], function(Backbone){
return Backbone.Model.extend({
defaults: {
"explicit_url": ""
},
url: function() {
return this.get("explicit_url");
}
});
});
define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) { define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
var CourseDetails = Backbone.Model.extend({ var CourseDetails = Backbone.Model.extend({
defaults: { defaults: {
location : null, // the course's Location model, required org : '',
course_id: '',
run: '',
start_date: null, // maps to 'start' start_date: null, // maps to 'start'
end_date: null, // maps to 'end' end_date: null, // maps to 'end'
enrollment_start: null, enrollment_start: null,
...@@ -17,9 +19,6 @@ var CourseDetails = Backbone.Model.extend({ ...@@ -17,9 +19,6 @@ var CourseDetails = Backbone.Model.extend({
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) {
attributes.location = new Location(attributes.course_location, {parse:true});
}
if (attributes['start_date']) { if (attributes['start_date']) {
attributes.start_date = new Date(attributes.start_date); attributes.start_date = new Date(attributes.start_date);
} }
......
...@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"], ...@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"],
var CourseGradingPolicy = Backbone.Model.extend({ var CourseGradingPolicy = Backbone.Model.extend({
defaults : { defaults : {
course_location : null,
graders : null, // CourseGraderCollection graders : null, // CourseGraderCollection
grade_cutoffs : null, // CourseGradeCutoff model grade_cutoffs : null, // CourseGradeCutoff model
grace_period : null // either null or { hours: n, minutes: m, ...} grace_period : null // either null or { hours: n, minutes: m, ...}
}, },
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) {
attributes.course_location = new Location(attributes.course_location, {parse:true});
}
if (attributes['graders']) { if (attributes['graders']) {
var graderCollection; var graderCollection;
// interesting race condition: if {parse:true} when newing, then parse called before .attributes created // interesting race condition: if {parse:true} when newing, then parse called before .attributes created
...@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ ...@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
} }
else { else {
graderCollection = new CourseGraderCollection(attributes.graders, {parse:true}); graderCollection = new CourseGraderCollection(attributes.graders, {parse:true});
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
} }
attributes.graders = graderCollection; attributes.graders = graderCollection;
} }
...@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ ...@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
} }
return attributes; return attributes;
}, },
url : function() {
var location = this.get('course_location');
return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/grading';
},
gracePeriodToDate : function() { gracePeriodToDate : function() {
var newDate = new Date(); var newDate = new Date();
if (this.has('grace_period') && this.get('grace_period')['hours']) if (this.has('grace_period') && this.get('grace_period')['hours'])
......
define(['js/models/explicit_url'],
function (Model) {
describe('Model ', function () {
it('allows url to be passed in constructor', function () {
expect(new Model({'explicit_url': '/fancy/url'}).url()).toBe('/fancy/url');
});
it('returns empty string if url not set', function () {
expect(new Model().url()).toBe('');
});
});
}
);
...@@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" ...@@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
model: this.model model: this.model
})) }))
); );
$('.handouts-content').html(this.model.get('data'));
this.$preview = this.$el.find('.handouts-content'); this.$preview = this.$el.find('.handouts-content');
this.$form = this.$el.find(".edit-handouts-form"); this.$form = this.$el.find(".edit-handouts-form");
this.$editor = this.$form.find('.handouts-content-editor'); this.$editor = this.$form.find('.handouts-content-editor');
...@@ -50,6 +51,9 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" ...@@ -50,6 +51,9 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
}, },
onSave: function(event) { onSave: function(event) {
$('#handout_error').removeClass('is-shown');
$('.save-button').removeClass('is-disabled');
if ($('.CodeMirror-lines').find('.cm-error').length == 0){
this.model.set('data', this.$codeMirror.getValue()); this.model.set('data', this.$codeMirror.getValue());
var saving = new NotificationView.Mini({ var saving = new NotificationView.Mini({
title: gettext('Saving&hellip;') title: gettext('Saving&hellip;')
...@@ -67,15 +71,23 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" ...@@ -67,15 +71,23 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
analytics.track('Saved Course Handouts', { analytics.track('Saved Course Handouts', {
'course': course_location_analytics 'course': course_location_analytics
}); });
}else{
$('#handout_error').addClass('is-shown');
$('.save-button').addClass('is-disabled');
event.preventDefault();
}
}, },
onCancel: function(event) { onCancel: function(event) {
$('#handout_error').removeClass('is-shown');
$('.save-button').removeClass('is-disabled');
this.$form.hide(); this.$form.hide();
this.closeEditor(); this.closeEditor();
}, },
closeEditor: function() { closeEditor: function() {
$('#handout_error').removeClass('is-shown');
$('.save-button').removeClass('is-disabled');
this.$form.hide(); this.$form.hide();
ModalUtils.hideModalCover(); ModalUtils.hideModalCover();
this.$form.find('.CodeMirror').remove(); this.$form.find('.CodeMirror').remove();
......
...@@ -6,7 +6,10 @@ define(["codemirror", "utility"], ...@@ -6,7 +6,10 @@ define(["codemirror", "utility"],
var $codeMirror = CodeMirror.fromTextArea(textArea, { var $codeMirror = CodeMirror.fromTextArea(textArea, {
mode: "text/html", mode: "text/html",
lineNumbers: true, lineNumbers: true,
lineWrapping: true lineWrapping: true,
onChange: function () {
$('.save-button').removeClass('is-disabled');
}
}); });
$codeMirror.setValue(content); $codeMirror.setValue(content);
$codeMirror.clearHistory(); $codeMirror.clearHistory();
......
...@@ -21,7 +21,7 @@ define(["backbone", "underscore", "gettext", "js/models/assignment_grade", "js/v ...@@ -21,7 +21,7 @@ define(["backbone", "underscore", "gettext", "js/models/assignment_grade", "js/v
'<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' + '<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' +
'</ul>'); '</ul>');
this.assignmentGrade = new AssignmentGrade({ this.assignmentGrade = new AssignmentGrade({
assignmentUrl : this.$el.closest('.id-holder').data('id'), locator : this.$el.closest('.id-holder').data('locator'),
graderType : this.$el.data('initial-status')}); graderType : this.$el.data('initial-status')});
// TODO throw exception if graders is null // TODO throw exception if graders is null
this.graders = this.options['graders']; this.graders = this.options['graders'];
......
...@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({ ...@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({
initialize : function() { initialize : function() {
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>'); this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>');
// fill in fields // fill in fields
this.$el.find("#course-name").val(this.model.get('location').get('name')); this.$el.find("#course-organization").val(this.model.get('org'));
this.$el.find("#course-organization").val(this.model.get('location').get('org')); this.$el.find("#course-number").val(this.model.get('course_id'));
this.$el.find("#course-number").val(this.model.get('location').get('course')); this.$el.find("#course-name").val(this.model.get('run'));
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
// Avoid showing broken image on mistyped/nonexistent image // Avoid showing broken image on mistyped/nonexistent image
......
...@@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass ...@@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass
<a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a> <a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a>
<div class="modal-body"> <div class="modal-body">
<h1 class="title">${_("Upload New File")}</h1> <h1 class="title">${_("Upload New File")}</h1>
<p class="file-name"></a> <p class="file-name">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill"></div> <div class="progress-fill"></div>
</div> </div>
......
...@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul ...@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul
var editor = new CourseInfoEditView({ var editor = new CourseInfoEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model : new CourseInfoModel({ model : new CourseInfoModel({
courseId : '${context_course.location}',
updates : course_updates, updates : course_updates,
base_asset_url : '${base_asset_url}', base_asset_url : '${base_asset_url}',
handouts : course_handouts handouts : course_handouts
......
...@@ -9,12 +9,15 @@ ...@@ -9,12 +9,15 @@
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <script type='text/javascript'>
require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) { require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, TabsEditView) {
var model = new TabsModel({
id: "${course_locator}",
explicit_url: "${course_locator.url_reverse('tabs')}"
});
new TabsEditView({ new TabsEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model: new Backbone.Model({ model: model,
id: '${locator}'
}),
mast: $('.wrapper-mast') mast: $('.wrapper-mast')
}); });
}); });
...@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) ...@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView)
<div class="tab-list"> <div class="tab-list">
<ol class='components'> <ol class='components'>
% for id, locator in components: % for locator in components:
<li class="component" data-id="${id}" data-locator="${locator}"/> <li class="component" data-locator="${locator}"/>
% endfor % endfor
<li class="new-component-item"> <li class="new-component-item">
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
</div> </div>
<div class="sidebar"> <div class="sidebar">
<div class="unit-settings window id-holder" data-id="${subsection.location}"> <div class="unit-settings window id-holder" data-locator="${locator}">
<h4 class="header">${_("Subsection Settings")}</h4> <h4 class="header">${_("Subsection Settings")}</h4>
<div class="window-contents"> <div class="window-contents">
<div class="scheduled-date-input row"> <div class="scheduled-date-input row">
...@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm ...@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm
// but we really should change that behavior. // but we really should change that behavior.
if (!window.graderTypes) { if (!window.graderTypes) {
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
window.graderTypes.course_location = new Location('${parent_location}');
} }
$(".gradable-status").each(function(index, ele) { $(".gradable-status").each(function(index, ele) {
......
...@@ -3,12 +3,13 @@ ...@@ -3,12 +3,13 @@
<h2 class="title">Course Handouts</h2> <h2 class="title">Course Handouts</h2>
<%if (model.get('data') != null) { %> <%if (model.get('data') != null) { %>
<div class="handouts-content"> <div class="handouts-content">
<%= model.get('data') %>
</div> </div>
<% } else {%> <% } else {%>
<p>${_("You have no handouts defined")}</p> <p>${_("You have no handouts defined")}</p>
<% } %> <% } %>
<form class="edit-handouts-form" style="display: block;"> <form class="edit-handouts-form" style="display: block;">
<div class="message message-status error" name="handout_html_error" id="handout_error"><%=gettext("There is invalid code in your content. Please check to make sure it is valid HTML.")%></div>
<div class="row"> <div class="row">
<textarea class="handouts-content-editor text-editor"></textarea> <textarea class="handouts-content-editor text-editor"></textarea>
</div> </div>
......
...@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
// but we really should change that behavior. // but we really should change that behavior.
if (!window.graderTypes) { if (!window.graderTypes) {
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
window.graderTypes.course_location = new Location('${parent_location}');
} }
$(".gradable-status").each(function(index, ele) { $(".gradable-status").each(function(index, ele) {
...@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
context_course.location.course_id, subsection.location, False, True context_course.location.course_id, subsection.location, False, True
) )
%> %>
<li class="courseware-subsection branch collapsed id-holder is-draggable" data-id="${subsection.location}" <li class="courseware-subsection branch collapsed id-holder is-draggable"
data-parent="${section_locator}" data-locator="${subsection_locator}"> data-parent="${section_locator}" data-locator="${subsection_locator}">
<%include file="widgets/_ui-dnd-indicator-before.html" /> <%include file="widgets/_ui-dnd-indicator-before.html" />
...@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<div class="section-item"> <div class="section-item">
<div class="details"> <div class="details">
<a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="expand-collapse-icon expand"></a> <a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="expand-collapse-icon expand"></a>
<a href="${reverse('edit_subsection', args=[subsection.location])}"> <a href="${subsection_locator.url_reverse('subsection')}">
<span class="folder-icon"></span> <span class="folder-icon"></span>
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
</a> </a>
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from contentstore import utils
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.django import loc_mapper
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -69,17 +68,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -69,17 +68,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<ol class="list-input"> <ol class="list-input">
<li class="field text is-not-editable" id="field-course-organization"> <li class="field text is-not-editable" id="field-course-organization">
<label for="course-organization">${_("Organization")}</label> <label for="course-organization">${_("Organization")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-organization" value="[Course Organization]" readonly /> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="course-organization" readonly />
</li> </li>
<li class="field text is-not-editable" id="field-course-number"> <li class="field text is-not-editable" id="field-course-number">
<label for="course-number">${_("Course Number")}</label> <label for="course-number">${_("Course Number")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="short" id="course-number" value="[Course No.]" readonly> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="short" id="course-number" readonly>
</li> </li>
<li class="field text is-not-editable" id="field-course-name"> <li class="field text is-not-editable" id="field-course-name">
<label for="course-name">${_("Course Name")}</label> <label for="course-name">${_("Course Name")}</label>
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-name" value="[Course Name]" readonly /> <input title="${_('This field is disabled: this information cannot be changed.')}" type="text"
class="long" id="course-name" readonly />
</li> </li>
</ol> </ol>
...@@ -87,12 +89,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -87,12 +89,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="note note-promotion note-promotion-courseURL has-actions"> <div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3> <h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
<div class="copy"> <div class="copy">
<p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p> <p><a class="link-courseURL" rel="external" href="https:${lms_link_for_about_page}">https:${lms_link_for_about_page}</a></p>
</div> </div>
<ul class="list-actions"> <ul class="list-actions">
<li class="action-item"> <li class="action-item">
<a title="${_('Send a note to students via email')}" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a> <a title="${_('Send a note to students via email')}"
href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${lms_link_for_about_page}%20to%20enroll." class="action action-primary">
<i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -199,7 +203,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -199,7 +203,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<%def name='overview_text()'><% <%def name='overview_text()'><%
a_link_start = '<a class="link-courseURL" rel="external" href="' a_link_start = '<a class="link-courseURL" rel="external" href="'
a_link_end = '">' + _("your course summary page") + '</a>' a_link_end = '">' + _("your course summary page") + '</a>'
a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end a_link = a_link_start + lms_link_for_about_page + a_link_end
text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link
%>${text}</%def> %>${text}</%def>
<span class="tip tip-stacked">${overview_text()}</span> <span class="tip tip-stacked">${overview_text()}</span>
...@@ -211,15 +215,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -211,15 +215,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="current current-course-image"> <div class="current current-course-image">
% if context_course.course_image: % if context_course.course_image:
<span class="wrapper-course-image"> <span class="wrapper-course-image">
<img class="course-image" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> <img class="course-image" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/>
</span> </span>
<% ctx_loc = context_course.location %> <span class="msg msg-help">
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href='${upload_asset_url}'>${_("files &amp; uploads")}</a></span> ${_("You can manage this image along with all of your other <a href='{}'>files &amp; uploads</a>").format(upload_asset_url)}
</span>
% else: % else:
<span class="wrapper-course-image"> <span class="wrapper-course-image">
<img class="course-image placeholder" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> <img class="course-image placeholder" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/>
</span> </span>
<span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span> <span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span>
% endif % endif
...@@ -286,14 +291,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s ...@@ -286,14 +291,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
<div class="bit"> <div class="bit">
% if context_course: % if context_course:
<% <%
course_team_url = course_locator.url_reverse('course_team/', '')
grading_config_url = course_locator.url_reverse('settings/grading/')
ctx_loc = context_course.location ctx_loc = context_course.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
course_team_url = location.url_reverse('course_team/', '')
%> %>
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> <li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> <li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
</ul> </ul>
......
...@@ -96,8 +96,8 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting ...@@ -96,8 +96,8 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li> <li class="nav-item"><a href="${course_locator.url_reverse('settings/details/')}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> <li class="nav-item"><a href="${course_locator.url_reverse('settings/grading/')}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
</ul> </ul>
</nav> </nav>
......
...@@ -28,9 +28,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings ...@@ -28,9 +28,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
var model = new CourseGradingPolicyModel(${course_details|n},{parse:true});
model.urlRoot = '${grading_url}';
var editor = new GradingView({ var editor = new GradingView({
el: $('.settings-grading'), el: $('.settings-grading'),
model : new CourseGradingPolicyModel(${course_details|n},{parse:true}) model : model
}); });
editor.render(); editor.render();
...@@ -138,13 +140,12 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings ...@@ -138,13 +140,12 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
% if context_course: % if context_course:
<% <%
ctx_loc = context_course.location ctx_loc = context_course.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) course_team_url = course_locator.url_reverse('course_team/')
course_team_url = location.url_reverse('course_team/', '')
%> %>
<h3 class="title-3">${_("Other Course Settings")}</h3> <h3 class="title-3">${_("Other Course Settings")}</h3>
<nav class="nav-related"> <nav class="nav-related">
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details &amp; Schedule")}</a></li> <li class="nav-item"><a href="${course_locator.url_reverse('settings/details/')}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> <li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
</ul> </ul>
......
...@@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -34,7 +34,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-id="${unit_location}" data-locator="${unit_locator}"> <div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_locator}">
<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>
...@@ -49,7 +49,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -49,7 +49,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
<p class="unit-name-input"><label>${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p> <p class="unit-name-input"><label>${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p>
<ol class="components"> <ol class="components">
% for id, locator in components: % for id, locator in components:
<li class="component" data-id="${id}" data-locator="${locator}"/> <li class="component" data-locator="${locator}" data-id="${id}" />
% endfor % endfor
<li class="new-component-item adding"> <li class="new-component-item adding">
<div class="new-component"> <div class="new-component">
...@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
</article> </article>
</div> </div>
<%
ctx_loc = context_course.location
index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course')
subsection_url = loc_mapper().translate_location(
ctx_loc.course_id, subsection.location, False, True
).url_reverse('subsection')
%>
<div class="sidebar"> <div class="sidebar">
<div class="unit-settings window"> <div class="unit-settings window">
<h4 class="header">${_("Unit Settings")}</h4> <h4 class="header">${_("Unit Settings")}</h4>
...@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
% endif % endif
${_("with the subsection {link_start}{name}{link_end}").format( ${_("with the subsection {link_start}{name}{link_end}").format(
name=subsection.display_name_with_default, name=subsection.display_name_with_default,
link_start='<a href="{url}">'.format(url=reverse('edit_subsection', kwargs={'location': subsection.location})), link_start='<a href="{url}">'.format(url=subsection_url),
link_end='</a>', link_end='</a>',
)} )}
</p> </p>
...@@ -180,14 +187,10 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -180,14 +187,10 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
</div> </div>
<ol> <ol>
<li> <li>
<%
ctx_loc = context_course.location
index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course/', '')
%>
<a href="${index_url}" class="section-item">${section.display_name_with_default}</a> <a href="${index_url}" class="section-item">${section.display_name_with_default}</a>
<ol> <ol>
<li> <li>
<a href="${reverse('edit_subsection', args=[subsection.location])}" class="section-item"> <a href="${subsection_url}" class="section-item">
<span class="folder-icon"></span> <span class="folder-icon"></span>
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
</a> </a>
......
...@@ -16,13 +16,16 @@ ...@@ -16,13 +16,16 @@
<% <%
ctx_loc = context_course.location ctx_loc = context_course.location
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
index_url = location.url_reverse('course/') index_url = location.url_reverse('course')
checklists_url = location.url_reverse('checklists/') checklists_url = location.url_reverse('checklists')
course_team_url = location.url_reverse('course_team/') course_team_url = location.url_reverse('course_team')
assets_url = location.url_reverse('assets/') assets_url = location.url_reverse('assets')
import_url = location.url_reverse('import/') import_url = location.url_reverse('import')
course_info_url = location.url_reverse('course_info/') course_info_url = location.url_reverse('course_info')
export_url = location.url_reverse('export/', '') export_url = location.url_reverse('export')
settings_url = location.url_reverse('settings/details/')
grading_url = location.url_reverse('settings/grading/')
tabs_url = location.url_reverse('tabs')
%> %>
<h2 class="info-course"> <h2 class="info-course">
<span class="sr">${_("Current Course:")}</span> <span class="sr">${_("Current Course:")}</span>
...@@ -48,7 +51,7 @@ ...@@ -48,7 +51,7 @@
<a href="${course_info_url}">${_("Updates")}</a> <a href="${course_info_url}">${_("Updates")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-pages"> <li class="nav-item nav-course-courseware-pages">
<a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a> <a href="${tabs_url}">${_("Static Pages")}</a>
</li> </li>
<li class="nav-item nav-course-courseware-uploads"> <li class="nav-item nav-course-courseware-uploads">
<a href="${assets_url}">${_("Files &amp; Uploads")}</a> <a href="${assets_url}">${_("Files &amp; Uploads")}</a>
...@@ -68,10 +71,10 @@ ...@@ -68,10 +71,10 @@
<div class="nav-sub"> <div class="nav-sub">
<ul> <ul>
<li class="nav-item nav-course-settings-schedule"> <li class="nav-item nav-course-settings-schedule">
<a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Schedule &amp; Details")}</a> <a href="${settings_url}">${_("Schedule &amp; Details")}</a>
</li> </li>
<li class="nav-item nav-course-settings-grading"> <li class="nav-item nav-course-settings-grading">
<a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a> <a href="${grading_url}">${_("Grading")}</a>
</li> </li>
<li class="nav-item nav-course-settings-team"> <li class="nav-item nav-course-settings-team">
<a href="${course_team_url}">${_("Course Team")}</a> <a href="${course_team_url}">${_("Course Team")}</a>
......
...@@ -75,7 +75,9 @@ ...@@ -75,7 +75,9 @@
<img src="${static.url("img/string-example.png")}" /> <img src="${static.url("img/string-example.png")}" />
</div> </div>
<div class="col"> <div class="col">
<pre><code>= dog</code></pre> <pre><code>= dog
or= cat
or= mouse</code></pre>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
......
<%!
from xmodule.modulestore.django import loc_mapper
%>
% if context_course:
<%
ctx_loc = context_course.location
locator = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
%>
% endif
% if settings.MITX_FEATURES.get('SEGMENT_IO'): % if settings.MITX_FEATURES.get('SEGMENT_IO'):
<!-- begin Segment.io --> <!-- begin Segment.io -->
<script type="text/javascript"> <script type="text/javascript">
// if inside course, inject the course location into the JS namespace // if inside course, inject the course location into the JS namespace
%if context_course: %if context_course:
var course_location_analytics = "${context_course.location}"; var course_location_analytics = "${locator}";
%endif %endif
var analytics=analytics||[];analytics.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=("https:"===document.location.protocol?"https://":"http://")+"d2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/"+e+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);var r=function(e){return function(){analytics.push([e].concat(Array.prototype.slice.call(arguments,0)))}},i=["identify","track","trackLink","trackForm","trackClick","trackSubmit","pageview","ab","alias","ready"];for(var s=0;s<i.length;s++)analytics[i[s]]=r(i[s])}; var analytics=analytics||[];analytics.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=("https:"===document.location.protocol?"https://":"http://")+"d2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/"+e+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);var r=function(e){return function(){analytics.push([e].concat(Array.prototype.slice.call(arguments,0)))}},i=["identify","track","trackLink","trackForm","trackClick","trackSubmit","pageview","ab","alias","ready"];for(var s=0;s<i.length;s++)analytics[i[s]]=r(i[s])};
...@@ -22,7 +33,7 @@ ...@@ -22,7 +33,7 @@
<!-- dummy segment.io --> <!-- dummy segment.io -->
<script type="text/javascript"> <script type="text/javascript">
%if context_course: %if context_course:
var course_location_analytics = "${context_course.location}"; var course_location_analytics = "${locator}";
%endif %endif
var analytics = { var analytics = {
"track": function() {} "track": function() {}
......
...@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units
selected_class = '' selected_class = ''
%> %>
<div class="section-item ${selected_class}"> <div class="section-item ${selected_class}">
<a href="${reverse('edit_unit', args=[unit.location])}" class="${unit_state}-item"> <a href="${unit_locator.url_reverse('unit')}" class="${unit_state}-item">
<span class="${unit.scope_ids.block_type}-icon"></span> <span class="${unit.scope_ids.block_type}-icon"></span>
<span class="unit-name">${unit.display_name_with_default}</span> <span class="unit-name">${unit.display_name_with_default}</span>
</a> </a>
......
...@@ -11,10 +11,6 @@ from ratelimitbackend import admin ...@@ -11,10 +11,6 @@ from ratelimitbackend import admin
admin.autodiscover() admin.autodiscover()
urlpatterns = patterns('', # nopep8 urlpatterns = patterns('', # nopep8
url(r'^$', 'contentstore.views.howitworks', name='homepage'),
url(r'^edit/(?P<location>.*?)$', 'contentstore.views.edit_unit', name='edit_unit'),
url(r'^subsection/(?P<location>.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'),
url(r'^preview_component/(?P<location>.*?)$', 'contentstore.views.preview_component', name='preview_component'),
url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'), url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'),
url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'), url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
...@@ -24,22 +20,9 @@ urlpatterns = patterns('', # nopep8 ...@@ -24,22 +20,9 @@ urlpatterns = patterns('', # nopep8
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'), url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'),
url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'), url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'),
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
url(r'^reorder_static_tabs', 'contentstore.views.reorder_static_tabs', name='reorder_static_tabs'),
url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$', url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$',
'contentstore.views.preview_handler', name='preview_handler'), 'contentstore.views.preview_handler', name='preview_handler'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$',
'contentstore.views.get_course_settings', name='settings_details'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$',
'contentstore.views.course_config_graders_page', name='settings_grading'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$',
'contentstore.views.course_settings_updates', name='course_settings'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$',
'contentstore.views.course_grader_updates', name='course_settings'),
# This is the URL to initially render the course advanced settings. # This is the URL to initially render the course advanced settings.
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$',
'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'),
...@@ -47,12 +30,6 @@ urlpatterns = patterns('', # nopep8 ...@@ -47,12 +30,6 @@ urlpatterns = patterns('', # nopep8
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$',
'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$',
'contentstore.views.assignment_type_update', name='assignment_type_update'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$',
'contentstore.views.textbook_index', name='textbook_index'), 'contentstore.views.textbook_index', name='textbook_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
...@@ -79,18 +56,12 @@ urlpatterns = patterns('', # nopep8 ...@@ -79,18 +56,12 @@ urlpatterns = patterns('', # nopep8
# User creation and updating views # User creation and updating views
urlpatterns += patterns( urlpatterns += patterns(
'', '',
url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'),
url(r'^signup$', 'contentstore.views.signup', name='signup'),
url(r'^create_account$', 'student.views.create_account'), url(r'^create_account$', 'student.views.create_account'),
url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'),
# form page
url(r'^login$', 'contentstore.views.old_login_redirect', name='old_login'),
url(r'^signin$', 'contentstore.views.login_page', name='login'),
# ajax view that actually does the work # ajax view that actually does the work
url(r'^login_post$', 'student.views.login_user', name='login_post'), url(r'^login_post$', 'student.views.login_user', name='login_post'),
url(r'^logout$', 'student.views.logout_user', name='logout'), url(r'^logout$', 'student.views.logout_user', name='logout'),
) )
...@@ -98,7 +69,12 @@ urlpatterns += patterns( ...@@ -98,7 +69,12 @@ urlpatterns += patterns(
urlpatterns += patterns( urlpatterns += patterns(
'contentstore.views', 'contentstore.views',
url(r'^$', 'howitworks', name='homepage'),
url(r'^howitworks$', 'howitworks'),
url(r'^signup$', 'signup', name='signup'),
url(r'^signin$', 'login_page', name='login'),
url(r'^request_course_creator$', 'request_course_creator'), url(r'^request_course_creator$', 'request_course_creator'),
# (?ix) == ignore case and verbose (multiline regex) # (?ix) == ignore case and verbose (multiline regex)
url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'), url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'),
url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'), url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'),
...@@ -107,6 +83,8 @@ urlpatterns += patterns( ...@@ -107,6 +83,8 @@ urlpatterns += patterns(
'course_info_update_handler' 'course_info_update_handler'
), ),
url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'), url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'),
url(r'(?ix)^subsection($|/){}$'.format(parsers.URL_RE_SOURCE), 'subsection_handler'),
url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'),
url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'), url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'), url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'), url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
...@@ -114,6 +92,9 @@ urlpatterns += patterns( ...@@ -114,6 +92,9 @@ urlpatterns += patterns(
url(r'(?ix)^import_status/{}/(?P<filename>.+)$'.format(parsers.URL_RE_SOURCE), 'import_status_handler'), url(r'(?ix)^import_status/{}/(?P<filename>.+)$'.format(parsers.URL_RE_SOURCE), 'import_status_handler'),
url(r'(?ix)^export/{}$'.format(parsers.URL_RE_SOURCE), 'export_handler'), url(r'(?ix)^export/{}$'.format(parsers.URL_RE_SOURCE), 'export_handler'),
url(r'(?ix)^xblock($|/){}$'.format(parsers.URL_RE_SOURCE), 'xblock_handler'), url(r'(?ix)^xblock($|/){}$'.format(parsers.URL_RE_SOURCE), 'xblock_handler'),
url(r'(?ix)^tabs/{}$'.format(parsers.URL_RE_SOURCE), 'tabs_handler'),
url(r'(?ix)^settings/details/{}$'.format(parsers.URL_RE_SOURCE), 'settings_handler'),
url(r'(?ix)^settings/grading/{}(/)?(?P<grader_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'grading_handler'),
) )
js_info_dict = { js_info_dict = {
......
...@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase): ...@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase):
self.user_bad_passwd.password = UNUSABLE_PASSWORD self.user_bad_passwd.password = UNUSABLE_PASSWORD
self.user_bad_passwd.save() self.user_bad_passwd.save()
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_user_bad_password_reset(self): def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
bad_pwd_resp = password_reset(bad_pwd_req) bad_pwd_resp = password_reset(bad_pwd_req)
# If they've got an unusable password, we return a successful response code
self.assertEquals(bad_pwd_resp.status_code, 200) self.assertEquals(bad_pwd_resp.status_code, 200)
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True,
'error': 'Invalid e-mail or user'})) 'value': "('registration/password_reset_done.html', [])"}))
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_nonexist_email_password_reset(self): def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email.""" """Now test the exception cases with of reset_password called with invalid email."""
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
bad_email_resp = password_reset(bad_email_req) bad_email_resp = password_reset(bad_email_req)
# Note: even if the email is bad, we return a successful response code
# This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX
self.assertEquals(bad_email_resp.status_code, 200) self.assertEquals(bad_email_resp.status_code, 200)
self.assertEquals(bad_email_resp.content, json.dumps({'success': False, self.assertEquals(bad_email_resp.content, json.dumps({'success': True,
'error': 'Invalid e-mail or user'})) 'value': "('registration/password_reset_done.html', [])"}))
@unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False), @unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False),
dedent("""Skipping Test because CMS has not provided necessary templates for password reset. dedent("""Skipping Test because CMS has not provided necessary templates for password reset.
...@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase): ...@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase):
{'status': 'processing', {'status': 'processing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, }) 'show_survey_button': False,
})
cert_status = {'status': 'unavailable'} cert_status = {'status': 'unavailable'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'processing', {'status': 'processing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False}) 'show_survey_button': False,
'mode': None
})
cert_status = {'status': 'generating', 'grade': '67'} cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating', {'status': 'generating',
'show_disabled_download_button': True, 'show_disabled_download_button': True,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
cert_status = {'status': 'regenerating', 'grade': '67'} cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating', {'status': 'generating',
'show_disabled_download_button': True, 'show_disabled_download_button': True,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'verified'
}) })
download_url = 'http://s3.edx/cert' download_url = 'http://s3.edx/cert'
cert_status = {'status': 'downloadable', 'grade': '67', cert_status = {'status': 'downloadable', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'ready', {'status': 'ready',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
...@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase): ...@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase):
'download_url': download_url, 'download_url': download_url,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
cert_status = {'status': 'notpassing', 'grade': '67', cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status), self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'notpassing', {'status': 'notpassing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': True, 'show_survey_button': True,
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
# Test a course that doesn't have a survey specified # Test a course that doesn't have a survey specified
course2 = Mock(end_of_course_survey_url=None) course2 = Mock(end_of_course_survey_url=None)
cert_status = {'status': 'notpassing', 'grade': '67', cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url} 'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course2, cert_status), self.assertEqual(_cert_info(user, course2, cert_status),
{'status': 'notpassing', {'status': 'notpassing',
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, 'show_survey_button': False,
'grade': '67' 'grade': '67',
'mode': 'honor'
}) })
......
...@@ -185,7 +185,8 @@ def _cert_info(user, course, cert_status): ...@@ -185,7 +185,8 @@ def _cert_info(user, course, cert_status):
default_info = {'status': default_status, default_info = {'status': default_status,
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False} 'show_survey_button': False,
}
if cert_status is None: if cert_status is None:
return default_info return default_info
...@@ -203,7 +204,8 @@ def _cert_info(user, course, cert_status): ...@@ -203,7 +204,8 @@ def _cert_info(user, course, cert_status):
d = {'status': status, d = {'status': status,
'show_download_url': status == 'ready', 'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating', } 'show_disabled_download_button': status == 'generating',
'mode': cert_status.get('mode', None)}
if (status in ('generating', 'ready', 'notpassing', 'restricted') and if (status in ('generating', 'ready', 'notpassing', 'restricted') and
course.end_of_course_survey_url is not None): course.end_of_course_survey_url is not None):
...@@ -296,7 +298,7 @@ def complete_course_mode_info(course_id, enrollment): ...@@ -296,7 +298,7 @@ def complete_course_mode_info(course_id, enrollment):
def dashboard(request): def dashboard(request):
user = request.user user = request.user
# Build our (course, enorllment) list for the user, but ignore any courses that no # Build our (course, enrollment) list for the user, but ignore any courses that no
# longer exist (because the course IDs have changed). Still, we don't delete those # longer exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu. # enrollments, because it could have been a data push snafu.
course_enrollment_pairs = [] course_enrollment_pairs = []
...@@ -1231,9 +1233,6 @@ def password_reset(request): ...@@ -1231,9 +1233,6 @@ def password_reset(request):
domain_override=request.get_host()) domain_override=request.get_host())
return HttpResponse(json.dumps({'success': True, return HttpResponse(json.dumps({'success': True,
'value': render_to_string('registration/password_reset_done.html', {})})) 'value': render_to_string('registration/password_reset_done.html', {})}))
else:
return HttpResponse(json.dumps({'success': False,
'error': _('Invalid e-mail or user')}))
def password_reset_confirm_wrapper( def password_reset_confirm_wrapper(
......
...@@ -946,17 +946,34 @@ class NumericalResponse(LoncapaResponse): ...@@ -946,17 +946,34 @@ class NumericalResponse(LoncapaResponse):
class StringResponse(LoncapaResponse): class StringResponse(LoncapaResponse):
'''
This response type allows one or more answers. Use `_or_` separator to set
more than 1 answer.
Example:
# One answer
<stringresponse answer="Michigan">
<textline size="20" />
</stringresponse >
# Multiple answers
<stringresponse answer="Martin Luther King_or_Dr. Martin Luther King Jr.">
<textline size="20" />
</stringresponse >
'''
response_tag = 'stringresponse' response_tag = 'stringresponse'
hint_tag = 'stringhint' hint_tag = 'stringhint'
allowed_inputfields = ['textline'] allowed_inputfields = ['textline']
required_attributes = ['answer'] required_attributes = ['answer']
max_inputfields = 1 max_inputfields = 1
correct_answer = None correct_answer = []
SEPARATOR = '_or_'
def setup_response(self): def setup_response(self):
self.correct_answer = contextualize_text( self.correct_answer = [contextualize_text(answer, self.context).strip()
self.xml.get('answer'), self.context).strip() for answer in self.xml.get('answer').split(self.SEPARATOR)]
def get_score(self, student_answers): def get_score(self, student_answers):
'''Grade a string response ''' '''Grade a string response '''
...@@ -966,23 +983,25 @@ class StringResponse(LoncapaResponse): ...@@ -966,23 +983,25 @@ class StringResponse(LoncapaResponse):
def check_string(self, expected, given): def check_string(self, expected, given):
if self.xml.get('type') == 'ci': if self.xml.get('type') == 'ci':
return given.lower() == expected.lower() return given.lower() in [i.lower() for i in expected]
return given == expected return given in expected
def check_hint_condition(self, hxml_set, student_answers): def check_hint_condition(self, hxml_set, student_answers):
given = student_answers[self.answer_id].strip() given = student_answers[self.answer_id].strip()
hints_to_show = [] hints_to_show = []
for hxml in hxml_set: for hxml in hxml_set:
name = hxml.get('name') name = hxml.get('name')
correct_answer = contextualize_text(
hxml.get('answer'), self.context).strip() correct_answer = [contextualize_text(answer, self.context).strip()
for answer in hxml.get('answer').split(self.SEPARATOR)]
if self.check_string(correct_answer, given): if self.check_string(correct_answer, given):
hints_to_show.append(name) hints_to_show.append(name)
log.debug('hints_to_show = %s', hints_to_show) log.debug('hints_to_show = %s', hints_to_show)
return hints_to_show return hints_to_show
def get_answers(self): def get_answers(self):
return {self.answer_id: self.correct_answer} return {self.answer_id: ' <b>or</b> '.join(self.correct_answer)}
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
......
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
% else: % else:
<% my_id = content_node.get('contents','') %> <% my_id = content_node.get('contents','') %>
<% my_val = value.get(my_id,'') %> <% my_val = value.get(my_id,'') %>
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/> <input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h}"/>
%endif %endif
<span class="mock_label"> <span class="mock_label">
${content_node['tail_text']} ${content_node['tail_text']}
......
...@@ -500,6 +500,7 @@ class StringResponseTest(ResponseTest): ...@@ -500,6 +500,7 @@ class StringResponseTest(ResponseTest):
xml_factory_class = StringResponseXMLFactory xml_factory_class = StringResponseXMLFactory
def test_case_sensitive(self): def test_case_sensitive(self):
# Test single answer
problem = self.build_problem(answer="Second", case_sensitive=True) problem = self.build_problem(answer="Second", case_sensitive=True)
# Exact string should be correct # Exact string should be correct
...@@ -509,7 +510,20 @@ class StringResponseTest(ResponseTest): ...@@ -509,7 +510,20 @@ class StringResponseTest(ResponseTest):
self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "Other String", "incorrect")
self.assert_grade(problem, "second", "incorrect") self.assert_grade(problem, "second", "incorrect")
# Test multiple answers
answers = ["Second", "Third", "Fourth"]
problem = self.build_problem(answer="_or_".join(answers), case_sensitive=True)
for answer in answers:
# Exact string should be correct
self.assert_grade(problem, answer, "correct")
# Other strings and the lowercase version of the string are incorrect
self.assert_grade(problem, "Other String", "incorrect")
self.assert_grade(problem, "second", "incorrect")
def test_case_insensitive(self): def test_case_insensitive(self):
# Test single answer
problem = self.build_problem(answer="Second", case_sensitive=False) problem = self.build_problem(answer="Second", case_sensitive=False)
# Both versions of the string should be allowed, regardless # Both versions of the string should be allowed, regardless
...@@ -520,9 +534,28 @@ class StringResponseTest(ResponseTest): ...@@ -520,9 +534,28 @@ class StringResponseTest(ResponseTest):
# Other strings are not allowed # Other strings are not allowed
self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "Other String", "incorrect")
# Test multiple answers
answers = ["Second", "Third", "Fourth"]
problem = self.build_problem(answer="_or_".join(answers), case_sensitive=False)
for answer in answers:
# Exact string should be correct
self.assert_grade(problem, answer, "correct")
self.assert_grade(problem, answer.lower(), "correct")
# Other strings and the lowercase version of the string are incorrect
self.assert_grade(problem, "Other String", "incorrect")
def test_hints(self): def test_hints(self):
multiple_answers = [
"Martin Luther King Junior",
"Doctor Martin Luther King Junior",
"Dr. Martin Luther King Jr.",
"Martin Luther King"
]
hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
("minnesota", "minn", "The state capital of Minnesota is St. Paul")] ("minnesota", "minn", "The state capital of Minnesota is St. Paul"),
("_or_".join(multiple_answers), "mlk", "He lead the civil right movement in the United States of America.")]
problem = self.build_problem(answer="Michigan", problem = self.build_problem(answer="Michigan",
case_sensitive=False, case_sensitive=False,
...@@ -550,6 +583,14 @@ class StringResponseTest(ResponseTest): ...@@ -550,6 +583,14 @@ class StringResponseTest(ResponseTest):
correct_map = problem.grade_answers(input_dict) correct_map = problem.grade_answers(input_dict)
self.assertEquals(correct_map.get_hint('1_2_1'), "") self.assertEquals(correct_map.get_hint('1_2_1'), "")
# We should get the same hint for each answer
for answer in multiple_answers:
input_dict = {'1_2_1': answer}
correct_map = problem.grade_answers(input_dict)
self.assertEquals(correct_map.get_hint('1_2_1'),
"He lead the civil right movement in the United States of America.")
def test_computed_hints(self): def test_computed_hints(self):
problem = self.build_problem( problem = self.build_problem(
answer="Michigan", answer="Michigan",
......
...@@ -597,6 +597,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -597,6 +597,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
@property @property
def raw_grader(self): def raw_grader(self):
# force the caching of the xblock value so that it can detect the change
# pylint: disable=pointless-statement
self.grading_policy['GRADER']
return self._grading_policy['RAW_GRADER'] return self._grading_policy['RAW_GRADER']
@raw_grader.setter @raw_grader.setter
......
...@@ -726,21 +726,24 @@ section.problem { ...@@ -726,21 +726,24 @@ section.problem {
} }
a.full { a.full {
@include position(absolute, 0 0 1px 0); @include position(absolute, 0 0px 1px 0px);
@include box-sizing(border-box); @include box-sizing(border-box);
display: block; display: block;
padding: 4px; padding: 4px;
width: 100%;
background: #f3f3f3; background: #f3f3f3;
text-align: right; text-align: right;
font-size: .8em; font-size: 1em;
&.full-top{
@include position(absolute, 1px 0px auto 0px);
}
} }
} }
} }
.external-grader-message { .external-grader-message {
section { section {
padding-top: $baseline/2; padding-top: ($baseline*1.5);
padding-left: $baseline; padding-left: $baseline;
background-color: #fafafa; background-color: #fafafa;
color: #2c2c2c; color: #2c2c2c;
......
describe 'Collapsible', ->
html = custom_labels = html_custom = el = undefined
initialize = (template) =>
setFixtures(template)
el = $('.collapsible')
Collapsible.setCollapsibles(el)
disableFx = () =>
$.fx.off = true
enableFx = () =>
$.fx.off = false
beforeEach ->
html = '''
<section class="collapsible">
<div class="shortform">
shortform message
</div>
<div class="longform">
<p>longform is visible</p>
</div>
</section>
'''
html_custom = '''
<section class="collapsible">
<div class="shortform-custom" data-open-text="Show shortform-custom" data-close-text="Hide shortform-custom">
shortform message
</div>
<div class="longform">
<p>longform is visible</p>
</div>
</section>
'''
describe 'setCollapsibles', ->
it 'Default container initialized correctly', ->
initialize(html)
expect(el.find('.shortform')).toContain '.full-top'
expect(el.find('.shortform')).toContain '.full-bottom'
expect(el.find('.longform')).toBeHidden()
expect(el.find('.full')).toHandle('click')
it 'Custom container initialized correctly', ->
initialize(html_custom)
expect(el.find('.shortform-custom')).toContain '.full-custom'
expect(el.find('.full-custom')).toHaveText "Show shortform-custom"
expect(el.find('.longform')).toBeHidden()
expect(el.find('.full-custom')).toHandle('click')
describe 'toggleFull', ->
beforeEach ->
disableFx()
afterEach ->
enableFx()
it 'Default container', ->
initialize(html)
event = jQuery.Event('click', {
target: el.find('.full').get(0)
})
assertChanges = (state='closed') =>
anchors = el.find('.full')
if state is 'closed'
expect(el.find('.longform')).toBeHidden()
expect(el).not.toHaveClass('open')
text = "See full output"
else
expect(el.find('.longform')).toBeVisible()
expect(el).toHaveClass('open')
text = "Hide output"
$.each anchors, (index, el) =>
expect(el).toHaveText text
Collapsible.toggleFull(event, "See full output", "Hide output")
assertChanges('opened')
Collapsible.toggleFull(event, "See full output", "Hide output")
assertChanges('closed')
it 'Custom container', ->
initialize(html_custom)
event = jQuery.Event('click', {
target: el.find('.full-custom').get(0)
})
assertChanges = (state='closed') =>
anchors = el.find('.full-custom')
if state is 'closed'
expect(el.find('.longform')).toBeHidden()
expect(el).not.toHaveClass('open')
text = "Show shortform-custom"
else
expect(el.find('.longform')).toBeVisible()
expect(el).toHaveClass('open')
text = "Hide shortform-custom"
$.each anchors, (index, el) =>
expect(el).toHaveText text
Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom")
assertChanges('opened')
Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom")
assertChanges('closed')
...@@ -162,6 +162,21 @@ describe 'MarkdownEditingDescriptor', -> ...@@ -162,6 +162,21 @@ describe 'MarkdownEditingDescriptor', ->
</problem>""") </problem>""")
it 'markup with multiple answers doesn\'t break numerical response', ->
data = MarkdownEditingDescriptor.markdownToXml("""
Enter 1 with a tolerance:
= 1 +- .02
or= 2 +- 5%
""")
expect(data).toEqual("""<problem>
<p>Enter 1 with a tolerance:</p>
<numericalresponse answer="1">
<responseparam type="tolerance" default=".02" />
<formulaequationinput />
</numericalresponse>
</problem>""")
it 'converts multiple choice to xml', -> it 'converts multiple choice to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
...@@ -268,6 +283,32 @@ describe 'MarkdownEditingDescriptor', -> ...@@ -268,6 +283,32 @@ describe 'MarkdownEditingDescriptor', ->
</div> </div>
</solution> </solution>
</problem>""") </problem>""")
it 'converts StringResponse with multiple answers to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""Who lead the civil right movement in the United States of America?
= Dr. Martin Luther King Jr.
or= Doctor Martin Luther King Junior
or= Martin Luther King
or= Martin Luther King Junior
[Explanation]
Test Explanation.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>Who lead the civil right movement in the United States of America?</p>
<stringresponse answer="Dr. Martin Luther King Jr._or_Doctor Martin Luther King Junior_or_Martin Luther King_or_Martin Luther King Junior" type="ci">
<textline size="20"/>
</stringresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>Test Explanation.</p>
</div>
</solution>
</problem>""")
# test oddities # test oddities
it 'converts headers and oddities to xml', -> it 'converts headers and oddities to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""Not a header data = MarkdownEditingDescriptor.markdownToXml("""Not a header
......
...@@ -271,7 +271,17 @@ ...@@ -271,7 +271,17 @@
}); });
// Disabled 10/29/13 due to flakiness in master // Disabled 10/29/13 due to flakiness in master
xdescribe('multiple YT on page', function () { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
describe('multiple YT on page', function () {
var state1, state2, state3; var state1, state2, state3;
beforeEach(function () { beforeEach(function () {
......
...@@ -457,7 +457,17 @@ ...@@ -457,7 +457,17 @@
}); });
// Disabled 10/25/13 due to flakiness in master // Disabled 10/25/13 due to flakiness in master
xit('scroll caption to new position', function () { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('scroll caption to new position', function () {
expect($.fn.scrollTo).toHaveBeenCalled(); expect($.fn.scrollTo).toHaveBeenCalled();
}); });
}); });
...@@ -538,7 +548,17 @@ ...@@ -538,7 +548,17 @@
}); });
// Disabled 10/23/13 due to flakiness in master // Disabled 10/23/13 due to flakiness in master
xdescribe('scrollCaption', function () { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
describe('scrollCaption', function () {
beforeEach(function () { beforeEach(function () {
initialize(); initialize();
}); });
...@@ -683,7 +703,17 @@ ...@@ -683,7 +703,17 @@
}); });
// Test turned off due to flakiness (30.10.2013). // Test turned off due to flakiness (30.10.2013).
xit('scroll the caption', function () { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('scroll the caption', function () {
// After transcripts are shown, and the video plays for a // After transcripts are shown, and the video plays for a
// bit. // bit.
jasmine.Clock.tick(1000); jasmine.Clock.tick(1000);
......
...@@ -72,7 +72,17 @@ ...@@ -72,7 +72,17 @@
expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled(); expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled();
}); });
it('after controls hide focus grabbers are enabled', function () { // Disabled on 18.11.2013 due to flakiness on local dev machine.
//
// Video FocusGrabber: after controls hide focus grabbers are
// enabled [fail]
// Expected spy enableFocusGrabber to have been called.
//
// Approximately 1 in 8 times this test fails.
//
// TODO: Most likely, focusGrabber will be disabled in the future. This
// test could become unneeded in the future.
xit('after controls hide focus grabbers are enabled', function () {
runs(function () { runs(function () {
// Captions should not be "sticky" for the autohide mechanism // Captions should not be "sticky" for the autohide mechanism
// to work. // to work.
......
...@@ -4,11 +4,21 @@ ...@@ -4,11 +4,21 @@
videoProgressSlider, videoSpeedControl, videoVolumeControl, videoProgressSlider, videoSpeedControl, videoVolumeControl,
oldOTBD; oldOTBD;
function initialize(fixture) { function initialize(fixture, params) {
if (typeof fixture === 'undefined') { if (_.isString(fixture)) {
loadFixtures('video_all.html');
} else {
loadFixtures(fixture); loadFixtures(fixture);
} else {
if (_.isObject(fixture)) {
params = fixture;
}
loadFixtures('video_all.html');
}
if (_.isObject(params)) {
$('#example')
.find('#video_id')
.data(params);
} }
state = new Video('#example'); state = new Video('#example');
...@@ -532,8 +542,54 @@ ...@@ -532,8 +542,54 @@
}); });
}); });
// Disabled 10/24/13 due to flakiness in master describe('update with start & end time', function () {
xdescribe('updatePlayTime', function () { var START_TIME = 1, END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'update').andCallThrough();
spyOn(videoPlayer, 'pause').andCallThrough();
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('video is paused on first endTime, start & end time are reset', function () {
var checkForStartEndTimeSet = true;
videoProgressSlider.notifyThroughHandleEnd.reset();
videoPlayer.pause.reset();
videoPlayer.play();
waitsFor(function () {
if (
!isFinite(videoPlayer.currentTime) ||
videoPlayer.currentTime <= 0
) {
return false;
}
if (checkForStartEndTimeSet) {
checkForStartEndTimeSet = false;
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
}
return videoPlayer.pause.calls.length === 1
}, 5000, 'pause() has been called');
runs(function () {
expect(videoPlayer.startTime).toBe(0);
expect(videoPlayer.endTime).toBe(null);
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: true});
});
});
});
describe('updatePlayTime', function () {
beforeEach(function () { beforeEach(function () {
initialize(); initialize();
...@@ -612,6 +668,74 @@ ...@@ -612,6 +668,74 @@
}); });
}); });
describe('updatePlayTime when start & end times are defined', function () {
var START_TIME = 1,
END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
spyOn(videoPlayer.player, 'seekTo').andCallThrough();
spyOn(videoProgressSlider, 'updateStartEndTimeRegion')
.andCallThrough();
});
it('when duration becomes available, updatePlayTime() is called', function () {
var duration;
expect(videoPlayer.initialSeekToStartTime).toBeTruthy();
expect(videoPlayer.seekToStartTimeOldSpeed).toBe('void');
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'duration becomes available', 1000);
runs(function () {
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
expect(videoPlayer.player.seekTo).toHaveBeenCalledWith(START_TIME);
expect(videoProgressSlider.updateStartEndTimeRegion)
.toHaveBeenCalledWith({duration: duration});
expect(videoPlayer.seekToStartTimeOldSpeed).toBe(state.speed);
});
});
});
describe('updatePlayTime with invalid endTime', function () {
beforeEach(function () {
initialize({end: 100000});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
});
it('invalid endTime is reset to null', function () {
var duration;
videoPlayer.updatePlayTime.reset();
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'updatePlayTime was invoked and duration is set', 5000);
runs(function () {
expect(videoPlayer.endTime).toBe(null);
});
});
});
describe('toggleFullScreen', function () { describe('toggleFullScreen', function () {
describe('when the video player is not full screen', function () { describe('when the video player is not full screen', function () {
beforeEach(function () { beforeEach(function () {
......
...@@ -154,7 +154,17 @@ ...@@ -154,7 +154,17 @@
}); });
// Turned off test due to flakiness (30.10.2013). // Turned off test due to flakiness (30.10.2013).
xit('trigger seek event', function() { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('trigger seek event', function() {
runs(function () { runs(function () {
videoProgressSlider.onSlide( videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 20 } jQuery.Event('slide'), { value: 20 }
...@@ -220,7 +230,17 @@ ...@@ -220,7 +230,17 @@
}); });
// Turned off test due to flakiness (30.10.2013). // Turned off test due to flakiness (30.10.2013).
xit('trigger seek event', function() { //
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('trigger seek event', function() {
runs(function () { runs(function () {
videoProgressSlider.onStop( videoProgressSlider.onStop(
jQuery.Event('stop'), { value: 20 } jQuery.Event('stop'), { value: 20 }
...@@ -285,6 +305,55 @@ ...@@ -285,6 +305,55 @@
expect(params).toEqual(expectedParams); expect(params).toEqual(expectedParams);
}); });
}); });
describe('notifyThroughHandleEnd', function () {
beforeEach(function () {
initialize();
spyOnEvent(videoProgressSlider.handle, 'focus');
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('params.end = true', function () {
videoProgressSlider.notifyThroughHandleEnd({end: true});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video ended');
expect('focus').toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('params.end = false', function () {
videoProgressSlider.notifyThroughHandleEnd({end: false});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video position');
expect('focus').not.toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('is called when video plays', function () {
videoPlayer.play();
waitsFor(function () {
var duration = videoPlayer.duration(),
currentTime = videoPlayer.currentTime;
return (
isFinite(duration) &&
duration > 0 &&
isFinite(currentTime) &&
currentTime > 0
);
}, 'duration is set, video is playing', 5000);
runs(function () {
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: false});
});
});
});
}); });
}).call(this); }).call(this);
...@@ -9,9 +9,12 @@ class @Collapsible ...@@ -9,9 +9,12 @@ class @Collapsible
### ###
el: container el: container
### ###
linkTop = '<a href="#" class="full full-top">See full output</a>'
linkBottom = '<a href="#" class="full full-bottom">See full output</a>'
# standard longform + shortfom pattern # standard longform + shortfom pattern
el.find('.longform').hide() el.find('.longform').hide()
el.find('.shortform').append('<a href="#" class="full">See full output</a>') el.find('.shortform').append(linkTop, linkBottom)
# custom longform + shortform text pattern # custom longform + shortform text pattern
short_custom = el.find('.shortform-custom') short_custom = el.find('.shortform-custom')
...@@ -31,13 +34,18 @@ class @Collapsible ...@@ -31,13 +34,18 @@ class @Collapsible
@toggleFull: (event, open_text, close_text) => @toggleFull: (event, open_text, close_text) =>
event.preventDefault() event.preventDefault()
$(event.target).parent().siblings().slideToggle() parent = $(event.target).parent()
$(event.target).parent().parent().toggleClass('open') parent.siblings().slideToggle()
parent.parent().toggleClass('open')
if $(event.target).text() == open_text if $(event.target).text() == open_text
new_text = close_text new_text = close_text
else else
new_text = open_text new_text = open_text
$(event.target).text(new_text) if $(event.target).hasClass('full')
el = parent.find('.full')
else
el = $(event.target)
el.text(new_text)
@toggleHint: (event) => @toggleHint: (event) =>
event.preventDefault() event.preventDefault()
......
...@@ -228,11 +228,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -228,11 +228,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
}); });
// replace string and numerical // replace string and numerical
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) { xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) {
var string; var string,
var floatValue = parseFloat(p); answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'),
floatValue = parseFloat(answersList[0]);
if(!isNaN(floatValue)) { if(!isNaN(floatValue)) {
var params = /(.*?)\+\-\s*(.*?$)/.exec(p); var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]);
if(params) { if(params) {
string = '<numericalresponse answer="' + floatValue + '">\n'; string = '<numericalresponse answer="' + floatValue + '">\n';
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n'; string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
...@@ -242,7 +244,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor ...@@ -242,7 +244,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
string += ' <formulaequationinput />\n'; string += ' <formulaequationinput />\n';
string += '</numericalresponse>\n\n'; string += '</numericalresponse>\n\n';
} else { } else {
string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n'; var answers = [];
for(var i = 0; i < answersList.length; i++) {
answers.push(answersList[i])
}
string = '<stringresponse answer="' + answers.join('_or_') + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';
} }
return string; return string;
}); });
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// VideoPlayer module. // VideoPlayer module.
define( define(
'video/03_video_player.js', 'video/03_video_player.js',
['video/02_html5_video.js', 'video/00_resizer.js' ], ['video/02_html5_video.js', 'video/00_resizer.js'],
function (HTML5Video, Resizer) { function (HTML5Video, Resizer) {
var dfd = $.Deferred(); var dfd = $.Deferred();
...@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) { ...@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) {
state.videoPlayer.initialSeekToStartTime = true; state.videoPlayer.initialSeekToStartTime = true;
state.videoPlayer.oneTimePauseAtEndTime = true; // At the start, the initial value of the variable
// `seekToStartTimeOldSpeed` should always differ from the value
// The initial value of the variable `seekToStartTimeOldSpeed` // returned by the duration function.
// should always differ from the value returned by the duration
// function.
state.videoPlayer.seekToStartTimeOldSpeed = 'void'; state.videoPlayer.seekToStartTimeOldSpeed = 'void';
state.videoPlayer.playerVars = { state.videoPlayer.playerVars = {
...@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) { ...@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) {
// This function gets the video's current play position in time // This function gets the video's current play position in time
// (currentTime) and its duration. // (currentTime) and its duration.
// It is called at a regular interval when the video is playing (see // It is called at a regular interval when the video is playing.
// below).
function update() { function update() {
this.videoPlayer.currentTime = this.videoPlayer.player this.videoPlayer.currentTime = this.videoPlayer.player
.getCurrentTime(); .getCurrentTime();
...@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) { ...@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) {
if (isFinite(this.videoPlayer.currentTime)) { if (isFinite(this.videoPlayer.currentTime)) {
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime); this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
// We need to pause the video is current time is smaller (or equal) // We need to pause the video if current time is smaller (or equal)
// than end time. Also, we must make sure that the end time is the // than end time. Also, we must make sure that this is only done
// one that was set in the configuration parameter. If it differs, // once.
// this means that it was either reset to the end, or the duration
// changed it's value.
// //
// In the case of YouTube Flash mode, we must remember that the // If `endTime` is not `null`, then we are safe to pause the
// start and end times are rescaled based on the current speed of // video. `endTime` will be set to `null`, and this if statement
// the video. // will not be executed on next runs.
if ( if (
this.videoPlayer.endTime <= this.videoPlayer.currentTime && this.videoPlayer.endTime != null &&
this.videoPlayer.oneTimePauseAtEndTime this.videoPlayer.endTime <= this.videoPlayer.currentTime
) { ) {
this.videoPlayer.oneTimePauseAtEndTime = false;
this.videoPlayer.pause(); this.videoPlayer.pause();
this.videoPlayer.endTime = this.videoPlayer.duration();
// After the first time the video reached the `endTime`,
// `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
} }
} }
} }
...@@ -321,8 +324,10 @@ function (HTML5Video, Resizer) { ...@@ -321,8 +324,10 @@ function (HTML5Video, Resizer) {
} }
); );
// After the user seeks, startTime and endTime are disabled. The video
// will play from start to the end on subsequent runs.
this.videoPlayer.startTime = 0; this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = duration; this.videoPlayer.endTime = null;
this.videoPlayer.player.seekTo(newTime, true); this.videoPlayer.player.seekTo(newTime, true);
...@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) { ...@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) {
var time = this.videoPlayer.duration(); var time = this.videoPlayer.duration();
this.trigger('videoControl.pause', null); this.trigger('videoControl.pause', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
if (this.config.show_captions) { if (this.config.show_captions) {
this.trigger('videoCaption.pause', null); this.trigger('videoCaption.pause', null);
} }
// When only `startTime` is set, the video will play to the end
// starting at `startTime`. After the first time the video reaches the
// end, `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
// Sometimes `onEnded` events fires when `currentTime` not equal // Sometimes `onEnded` events fires when `currentTime` not equal
// `duration`. In this case, slider doesn't reach the end point of // `duration`. In this case, slider doesn't reach the end point of
// timeline. // timeline.
...@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) { ...@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) {
this.trigger('videoControl.play', null); this.trigger('videoControl.play', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: false
});
if (this.config.show_captions) { if (this.config.show_captions) {
this.trigger('videoCaption.play', null); this.trigger('videoCaption.play', null);
} }
...@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) { ...@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) {
function updatePlayTime(time) { function updatePlayTime(time) {
var duration = this.videoPlayer.duration(), var duration = this.videoPlayer.duration(),
durationChange; durationChange, tempStartTime, tempEndTime;
if ( if (
duration > 0 && duration > 0 &&
...@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) { ...@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) {
this.videoPlayer.initialSeekToStartTime === false this.videoPlayer.initialSeekToStartTime === false
) { ) {
durationChange = true; durationChange = true;
} else { } else { // this.videoPlayer.initialSeekToStartTime === true
this.videoPlayer.initialSeekToStartTime = false;
durationChange = false; durationChange = false;
} }
this.videoPlayer.initialSeekToStartTime = false;
this.videoPlayer.seekToStartTimeOldSpeed = this.speed; this.videoPlayer.seekToStartTimeOldSpeed = this.speed;
// Current startTime and endTime could have already been reset.
// We will remember their current values, and reset them at the
// end. We need to perform the below calculations on start and end
// times so that the range on the slider gets correctly updated in
// the case of speed change in Flash player mode (for YouTube
// videos).
tempStartTime = this.videoPlayer.startTime;
tempEndTime = this.videoPlayer.endTime;
// We retrieve the original times. They could have been changed due // We retrieve the original times. They could have been changed due
// to the fact of speed change (duration change). This happens when // to the fact of speed change (duration change). This happens when
// in YouTube Flash mode. There each speed is a different video, // in YouTube Flash mode. There each speed is a different video,
...@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) { ...@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) {
this.videoPlayer.startTime /= Number(this.speed); this.videoPlayer.startTime /= Number(this.speed);
} }
} }
// An `endTime` of `null` means that either the user didn't set
// and `endTime`, or it was set to a value greater than the
// duration of the video.
//
// If `endTime` is `null`, the video will play to the end. We do
// not set the `endTime` to the duration of the video because
// sometimes in YouTube mode the duration changes slightly during
// the course of playback. This would cause the video to pause just
// before the actual end of the video.
if ( if (
this.videoPlayer.endTime === null || this.videoPlayer.endTime !== null &&
this.videoPlayer.endTime > duration this.videoPlayer.endTime > duration
) { ) {
this.videoPlayer.endTime = duration; this.videoPlayer.endTime = null;
} else { } else if (this.videoPlayer.endTime !== null) {
if (this.currentPlayerMode === 'flash') { if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime /= Number(this.speed); this.videoPlayer.endTime /= Number(this.speed);
} }
} }
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero.
if (durationChange === false && this.videoPlayer.startTime > 0) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Rebuild the slider start-end range (if it doesn't take up the // Rebuild the slider start-end range (if it doesn't take up the
// whole slider). // whole slider). Remember that endTime === null means the end time
// is set to the end of video by default.
if (!( if (!(
this.videoPlayer.startTime === 0 && this.videoPlayer.startTime === 0 &&
this.videoPlayer.endTime === duration this.videoPlayer.endTime === null
)) { )) {
this.trigger( this.trigger(
'videoProgressSlider.updateStartEndTimeRegion', 'videoProgressSlider.updateStartEndTimeRegion',
...@@ -599,6 +630,28 @@ function (HTML5Video, Resizer) { ...@@ -599,6 +630,28 @@ function (HTML5Video, Resizer) {
} }
); );
} }
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero, and we haven't
// performed already such a seek.
if (
durationChange === false &&
this.videoPlayer.startTime > 0 &&
!(tempStartTime === 0 && tempEndTime === null)
) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Reset back the actual startTime and endTime if they have been
// already reset (a seek event happened, the video already ended
// once, or endTime has already been reached once).
if (tempStartTime === 0 && tempEndTime === null) {
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
}
} }
this.trigger( this.trigger(
......
...@@ -41,7 +41,8 @@ function () { ...@@ -41,7 +41,8 @@ function () {
onSlide: onSlide, onSlide: onSlide,
onStop: onStop, onStop: onStop,
updatePlayTime: updatePlayTime, updatePlayTime: updatePlayTime,
updateStartEndTimeRegion: updateStartEndTimeRegion updateStartEndTimeRegion: updateStartEndTimeRegion,
notifyThroughHandleEnd: notifyThroughHandleEnd
}; };
state.bindTo(methodsDict, state.videoProgressSlider, state); state.bindTo(methodsDict, state.videoProgressSlider, state);
...@@ -111,11 +112,6 @@ function () { ...@@ -111,11 +112,6 @@ function () {
duration = params.duration; duration = params.duration;
} }
// If the range spans the entire length of video, we don't do anything.
if (!this.videoPlayer.startTime && !this.videoPlayer.endTime) {
return;
}
start = this.videoPlayer.startTime; start = this.videoPlayer.startTime;
// If end is set to null, then we set it to the end of the video. We // If end is set to null, then we set it to the end of the video. We
...@@ -199,8 +195,6 @@ function () { ...@@ -199,8 +195,6 @@ function () {
}, 200); }, 200);
} }
// Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera
// noticed
function updatePlayTime(params) { function updatePlayTime(params) {
var time = Math.floor(params.time), var time = Math.floor(params.time),
duration = Math.floor(params.duration); duration = Math.floor(params.duration);
...@@ -215,6 +209,33 @@ function () { ...@@ -215,6 +209,33 @@ function () {
} }
} }
// When the video stops playing (either because the end was reached, or
// because endTime was reached), the screen reader must be notified that
// the video is no longer playing. We do this by a little trick. Setting
// the title attribute of the slider know to "video ended", and focusing
// on it. The screen reader will read the attr text.
//
// The user can then tab his way forward, landing on the next control
// element, the Play button.
//
// @param params - object with property `end`. If set to true, the
// function must set the title attribute to
// `video ended`;
// if set to false, the function must reset the attr to
// it's original state.
//
// This function will be triggered from VideoPlayer methods onEnded(),
// onPlay(), and update() (update method handles endTime).
function notifyThroughHandleEnd(params) {
if (params.end) {
this.videoProgressSlider.handle
.attr('title', 'video ended')
.focus();
} else {
this.videoProgressSlider.handle.attr('title', 'video position');
}
}
// Returns a string describing the current time of video in hh:mm:ss // Returns a string describing the current time of video in hh:mm:ss
// format. // format.
function getTimeDescription(time) { function getTimeDescription(time) {
......
...@@ -204,21 +204,16 @@ class LocMapperStore(object): ...@@ -204,21 +204,16 @@ class LocMapperStore(object):
self._decode_from_mongo(old_name), self._decode_from_mongo(old_name),
None) None)
elif usage_id == locator.usage_id: elif usage_id == locator.usage_id:
# figure out revision # Always return revision=None because the
# enforce the draft only if category in [..] logic # old draft module store wraps locations as draft before
if category in draft.DIRECT_ONLY_CATEGORIES: # trying to access things.
revision = None
elif locator.branch == candidate['draft_branch']:
revision = draft.DRAFT
else:
revision = None
return Location( return Location(
'i4x', 'i4x',
candidate['_id']['org'], candidate['_id']['org'],
candidate['_id']['course'], candidate['_id']['course'],
category, category,
self._decode_from_mongo(old_name), self._decode_from_mongo(old_name),
revision) None)
return None return None
def add_block_location_translator(self, location, old_course_id=None, usage_id=None): def add_block_location_translator(self, location, old_course_id=None, usage_id=None):
......
...@@ -778,11 +778,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -778,11 +778,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
children: A list of child item identifiers children: A list of child item identifiers
""" """
# We expect the children IDs to always be the non-draft version. With view refactoring self._update_single_item(location, {'definition.children': children})
# for split, we are now passing the draft version in some cases.
children_ids = [Location(child).replace(revision=None).url() for child in children]
self._update_single_item(location, {'definition.children': children_ids})
# recompute (and update) the metadata inheritance tree which is cached # recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location)) self.refresh_cached_metadata_inheritance_tree(Location(location))
# fire signal that we've written to DB # fire signal that we've written to DB
......
...@@ -81,7 +81,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -81,7 +81,7 @@ class DraftModuleStore(MongoModuleStore):
try: try:
return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth)) return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth))
except ItemNotFoundError: except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_item(as_published(location), depth=depth)) return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth))
def get_instance(self, course_id, location, depth=0): def get_instance(self, course_id, location, depth=0):
""" """
...@@ -169,7 +169,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -169,7 +169,7 @@ class DraftModuleStore(MongoModuleStore):
try: try:
draft_item = self.get_item(location) draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False): if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location)) self.convert_to_draft(location)
except ItemNotFoundError, e: except ItemNotFoundError, e:
if not allow_not_found: if not allow_not_found:
raise e raise e
...@@ -187,7 +187,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -187,7 +187,7 @@ class DraftModuleStore(MongoModuleStore):
draft_loc = as_draft(location) draft_loc = as_draft(location)
draft_item = self.get_item(location) draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False): if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location)) self.convert_to_draft(location)
return super(DraftModuleStore, self).update_children(draft_loc, children) return super(DraftModuleStore, self).update_children(draft_loc, children)
...@@ -203,7 +203,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -203,7 +203,7 @@ class DraftModuleStore(MongoModuleStore):
draft_item = self.get_item(location) draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False): if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location)) self.convert_to_draft(location)
if 'is_draft' in metadata: if 'is_draft' in metadata:
del metadata['is_draft'] del metadata['is_draft']
...@@ -262,7 +262,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -262,7 +262,7 @@ class DraftModuleStore(MongoModuleStore):
""" """
Turn the published version into a draft, removing the published version Turn the published version into a draft, removing the published version
""" """
self.convert_to_draft(as_published(location)) self.convert_to_draft(location)
super(DraftModuleStore, self).delete_item(location) super(DraftModuleStore, self).delete_item(location)
def _query_children_for_cache_children(self, items): def _query_children_for_cache_children(self, items):
......
...@@ -88,7 +88,7 @@ class SplitMigrator(object): ...@@ -88,7 +88,7 @@ class SplitMigrator(object):
index_info = self.split_modulestore.get_course_index_info(course_version_locator) index_info = self.split_modulestore.get_course_index_info(course_version_locator)
versions = index_info['versions'] versions = index_info['versions']
versions['draft'] = versions['published'] versions['draft'] = versions['published']
self.split_modulestore.update_course_index(course_version_locator, {'versions': versions}, update_versions=True) self.split_modulestore.update_course_index(index_info)
# clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft # clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft
# children which meant some pointers were to non-existent locations in 'direct' # children which meant some pointers were to non-existent locations in 'direct'
......
...@@ -22,5 +22,4 @@ class DefinitionLazyLoader(object): ...@@ -22,5 +22,4 @@ class DefinitionLazyLoader(object):
Fetch the definition. Note, the caller should replace this lazy Fetch the definition. Note, the caller should replace this lazy
loader pointer with the result so as not to fetch more than once loader pointer with the result so as not to fetch more than once
""" """
return self.modulestore.definitions.find_one( return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id)
{'_id': self.definition_locator.definition_id})
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
import pymongo
class MongoConnection(object):
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
def __init__(
self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
):
"""
Create & open the connection, authenticate, and provide pointers to the collections
"""
self.database = pymongo.database.Database(
pymongo.MongoClient(
host=host,
port=port,
tz_aware=tz_aware,
**kwargs
),
db
)
if user is not None and password is not None:
self.database.authenticate(user, password)
self.course_index = self.database[collection + '.active_versions']
self.structures = self.database[collection + '.structures']
self.definitions = self.database[collection + '.definitions']
# every app has write access to the db (v having a flag to indicate r/o v write)
# Force mongo to report errors, at the expense of performance
# pymongo docs suck but explanation:
# http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html
self.course_index.write_concern = {'w': 1}
self.structures.write_concern = {'w': 1}
self.definitions.write_concern = {'w': 1}
def get_structure(self, key):
"""
Get the structure from the persistence mechanism whose id is the given key
"""
return self.structures.find_one({'_id': key})
def find_matching_structures(self, query):
"""
Find the structure matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.structures.find(query)
def insert_structure(self, structure):
"""
Create the structure in the db
"""
self.structures.insert(structure)
def update_structure(self, structure):
"""
Update the db record for structure
"""
self.structures.update({'_id': structure['_id']}, structure)
def get_course_index(self, key):
"""
Get the course_index from the persistence mechanism whose id is the given key
"""
return self.course_index.find_one({'_id': key})
def find_matching_course_indexes(self, query):
"""
Find the course_index matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.course_index.find(query)
def insert_course_index(self, course_index):
"""
Create the course_index in the db
"""
self.course_index.insert(course_index)
def update_course_index(self, course_index):
"""
Update the db record for course_index
"""
self.course_index.update({'_id': course_index['_id']}, course_index)
def delete_course_index(self, key):
"""
Delete the course_index from the persistence mechanism whose id is the given key
"""
return self.course_index.remove({'_id': key})
def get_definition(self, key):
"""
Get the definition from the persistence mechanism whose id is the given key
"""
return self.definitions.find_one({'_id': key})
def find_matching_definitions(self, query):
"""
Find the definitions matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.definitions.find(query)
def insert_definition(self, definition):
"""
Create the definition in the db
"""
self.definitions.insert(definition)
...@@ -274,7 +274,9 @@ class TestLocationMapper(unittest.TestCase): ...@@ -274,7 +274,9 @@ class TestLocationMapper(unittest.TestCase):
course_id=prob_locator.course_id, branch='draft', usage_id=prob_locator.usage_id course_id=prob_locator.course_id, branch='draft', usage_id=prob_locator.usage_id
) )
prob_location = loc_mapper().translate_locator_to_location(prob_locator) prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', 'draft')) # Even though the problem was set as draft, we always return revision=None to work
# with old mongo/draft modulestores.
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
prob_locator = BlockUsageLocator( prob_locator = BlockUsageLocator(
course_id=new_style_course_id, usage_id='problem2', branch='production' course_id=new_style_course_id, usage_id='problem2', branch='production'
) )
......
...@@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase): ...@@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase):
dbref = self.loc_mapper.db dbref = self.loc_mapper.db
dbref.drop_collection(self.loc_mapper.location_map) dbref.drop_collection(self.loc_mapper.location_map)
split_db = self.split_mongo.db split_db = self.split_mongo.db
split_db.drop_collection(split_db.course_index) split_db.drop_collection(self.split_mongo.db_connection.course_index)
split_db.drop_collection(split_db.structures) split_db.drop_collection(self.split_mongo.db_connection.structures)
split_db.drop_collection(split_db.definitions) split_db.drop_collection(self.split_mongo.db_connection.definitions)
# old_mongo doesn't give a db attr, but all of the dbs are the same # old_mongo doesn't give a db attr, but all of the dbs are the same
dbref.drop_collection(self.old_mongo.collection) dbref.drop_collection(self.old_mongo.collection)
......
...@@ -1018,41 +1018,29 @@ class TestCourseCreation(SplitModuleTest): ...@@ -1018,41 +1018,29 @@ class TestCourseCreation(SplitModuleTest):
Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc. Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc.
""" """
locator = CourseLocator(course_id="GreekHero", branch='draft') locator = CourseLocator(course_id="GreekHero", branch='draft')
modulestore().update_course_index(locator, {'org': 'funkyU'}) course_info = modulestore().get_course_index_info(locator)
course_info['org'] = 'funkyU'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator) course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'funkyU') self.assertEqual(course_info['org'], 'funkyU')
modulestore().update_course_index(locator, {'org': 'moreFunky', 'prettyid': 'Ancient Greek Demagods'}) course_info['org'] = 'moreFunky'
course_info['prettyid'] = 'Ancient Greek Demagods'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator) course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'moreFunky') self.assertEqual(course_info['org'], 'moreFunky')
self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods') self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods')
self.assertRaises(ValueError, modulestore().update_course_index, locator, {'_id': 'funkygreeks'})
with self.assertRaises(ValueError):
modulestore().update_course_index(
locator,
{'edited_on': datetime.datetime.now(UTC)}
)
with self.assertRaises(ValueError):
modulestore().update_course_index(
locator,
{'edited_by': 'sneak'}
)
self.assertRaises(ValueError, modulestore().update_course_index, locator,
{'versions': {'draft': self.GUID_D1}})
# an allowed but not necessarily recommended way to revert the draft version # an allowed but not necessarily recommended way to revert the draft version
versions = course_info['versions'] versions = course_info['versions']
versions['draft'] = self.GUID_D1 versions['draft'] = self.GUID_D1
modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) modulestore().update_course_index(course_info)
course = modulestore().get_course(locator) course = modulestore().get_course(locator)
self.assertEqual(str(course.location.version_guid), self.GUID_D1) self.assertEqual(str(course.location.version_guid), self.GUID_D1)
# an allowed but not recommended way to publish a course # an allowed but not recommended way to publish a course
versions['published'] = self.GUID_D1 versions['published'] = self.GUID_D1
modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) modulestore().update_course_index(course_info)
course = modulestore().get_course(CourseLocator(course_id=locator.course_id, branch="published")) course = modulestore().get_course(CourseLocator(course_id=locator.course_id, branch="published"))
self.assertEqual(str(course.location.version_guid), self.GUID_D1) self.assertEqual(str(course.location.version_guid), self.GUID_D1)
...@@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest): ...@@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest):
self.assertEqual(new_course.location.usage_id, 'top') self.assertEqual(new_course.location.usage_id, 'top')
self.assertEqual(new_course.category, 'chapter') self.assertEqual(new_course.category, 'chapter')
# look at db to verify # look at db to verify
db_structure = modulestore().structures.find_one({ db_structure = modulestore().db_connection.get_structure(
'_id': new_course.location.as_object_id(new_course.location.version_guid) new_course.location.as_object_id(new_course.location.version_guid)
}) )
self.assertIsNotNone(db_structure, "Didn't find course") self.assertIsNotNone(db_structure, "Didn't find course")
self.assertNotIn('course', db_structure['blocks']) self.assertNotIn('course', db_structure['blocks'])
self.assertIn('top', db_structure['blocks']) self.assertIn('top', db_structure['blocks'])
......
...@@ -97,7 +97,6 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d ...@@ -97,7 +97,6 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
if len(draft_verticals) > 0: if len(draft_verticals) > 0:
draft_course_dir = export_fs.makeopendir('drafts') draft_course_dir = export_fs.makeopendir('drafts')
for draft_vertical in draft_verticals: for draft_vertical in draft_verticals:
if getattr(draft_vertical, 'is_draft', False):
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id) parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
# Don't try to export orphaned items. # Don't try to export orphaned items.
if len(parent_locs) > 0: if len(parent_locs) > 0:
......
...@@ -25,9 +25,8 @@ if Backbone? ...@@ -25,9 +25,8 @@ if Backbone?
@add model @add model
model model
retrieveAnotherPage: (mode, options={}, sort_options={})-> retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
@current_page += 1 data = { page: @current_page + 1 }
data = { page: @current_page }
switch mode switch mode
when 'search' when 'search'
url = DiscussionUtil.urlFor 'search' url = DiscussionUtil.urlFor 'search'
...@@ -59,6 +58,7 @@ if Backbone? ...@@ -59,6 +58,7 @@ if Backbone?
@reset new_collection @reset new_collection
@pages = response.num_pages @pages = response.num_pages
@current_page = response.page @current_page = response.page
error: error
sortByDate: (thread) -> sortByDate: (thread) ->
# #
......
...@@ -36,12 +36,15 @@ if Backbone? ...@@ -36,12 +36,15 @@ if Backbone?
event.preventDefault() event.preventDefault()
@newPostForm.slideUp(300) @newPostForm.slideUp(300)
toggleDiscussion: (event) -> hideDiscussion: ->
if @showed
@$("section.discussion").slideUp() @$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown') @toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Show Discussion") @toggleDiscussionBtn.find('.button-text').html("Show Discussion")
@showed = false @showed = false
toggleDiscussion: (event) ->
if @showed
@hideDiscussion()
else else
@toggleDiscussionBtn.addClass('shown') @toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Hide Discussion") @toggleDiscussionBtn.find('.button-text').html("Hide Discussion")
...@@ -51,9 +54,17 @@ if Backbone? ...@@ -51,9 +54,17 @@ if Backbone?
@showed = true @showed = true
else else
$elem = @toggleDiscussionBtn $elem = @toggleDiscussionBtn
@loadPage $elem @loadPage(
$elem,
=>
@hideDiscussion()
DiscussionUtil.discussionAlert(
"Sorry",
"We had some trouble loading the discussion. Please try again."
)
)
loadPage: ($elem)=> loadPage: ($elem, error) =>
discussionId = @$el.data("discussion-id") discussionId = @$el.data("discussion-id")
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}" url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
DiscussionUtil.safeAjax DiscussionUtil.safeAjax
...@@ -63,6 +74,7 @@ if Backbone? ...@@ -63,6 +74,7 @@ if Backbone?
type: "GET" type: "GET"
dataType: 'json' dataType: 'json'
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId) success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
error: error
renderDiscussion: ($elem, response, textStatus, discussionId) => renderDiscussion: ($elem, response, textStatus, discussionId) =>
window.user = new DiscussionUser(response.user_info) window.user = new DiscussionUser(response.user_info)
...@@ -131,5 +143,14 @@ if Backbone? ...@@ -131,5 +143,14 @@ if Backbone?
navigateToPage: (event) => navigateToPage: (event) =>
event.preventDefault() event.preventDefault()
window.history.pushState({}, window.document.title, event.target.href) window.history.pushState({}, window.document.title, event.target.href)
currPage = @page
@page = $(event.target).data('page-number') @page = $(event.target).data('page-number')
@loadPage($(event.target)) @loadPage(
$(event.target),
=>
@page = currPage
DiscussionUtil.discussionAlert(
"Sorry",
"We had some trouble loading the threads you requested. Please try again."
)
)
...@@ -87,6 +87,13 @@ class @DiscussionUtil ...@@ -87,6 +87,13 @@ class @DiscussionUtil
"notifications_status" : "/notification_prefs/status" "notifications_status" : "/notification_prefs/status"
}[name] }[name]
@makeFocusTrap: (elem) ->
elem.keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
@discussionAlert: (header, body) -> @discussionAlert: (header, body) ->
if $("#discussion-alert").length == 0 if $("#discussion-alert").length == 0
alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none") alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none")
...@@ -99,12 +106,7 @@ class @DiscussionUtil ...@@ -99,12 +106,7 @@ class @DiscussionUtil
" <button class='dismiss'>OK</button>" + " <button class='dismiss'>OK</button>" +
"</div>" "</div>"
) )
# Capture focus @makeFocusTrap(alertDiv.find("button"))
alertDiv.find("button").keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none") alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none")
alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200}) alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200})
$("body").append(alertDiv).append(alertTrigger) $("body").append(alertDiv).append(alertTrigger)
......
...@@ -124,8 +124,11 @@ if Backbone? ...@@ -124,8 +124,11 @@ if Backbone?
loadMorePages: (event) -> loadMorePages: (event) ->
if event if event
event.preventDefault() event.preventDefault()
@$(".more-pages").html('<div class="loading-animation"><span class="sr">Loading more threads</span></div>') @$(".more-pages").html('<div class="loading-animation" tabindex=0><span class="sr" role="alert">Loading more threads</span></div>')
@$(".more-pages").addClass("loading") @$(".more-pages").addClass("loading")
loadingDiv = @$(".more-pages .loading-animation")
DiscussionUtil.makeFocusTrap(loadingDiv)
loadingDiv.focus()
options = {} options = {}
switch @mode switch @mode
when 'search' when 'search'
...@@ -156,7 +159,11 @@ if Backbone? ...@@ -156,7 +159,11 @@ if Backbone?
$(".post-list a").first()?.focus() $(".post-list a").first()?.focus()
) )
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) error = =>
@renderThreads()
DiscussionUtil.discussionAlert("Sorry", "We had some trouble loading more threads. Please try again.")
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}, error)
renderThread: (thread) => renderThread: (thread) =>
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON())) content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))
......
(function () { (function () {
var update = function () { var update = function () {
// Whenever a value changes create a new serialized version of this // Whenever a value changes create a new serialized version of this
// problem's inputs and set the hidden input fields value to equal it. // problem's inputs and set the hidden input field's value to equal it.
var parent = $(this).closest('.problems-wrapper'); var parent = $(this).closest('section.choicetextinput');
// find the closest parent problems-wrapper and use that as the problem // find the closest parent problems-wrapper and use that as the problem
// grab the input id from the input // grab the input id from the input
// real_input is the hidden input field // real_input is the hidden input field
var real_input = $('input.choicetextvalue', parent); var real_input = $('input.choicetextvalue', parent);
var all_inputs = $('.choicetextinput .ctinput', parent); var all_inputs = $('input.ctinput', parent);
var user_inputs = {}; var user_inputs = {};
$(all_inputs).each(function (index, elt) { $(all_inputs).each(function (index, elt) {
var node = $(elt); var node = $(elt);
......
...@@ -5,7 +5,7 @@ import datetime ...@@ -5,7 +5,7 @@ import datetime
from pytz import UTC from pytz import UTC
from django.conf import settings from django.conf import settings
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.django import editable_modulestore from xmodule.modulestore.django import editable_modulestore
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -25,6 +25,7 @@ class AnonymousIndexPageTest(ModuleStoreTestCase): ...@@ -25,6 +25,7 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
""" """
def setUp(self): def setUp(self):
self.store = editable_modulestore() self.store = editable_modulestore()
self.factory = RequestFactory()
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.course.days_early_for_beta = 5 self.course.days_early_for_beta = 5
self.course.enrollment_start = datetime.datetime.now(UTC) + datetime.timedelta(days=3) self.course.enrollment_start = datetime.datetime.now(UTC) + datetime.timedelta(days=3)
...@@ -32,7 +33,11 @@ class AnonymousIndexPageTest(ModuleStoreTestCase): ...@@ -32,7 +33,11 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE) @override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE)
def test_none_user_index_access_with_startdate_fails(self): def test_none_user_index_access_with_startdate_fails(self):
with self.assertRaises(Exception): """
This was a "before" test for a bugfix. If someone fixes the bug another way in the future
and this test begins failing (but the other two pass), then feel free to delete this test.
"""
with self.assertRaisesRegexp(AttributeError, "'NoneType' object has no attribute 'is_authenticated'"):
student.views.index(self.factory.get('/'), user=None) # pylint: disable=E1101 student.views.index(self.factory.get('/'), user=None) # pylint: disable=E1101
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE) @override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE)
......
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