Merged master to rc/2013-11-21
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,6 +17,7 @@ cms/envs/private.py
|
||||
/nbproject
|
||||
.idea/
|
||||
.redcar/
|
||||
codekit-config.json
|
||||
|
||||
### OS X artifacts
|
||||
*.DS_Store
|
||||
@@ -48,14 +49,18 @@ reports/
|
||||
.prereqs_cache
|
||||
.vagrant/
|
||||
node_modules
|
||||
.bundle/
|
||||
bin/
|
||||
|
||||
### Static assets pipeline artifacts
|
||||
*.scssc
|
||||
lms/static/css/
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/application.scss
|
||||
lms/static/sass/application-extend1.scss
|
||||
lms/static/sass/application-extend2.scss
|
||||
lms/static/sass/course.scss
|
||||
cms/static/css/
|
||||
cms/static/sass/*.css
|
||||
|
||||
### Logging artifacts
|
||||
|
||||
1
AUTHORS
1
AUTHORS
@@ -97,3 +97,4 @@ Iain Dunning <idunning@mit.edu>
|
||||
Olivier Marquez <oliviermarquez@gmail.com>
|
||||
Florian Dufour <neurolit@gmail.com>
|
||||
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
|
||||
instructor task, with reports uploaded to S3. Feature is visible on the beta
|
||||
instructor dashboard. LMS-58
|
||||
|
||||
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid
|
||||
across all runs with the same course name. Old group membership will still work
|
||||
across runs, but new beta-testers will only be added to a single course run.
|
||||
LMS: Beta-tester status is now set on a per-course-run basis, rather than being
|
||||
valid across all runs with the same course name. Old group membership will
|
||||
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.
|
||||
|
||||
@@ -32,7 +60,8 @@ text like with bold or italics. (BLD-449)
|
||||
LMS: Beta instructor dashboard will only count actively enrolled students for
|
||||
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
|
||||
their forum posts.
|
||||
|
||||
@@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611
|
||||
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
|
||||
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
|
||||
world.wait_for_invisible(component_button_css)
|
||||
click_component_from_menu(category, component_type, is_advanced)
|
||||
|
||||
world.wait_for(lambda _: _is_expected_element_count(module_css,
|
||||
module_count_before + 1))
|
||||
expected_count = module_count_before + 1
|
||||
world.wait_for(
|
||||
lambda _: len(world.css_find(module_css)) == expected_count,
|
||||
timeout=20
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -76,3 +76,17 @@ Feature: CMS.Course updates
|
||||
Then I see the handout "/c4x/MITx/999/asset/modified.jpg"
|
||||
And when I reload the page
|
||||
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):
|
||||
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):
|
||||
type_in_codemirror(0, text)
|
||||
save_css = 'a.save-button'
|
||||
|
||||
@@ -9,10 +9,8 @@ Feature: CMS.Static Pages
|
||||
Then I should see a static page named "Empty"
|
||||
|
||||
Scenario: Users can delete static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
And I "delete" the static page
|
||||
Given I have created a static page
|
||||
When I "delete" the static page
|
||||
Then I am shown a prompt
|
||||
When I confirm the prompt
|
||||
Then I should not see any static pages
|
||||
@@ -20,9 +18,16 @@ Feature: CMS.Static Pages
|
||||
# Safari won't update the name properly
|
||||
@skip_safari
|
||||
Scenario: Users can edit static pages
|
||||
Given I have opened a new course in Studio
|
||||
And I go to the static pages page
|
||||
And I add a new page
|
||||
Given I have created a static page
|
||||
When I "edit" the static page
|
||||
And I change the name to "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):
|
||||
world.trigger_event(input_css)
|
||||
save_button = 'a.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
|
||||
|
||||
And I save changes
|
||||
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 open tab "Advanced"
|
||||
|
||||
@@ -116,6 +116,7 @@ def i_see_status_message(_step, status):
|
||||
world.wait(DELAY)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
assert not world.css_visible(SELECTORS['error_bar'])
|
||||
assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()])
|
||||
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def click_on_the_caption(_step, index):
|
||||
@step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$')
|
||||
def caption_line_has_class(_step, index, className):
|
||||
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$')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pylint: disable=E1101
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import mock
|
||||
|
||||
@@ -15,6 +14,7 @@ from fs.osfs import OSFS
|
||||
import copy
|
||||
from json import loads
|
||||
from datetime import timedelta
|
||||
from django.test import TestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.dispatch import Signal
|
||||
@@ -42,6 +42,7 @@ from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from xmodule.exceptions import NotFoundError
|
||||
@@ -53,6 +54,7 @@ from pytz import UTC
|
||||
from uuid import uuid4
|
||||
from pymongo import MongoClient
|
||||
from student.models import CourseEnrollment
|
||||
import re
|
||||
|
||||
from contentstore.utils import delete_course_and_groups
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
@@ -132,9 +134,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
locator = loc_mapper().translate_location(course.location.course_id, descriptor.location, False, True)
|
||||
resp = self.client.get_html(locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# TODO: uncomment when video transcripts no longer require IDs.
|
||||
# _test_no_locations(self, resp)
|
||||
|
||||
for expected in expected_types:
|
||||
self.assertIn(expected, resp.content)
|
||||
@@ -152,25 +156,24 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
def test_malformed_edit_unit_request(self):
|
||||
store = modulestore('direct')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
_, course_items = import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
location = descriptor.location.replace(name='.' + descriptor.location.name)
|
||||
locator = loc_mapper().translate_location(course_items[0].location.course_id, location, False, True)
|
||||
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': location.url()}))
|
||||
resp = self.client.get_html(locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
_test_no_locations(self, resp, status_code=400)
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
_, course_items = import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
|
||||
for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)):
|
||||
print "Checking ", descriptor.location.url()
|
||||
print descriptor.__class__, descriptor.location
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
items = modulestore().get_items(Location('i4x', 'edX', test_course_name, 'vertical', None, None))
|
||||
self._check_verticals(items, course_items[0].location.course_id)
|
||||
|
||||
def lockAnAsset(self, content_store, course_location):
|
||||
def _lock_an_asset(self, content_store, course_location):
|
||||
"""
|
||||
Lock an arbitrary asset in the course
|
||||
:param course_location:
|
||||
@@ -398,15 +401,63 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
def get_tab_locator(tab):
|
||||
tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug'])
|
||||
return unicode(loc_mapper().translate_location(
|
||||
course.location.course_id, Location(tab_location), False, True
|
||||
))
|
||||
module_store, course_location, new_location = self._create_static_tabs()
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# reverse the ordering
|
||||
reverse_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reverse_tabs.insert(0, unicode(self._get_tab_locator(course, tab)))
|
||||
|
||||
self.client.ajax_post(new_location.url_reverse('tabs'), {'tabs': reverse_tabs})
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# compare to make sure that the tabs information is in the expected order after the server call
|
||||
course_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
course_tabs.append(unicode(self._get_tab_locator(course, tab)))
|
||||
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
|
||||
def test_static_tab_deletion(self):
|
||||
module_store, course_location, _ = self._create_static_tabs()
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
num_tabs = len(course.tabs)
|
||||
last_tab = course.tabs[num_tabs - 1]
|
||||
url_slug = last_tab['url_slug']
|
||||
delete_url = self._get_tab_locator(course, last_tab).url_reverse('xblock')
|
||||
|
||||
self.client.delete(delete_url)
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
self.assertEqual(num_tabs - 1, len(course.tabs))
|
||||
|
||||
def tab_matches(tab):
|
||||
""" Checks if the tab matches the one we deleted """
|
||||
return tab['type'] == 'static_tab' and tab['url_slug'] == url_slug
|
||||
|
||||
tab_found = any(tab_matches(tab) for tab in course.tabs)
|
||||
|
||||
self.assertFalse(tab_found, "tab should have been deleted")
|
||||
|
||||
def _get_tab_locator(self, course, tab):
|
||||
""" Returns the locator for a given tab. """
|
||||
tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug'])
|
||||
return loc_mapper().translate_location(
|
||||
course.location.course_id, Location(tab_location), False, True
|
||||
)
|
||||
|
||||
def _create_static_tabs(self):
|
||||
""" Creates two static tabs in a dummy course. """
|
||||
module_store = modulestore('direct')
|
||||
locator = _course_factory_create_course()
|
||||
course_location = loc_mapper().translate_locator_to_location(locator)
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
new_location = loc_mapper().translate_location(course_location.course_id, course_location, False, True)
|
||||
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
@@ -417,25 +468,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
category="static_tab",
|
||||
display_name="Static_2")
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# reverse the ordering
|
||||
reverse_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reverse_tabs.insert(0, get_tab_locator(tab))
|
||||
|
||||
self.client.ajax_post(reverse('reorder_static_tabs'), {'tabs': reverse_tabs})
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
# compare to make sure that the tabs information is in the expected order after the server call
|
||||
course_tabs = []
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
course_tabs.append(get_tab_locator(tab))
|
||||
|
||||
self.assertEqual(reverse_tabs, course_tabs)
|
||||
return module_store, course_location, new_location
|
||||
|
||||
def test_import_polls(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -454,31 +487,38 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
@override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*'])
|
||||
def test_module_preview_in_whitelist(self):
|
||||
'''
|
||||
"""
|
||||
Tests the ajax callback to render an XModule
|
||||
'''
|
||||
direct_store = modulestore('direct')
|
||||
import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
|
||||
# also try a custom response which will trigger the 'is this course in whitelist' logic
|
||||
problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None])
|
||||
url = reverse('preview_component', kwargs={'location': problem_module_location.url()})
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
"""
|
||||
resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]))
|
||||
# These are the data-ids of the xblocks contained in the vertical.
|
||||
# Ultimately, these must be converted to new locators.
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/sample_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time')
|
||||
self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2')
|
||||
|
||||
def test_video_module_caption_asset_path(self):
|
||||
'''
|
||||
"""
|
||||
This verifies that a video caption url is as we expect it to be
|
||||
'''
|
||||
"""
|
||||
resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None]))
|
||||
self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
|
||||
|
||||
def _test_preview(self, location):
|
||||
""" Preview test case. """
|
||||
direct_store = modulestore('direct')
|
||||
import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
_, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
|
||||
# also try a custom response which will trigger the 'is this course in whitelist' logic
|
||||
video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None])
|
||||
url = reverse('preview_component', kwargs={'location': video_module_location.url()})
|
||||
resp = self.client.get_html(url)
|
||||
locator = loc_mapper().translate_location(
|
||||
course_items[0].location.course_id, location, False, True
|
||||
)
|
||||
resp = self.client.get_html(locator.url_reverse('xblock'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"')
|
||||
# TODO: uncomment when preview no longer has locations being returned.
|
||||
# _test_no_locations(self, resp)
|
||||
return resp
|
||||
|
||||
def test_delete(self):
|
||||
direct_store = modulestore('direct')
|
||||
@@ -617,7 +657,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def _delete_asset_in_course (self):
|
||||
def _delete_asset_in_course(self):
|
||||
"""
|
||||
Helper method for:
|
||||
1) importing course from xml
|
||||
@@ -836,6 +876,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_bad_contentstore_request(self):
|
||||
resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
_test_no_locations(self, resp, 400)
|
||||
|
||||
def test_rewrite_nonportable_links_on_import(self):
|
||||
module_store = modulestore('direct')
|
||||
@@ -955,7 +996,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertIn(private_location_no_draft.url(), sequential.children)
|
||||
|
||||
locked_asset = self.lockAnAsset(content_store, location)
|
||||
locked_asset = self._lock_an_asset(content_store, location)
|
||||
locked_asset_attrs = content_store.get_attrs(locked_asset)
|
||||
# the later import will reupload
|
||||
del locked_asset_attrs['uploadDate']
|
||||
@@ -1010,7 +1051,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def check_import(self, module_store, root_dir, draft_store, content_store, stub_location, course_location,
|
||||
locked_asset, locked_asset_attrs):
|
||||
locked_asset, locked_asset_attrs):
|
||||
# reimport
|
||||
import_from_xml(
|
||||
module_store, root_dir, ['test_export'], draft_store=draft_store,
|
||||
@@ -1018,15 +1059,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
target_location_namespace=course_location
|
||||
)
|
||||
|
||||
# Unit test fails in Jenkins without this.
|
||||
loc_mapper().translate_location(course_location.course_id, course_location, False, True)
|
||||
|
||||
items = module_store.get_items(stub_location.replace(category='vertical', name=None))
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
# don't try to look at private verticals. Right now we're running
|
||||
# the service in non-draft aware
|
||||
if getattr(descriptor, 'is_draft', False):
|
||||
print "Checking {0}....".format(descriptor.location.url())
|
||||
resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self._check_verticals(items, course_location.course_id)
|
||||
|
||||
# verify that we have the content in the draft store as well
|
||||
vertical = draft_store.get_item(
|
||||
@@ -1210,7 +1247,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
handouts_locator = loc_mapper().translate_location('edX/toy/2012_Fall', handout_location)
|
||||
|
||||
# get module info (json)
|
||||
resp = self.client.get(handouts_locator.url_reverse('/xblock', ''))
|
||||
resp = self.client.get(handouts_locator.url_reverse('/xblock'))
|
||||
|
||||
# make sure we got a successful response
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -1309,6 +1346,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
items = module_store.get_items(stub_location)
|
||||
self.assertEqual(len(items), 1)
|
||||
|
||||
def _check_verticals(self, items, course_id):
|
||||
""" Test getting the editing HTML for each vertical. """
|
||||
# Assert is here to make sure that the course being tested actually has verticals (units) to check.
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
unit_locator = loc_mapper().translate_location(course_id, descriptor.location, False, True)
|
||||
resp = self.client.get_html(unit_locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# TODO: uncomment when video transcripts no longer require IDs.
|
||||
# _test_no_locations(self, resp)
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE)
|
||||
class ContentStoreTest(ModuleStoreTestCase):
|
||||
@@ -1387,7 +1435,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
second_course_data = self.assert_created_course(number_suffix=uuid4().hex)
|
||||
|
||||
# unseed the forums for the first course
|
||||
course_id =_get_course_id(test_course_data)
|
||||
course_id = _get_course_id(test_course_data)
|
||||
delete_course_and_groups(course_id, commit=True)
|
||||
self.assertFalse(are_permissions_roles_seeded(course_id))
|
||||
|
||||
@@ -1503,6 +1551,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
def test_course_factory(self):
|
||||
"""Test that the course factory works correctly."""
|
||||
@@ -1525,6 +1574,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
status_code=200,
|
||||
html=True
|
||||
)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
def test_course_overview_view_with_course(self):
|
||||
"""Test viewing the course overview page with an existing course"""
|
||||
@@ -1550,12 +1600,13 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
}
|
||||
|
||||
resp = self.client.ajax_post('/xblock', section_data)
|
||||
_test_no_locations(self, resp, html=False)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertRegexpMatches(
|
||||
data['id'],
|
||||
r"^i4x://MITx/999/chapter/([0-9]|[a-f]){32}$"
|
||||
data['locator'],
|
||||
r"^MITx.999.Robot_Super_Course/branch/draft/block/chapter([0-9]|[a-f]){3}$"
|
||||
)
|
||||
|
||||
def test_capa_module(self):
|
||||
@@ -1571,7 +1622,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = parse_json(resp)
|
||||
problem_loc = Location(payload['id'])
|
||||
problem_loc = loc_mapper().translate_locator_to_location(BlockUsageLocator(payload['locator']))
|
||||
problem = get_modulestore(problem_loc).get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
@@ -1584,6 +1635,13 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Import and walk through some common URL endpoints. This just verifies non-500 and no other
|
||||
correct behavior, so it is not a deep test
|
||||
"""
|
||||
def test_get_html(page):
|
||||
# Helper function for getting HTML for a page in Studio and
|
||||
# checking that it does not error.
|
||||
resp = self.client.get_html(new_location.url_reverse(page))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
|
||||
loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None])
|
||||
new_location = loc_mapper().translate_location(loc.course_id, loc, False, True)
|
||||
@@ -1593,55 +1651,47 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.assertContains(resp, 'Chapter 2')
|
||||
|
||||
# go to various pages
|
||||
test_get_html('import')
|
||||
test_get_html('export')
|
||||
test_get_html('course_team')
|
||||
test_get_html('course_info')
|
||||
test_get_html('checklists')
|
||||
test_get_html('assets')
|
||||
test_get_html('tabs')
|
||||
test_get_html('settings/details')
|
||||
test_get_html('settings/grading')
|
||||
|
||||
# import page
|
||||
resp = self.client.get_html(new_location.url_reverse('import/', ''))
|
||||
# advanced settings
|
||||
resp = self.client.get_html(reverse('course_advanced_settings',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# TODO: uncomment when advanced settings not using old locations.
|
||||
# _test_no_locations(self, resp)
|
||||
|
||||
# export page
|
||||
resp = self.client.get_html(new_location.url_reverse('export/', ''))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# course team
|
||||
url = new_location.url_reverse('course_team/', '')
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# course info
|
||||
resp = self.client.get(new_location.url_reverse('course_info'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_details',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# settings_details
|
||||
resp = self.client.get(reverse('settings_grading',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# assets_handler (HTML for full page content)
|
||||
url = new_location.url_reverse('assets/', '')
|
||||
resp = self.client.get_html(url)
|
||||
# textbook index
|
||||
resp = self.client.get_html(reverse('textbook_index',
|
||||
kwargs={'org': loc.org,
|
||||
'course': loc.course,
|
||||
'name': loc.name}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_location = loc.replace(category='sequential', name='test_sequence')
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_subsection', kwargs={'location': subsection_location.url()})
|
||||
)
|
||||
subsection_locator = loc_mapper().translate_location(loc.course_id, subsection_location, False, True)
|
||||
resp = self.client.get_html(subsection_locator.url_reverse('subsection'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
_test_no_locations(self, resp)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_location = loc.replace(category='vertical', name='test_vertical')
|
||||
resp = self.client.get_html(
|
||||
reverse('edit_unit', kwargs={'location': unit_location.url()}))
|
||||
unit_locator = loc_mapper().translate_location(loc.course_id, unit_location, False, True)
|
||||
resp = self.client.get_html(unit_locator.url_reverse('unit'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# TODO: uncomment when video transcripts no longer require IDs.
|
||||
# _test_no_locations(self, resp)
|
||||
|
||||
def delete_item(category, name):
|
||||
""" Helper method for testing the deletion of an xblock item. """
|
||||
@@ -1649,6 +1699,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
del_location = loc_mapper().translate_location(loc.course_id, del_loc, False, True)
|
||||
resp = self.client.delete(del_location.url_reverse('xblock'))
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
_test_no_locations(self, resp, status_code=204, html=False)
|
||||
|
||||
# delete a component
|
||||
delete_item(category='html', name='test_html')
|
||||
@@ -1848,7 +1899,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
Show the course overview page.
|
||||
"""
|
||||
new_location = loc_mapper().translate_location(location.course_id, location, False, True)
|
||||
return self.client.get_html(new_location.url_reverse('course/', ''))
|
||||
resp = self.client.get_html(new_location.url_reverse('course/', ''))
|
||||
_test_no_locations(self, resp)
|
||||
return resp
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_MODULESTORE)
|
||||
@@ -1915,6 +1968,32 @@ class MetadataSaveTestCase(ModuleStoreTestCase):
|
||||
pass
|
||||
|
||||
|
||||
class EntryPageTestCase(TestCase):
|
||||
"""
|
||||
Tests entry pages that aren't specific to a course.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = AjaxEnabledTestClient()
|
||||
|
||||
def _test_page(self, page, status_code=200):
|
||||
resp = self.client.get_html(page)
|
||||
self.assertEqual(resp.status_code, status_code)
|
||||
_test_no_locations(self, resp, status_code)
|
||||
|
||||
def test_how_it_works(self):
|
||||
self._test_page("/howitworks")
|
||||
|
||||
def test_signup(self):
|
||||
self._test_page("/signup")
|
||||
|
||||
def test_login(self):
|
||||
self._test_page("/signin")
|
||||
|
||||
def test_logout(self):
|
||||
# Logout redirects.
|
||||
self._test_page("/logout", 302)
|
||||
|
||||
|
||||
def _create_course(test, course_data):
|
||||
"""
|
||||
Creates a course via an AJAX request and verifies the URL returned in the response.
|
||||
@@ -1926,7 +2005,7 @@ def _create_course(test, course_data):
|
||||
test.assertEqual(response.status_code, 200)
|
||||
data = parse_json(response)
|
||||
test.assertNotIn('ErrMsg', data)
|
||||
test.assertEqual(data['url'], new_location.url_reverse("course/", ""))
|
||||
test.assertEqual(data['url'], new_location.url_reverse("course"))
|
||||
|
||||
|
||||
def _course_factory_create_course():
|
||||
@@ -1940,3 +2019,19 @@ def _course_factory_create_course():
|
||||
def _get_course_id(test_course_data):
|
||||
"""Returns the course ID (org/number/run)."""
|
||||
return "{org}/{number}/{run}".format(**test_course_data)
|
||||
|
||||
|
||||
def _test_no_locations(test, resp, status_code=200, html=True):
|
||||
"""
|
||||
Verifies that "i4x", which appears in old locations, but not
|
||||
new locators, does not appear in the HTML response output.
|
||||
Used to verify that database refactoring is complete.
|
||||
"""
|
||||
test.assertNotContains(resp, 'i4x', status_code=status_code, html=html)
|
||||
if html:
|
||||
# For HTML pages, it is nice to call the method with html=True because
|
||||
# it checks that the HTML properly parses. However, it won't find i4x usages
|
||||
# in JavaScript blocks.
|
||||
content = resp.content
|
||||
hits = len(re.findall(r"(?<!jump_to/)i4x://", content))
|
||||
test.assertEqual(hits, 0, "i4x found outside of LMS jump-to links")
|
||||
|
||||
@@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline.
|
||||
"""
|
||||
import json
|
||||
import lxml
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
@@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase):
|
||||
"""
|
||||
Test the error conditions for the access
|
||||
"""
|
||||
locator = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
|
||||
outline_url = locator.url_reverse('course/', '')
|
||||
outline_url = self.course_locator.url_reverse('course/', '')
|
||||
# register a non-staff member and try to delete the course branch
|
||||
non_staff_client, _ = self.createNonStaffAuthedUserClient()
|
||||
response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json')
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
import copy
|
||||
import mock
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
from django.test.utils import override_settings
|
||||
|
||||
@@ -21,6 +20,7 @@ from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.fields import Date
|
||||
|
||||
from .utils import CourseTestCase
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
@@ -28,8 +28,10 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
self.assertEqual(details.course_location, self.course.location, "Location not copied into")
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
self.assertEqual(details.org, self.course.location.org, "Org not copied into")
|
||||
self.assertEqual(details.course_id, self.course.location.course, "Course_id not copied into")
|
||||
self.assertEqual(details.run, self.course.location.name, "Course name not copied into")
|
||||
self.assertEqual(details.course_image_name, self.course.course_image)
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
@@ -40,10 +42,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
|
||||
def test_encoder(self):
|
||||
details = CourseDetails.fetch(self.course.location)
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=")
|
||||
self.assertEqual(jsondetails['course_image_name'], self.course.course_image)
|
||||
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
@@ -57,7 +58,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Test the encoder out of its original constrained purpose to see if it functions for general use
|
||||
"""
|
||||
details = {
|
||||
'location': Location(['tag', 'org', 'course', 'category', 'name']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())
|
||||
@@ -65,59 +65,49 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
self.assertIn('location', jsondetails)
|
||||
self.assertIn('org', jsondetails['location'])
|
||||
self.assertEquals('org', jsondetails['location'][1])
|
||||
self.assertEquals(1, jsondetails['number'])
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
jsondetails = CourseDetails.fetch(self.course.location)
|
||||
jsondetails = CourseDetails.fetch(self.course_locator)
|
||||
jsondetails.syllabus = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus"
|
||||
)
|
||||
jsondetails.overview = "Overview"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).overview,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).overview,
|
||||
jsondetails.overview, "After set overview"
|
||||
)
|
||||
jsondetails.intro_video = "intro_video"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video"
|
||||
)
|
||||
jsondetails.effort = "effort"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).effort,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).effort,
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).start_date,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).start_date,
|
||||
jsondetails.start_date
|
||||
)
|
||||
jsondetails.course_image_name = "an_image.jpg"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(jsondetails.__dict__).course_image_name,
|
||||
CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).course_image_name,
|
||||
jsondetails.course_image_name
|
||||
)
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
|
||||
def test_marketing_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
settings_details_url = self.course_locator.url_reverse('settings/details/')
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
response = self.client.get(settings_details_url)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertNotContains(response, "Course Summary Page")
|
||||
self.assertNotContains(response, "Send a note to students via email")
|
||||
self.assertContains(response, "course summary page will not be viewable")
|
||||
@@ -135,17 +125,10 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertNotContains(response, "Requirements")
|
||||
|
||||
def test_regular_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course.location.org,
|
||||
'name': self.course.location.name,
|
||||
'course': self.course.location.course
|
||||
}
|
||||
)
|
||||
settings_details_url = self.course_locator.url_reverse('settings/details/')
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
response = self.client.get(settings_details_url)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertContains(response, "Send a note to students via email")
|
||||
self.assertNotContains(response, "course summary page will not be viewable")
|
||||
@@ -168,10 +151,12 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def alter_field(self, url, details, field, val):
|
||||
"""
|
||||
Change the one field to the given value and then invoke the update post to see if it worked.
|
||||
"""
|
||||
setattr(details, field, val)
|
||||
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
||||
payload = copy.copy(details.__dict__)
|
||||
payload['course_location'] = details.course_location.url()
|
||||
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
|
||||
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
|
||||
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
|
||||
@@ -181,16 +166,17 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime_obj):
|
||||
"""
|
||||
Use the xblock serializer to convert the datetime
|
||||
"""
|
||||
return Date().to_json(datetime_obj)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
loc = self.course.location
|
||||
details = CourseDetails.fetch(loc)
|
||||
details = CourseDetails.fetch(self.course_locator)
|
||||
|
||||
# resp s/b json from here on
|
||||
url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course,
|
||||
'name': loc.name, 'section': 'details'})
|
||||
resp = self.client.get(url)
|
||||
url = self.course_locator.url_reverse('settings/details/')
|
||||
resp = self.client.get_json(url)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
|
||||
|
||||
utc = UTC()
|
||||
@@ -206,6 +192,9 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
||||
|
||||
def compare_details_with_encoding(self, encoded, details, context):
|
||||
"""
|
||||
compare all of the fields of the before and after dicts
|
||||
"""
|
||||
self.compare_date_fields(details, encoded, context, 'start_date')
|
||||
self.compare_date_fields(details, encoded, context, 'end_date')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_start')
|
||||
@@ -216,6 +205,9 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
"""
|
||||
Compare the given date fields between the before and after doing json deserialization
|
||||
"""
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
@@ -234,142 +226,191 @@ class CourseGradingTest(CourseTestCase):
|
||||
Tests for the course settings grading page.
|
||||
"""
|
||||
def test_initial_grader(self):
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
test_grader = CourseGradingModel(descriptor)
|
||||
# ??? How much should this test bake in expectations about defaults and thus fail if defaults change?
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
test_grader = CourseGradingModel(self.course)
|
||||
self.assertIsNotNone(test_grader.graders)
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs)
|
||||
|
||||
def test_fetch_grader(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location.url())
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertEqual(self.course.location, test_grader.course_location, "Course locations")
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertIsNotNone(test_grader.graders, "No graders")
|
||||
self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs")
|
||||
|
||||
for i, grader in enumerate(test_grader.graders):
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course.location, i)
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course_locator, i)
|
||||
self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal")
|
||||
|
||||
subgrader = CourseGradingModel.fetch_grader(self.course.location.list(), 0)
|
||||
self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list")
|
||||
|
||||
def test_fetch_cutoffs(self):
|
||||
test_grader = CourseGradingModel.fetch_cutoffs(self.course.location)
|
||||
# ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think)
|
||||
self.assertIsNotNone(test_grader, "No cutoffs via fetch")
|
||||
|
||||
test_grader = CourseGradingModel.fetch_cutoffs(self.course.location.url())
|
||||
self.assertIsNotNone(test_grader, "No cutoffs via fetch with url")
|
||||
|
||||
def test_fetch_grace(self):
|
||||
test_grader = CourseGradingModel.fetch_grace_period(self.course.location)
|
||||
# almost a worthless test
|
||||
self.assertIn('grace_period', test_grader, "No grace via fetch")
|
||||
|
||||
test_grader = CourseGradingModel.fetch_grace_period(self.course.location.url())
|
||||
self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url")
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update")
|
||||
|
||||
test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D")
|
||||
|
||||
test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0}
|
||||
altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__)
|
||||
altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__)
|
||||
self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period")
|
||||
|
||||
def test_update_grader_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update")
|
||||
|
||||
test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2")
|
||||
|
||||
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
|
||||
altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1])
|
||||
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
|
||||
|
||||
def test_update_cutoffs_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
|
||||
# simply returns the cutoffs you send into it, rather than returning the db contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
|
||||
|
||||
test_grader.grade_cutoffs['Pass'] = 0.75
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
|
||||
|
||||
def test_delete_grace_period(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
test_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
|
||||
|
||||
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
|
||||
|
||||
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
|
||||
# Now delete the grace period
|
||||
CourseGradingModel.delete_grace_period(test_grader.course_location)
|
||||
CourseGradingModel.delete_grace_period(self.course_locator)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
altered_grader = CourseGradingModel.fetch(self.course_locator)
|
||||
# Once deleted, the grace period should simply be None
|
||||
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
|
||||
|
||||
def test_update_section_grader_type(self):
|
||||
# Get the descriptor and the section_grader_type and assert they are the default values
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'Homework')
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.format)
|
||||
self.assertEqual(True, descriptor.graded)
|
||||
|
||||
# Change the grader type back to Not Graded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
|
||||
CourseGradingModel.update_section_grader_type(self.course, 'Not Graded')
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.format)
|
||||
self.assertEqual(False, descriptor.graded)
|
||||
|
||||
def test_get_set_grader_types_ajax(self):
|
||||
"""
|
||||
Test configuring the graders via ajax calls
|
||||
"""
|
||||
grader_type_url_base = self.course_locator.url_reverse('settings/grading')
|
||||
# test get whole
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
whole_model = json.loads(response.content)
|
||||
self.assertIn('graders', whole_model)
|
||||
self.assertIn('grade_cutoffs', whole_model)
|
||||
self.assertIn('grace_period', whole_model)
|
||||
# test post/update whole
|
||||
whole_model['grace_period'] = {'hours': 1, 'minutes': 30, 'seconds': 0}
|
||||
response = self.client.ajax_post(grader_type_url_base, whole_model)
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
whole_model = json.loads(response.content)
|
||||
self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0})
|
||||
# test get one grader
|
||||
self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense
|
||||
response = self.client.get_json(grader_type_url_base + '/1')
|
||||
grader_sample = json.loads(response.content)
|
||||
self.assertEqual(grader_sample, whole_model['graders'][1])
|
||||
# test add grader
|
||||
new_grader = {
|
||||
"type": "Extra Credit",
|
||||
"min_count": 1,
|
||||
"drop_count": 2,
|
||||
"short_label": None,
|
||||
"weight": 15,
|
||||
}
|
||||
response = self.client.ajax_post(
|
||||
'{}/{}'.format(grader_type_url_base, len(whole_model['graders'])),
|
||||
new_grader
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
grader_sample = json.loads(response.content)
|
||||
new_grader['id'] = len(whole_model['graders'])
|
||||
self.assertEqual(new_grader, grader_sample)
|
||||
# test delete grader
|
||||
response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json")
|
||||
self.assertEqual(204, response.status_code)
|
||||
response = self.client.get_json(grader_type_url_base)
|
||||
updated_model = json.loads(response.content)
|
||||
new_grader['id'] -= 1 # one fewer and the id mutates
|
||||
self.assertIn(new_grader, updated_model['graders'])
|
||||
self.assertNotIn(whole_model['graders'][1], updated_model['graders'])
|
||||
|
||||
def setup_test_set_get_section_grader_ajax(self):
|
||||
"""
|
||||
Populate the course, grab a section, get the url for the assignment type access
|
||||
"""
|
||||
self.populateCourse()
|
||||
sections = get_modulestore(self.course_location).get_items(
|
||||
self.course_location.replace(category="sequential", name=None)
|
||||
)
|
||||
# see if test makes sense
|
||||
self.assertGreater(len(sections), 0, "No sections found")
|
||||
section = sections[0] # just take the first one
|
||||
section_locator = loc_mapper().translate_location(self.course_location.course_id, section.location, False, True)
|
||||
return section_locator.url_reverse('xblock')
|
||||
|
||||
def test_set_get_section_grader_ajax(self):
|
||||
"""
|
||||
Test setting and getting section grades via the grade as url
|
||||
"""
|
||||
grade_type_url = self.setup_test_set_get_section_grader_ajax()
|
||||
response = self.client.ajax_post(grade_type_url, {'graderType': u'Homework'})
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
||||
self.assertEqual(json.loads(response.content).get('graderType'), u'Homework')
|
||||
# and unset
|
||||
response = self.client.ajax_post(grade_type_url, {'graderType': u'Not Graded'})
|
||||
self.assertEqual(200, response.status_code)
|
||||
response = self.client.get_json(grade_type_url + '?fields=graderType')
|
||||
self.assertEqual(json.loads(response.content).get('graderType'), u'Not Graded')
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
@@ -436,25 +477,52 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
|
||||
|
||||
class CourseGraderUpdatesTest(CourseTestCase):
|
||||
"""
|
||||
Test getting, deleting, adding, & updating graders
|
||||
"""
|
||||
def setUp(self):
|
||||
"""Compute the url to use in tests"""
|
||||
super(CourseGraderUpdatesTest, self).setUp()
|
||||
self.url = reverse("course_settings", kwargs={
|
||||
'org': self.course.location.org,
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'grader_index': 0,
|
||||
})
|
||||
self.url = self.course_locator.url_reverse('settings/grading')
|
||||
self.starting_graders = CourseGradingModel(self.course).graders
|
||||
|
||||
def test_get(self):
|
||||
resp = self.client.get(self.url)
|
||||
"""Test getting a specific grading type record."""
|
||||
resp = self.client.get_json(self.url + '/0')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(self.starting_graders[0], obj)
|
||||
|
||||
def test_delete(self):
|
||||
resp = self.client.delete(self.url)
|
||||
"""Test deleting a specific grading type record."""
|
||||
resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json")
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertNotIn(self.starting_graders[0], current_graders)
|
||||
self.assertEqual(len(self.starting_graders) - 1, len(current_graders))
|
||||
|
||||
def test_post(self):
|
||||
def test_update(self):
|
||||
"""Test updating a specific grading type record."""
|
||||
grader = {
|
||||
"id": 0,
|
||||
"type": "manual",
|
||||
"min_count": 5,
|
||||
"drop_count": 10,
|
||||
"short_label": "yo momma",
|
||||
"weight": 17.3,
|
||||
}
|
||||
resp = self.client.ajax_post(self.url + '/0', grader)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(obj, grader)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertEqual(len(self.starting_graders), len(current_graders))
|
||||
|
||||
def test_add(self):
|
||||
"""Test adding a grading type record."""
|
||||
# the same url works for changing the whole grading model (graceperiod, cutoffs, and grading types) when
|
||||
# the grading_index is None; thus, using None to imply adding a grading_type doesn't work; so, it uses an
|
||||
# index out of bounds to imply create item.
|
||||
grader = {
|
||||
"type": "manual",
|
||||
"min_count": 5,
|
||||
@@ -462,6 +530,11 @@ class CourseGraderUpdatesTest(CourseTestCase):
|
||||
"short_label": "yo momma",
|
||||
"weight": 17.3,
|
||||
}
|
||||
resp = self.client.ajax_post(self.url, grader)
|
||||
resp = self.client.ajax_post('{}/{}'.format(self.url, len(self.starting_graders) + 1), grader)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
obj = json.loads(resp.content)
|
||||
self.assertEqual(obj['id'], len(self.starting_graders))
|
||||
del obj['id']
|
||||
self.assertEqual(obj, grader)
|
||||
current_graders = CourseGradingModel.fetch(self.course_locator).graders
|
||||
self.assertEqual(len(self.starting_graders) + 1, len(current_graders))
|
||||
|
||||
@@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase):
|
||||
parent_location=vertical.location,
|
||||
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):
|
||||
""" Export failure helper method. """
|
||||
|
||||
@@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
class ItemTest(CourseTestCase):
|
||||
@@ -30,7 +31,7 @@ class ItemTest(CourseTestCase):
|
||||
"""
|
||||
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))
|
||||
|
||||
def response_locator(self, response):
|
||||
@@ -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(unit1_locator).url(), children[2])
|
||||
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
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from contentstore.tests.modulestore_config import TEST_MODULESTORE
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
@@ -59,7 +60,7 @@ class Basetranscripts(CourseTestCase):
|
||||
'type': 'video'
|
||||
}
|
||||
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)
|
||||
|
||||
# hI10vDNYz4M - valid Youtube ID with transcripts.
|
||||
@@ -72,6 +73,11 @@ class Basetranscripts(CourseTestCase):
|
||||
# Remove all transcripts for current module.
|
||||
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):
|
||||
"""Return youtube speeds and ids."""
|
||||
item = modulestore().get_item(self.item_location)
|
||||
@@ -205,7 +211,7 @@ class TestUploadtranscripts(Basetranscripts):
|
||||
'type': 'non_video'
|
||||
}
|
||||
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" />'
|
||||
modulestore().update_item(item_location, data)
|
||||
|
||||
@@ -416,7 +422,7 @@ class TestDownloadtranscripts(Basetranscripts):
|
||||
'type': 'videoalpha'
|
||||
}
|
||||
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())
|
||||
data = textwrap.dedent("""
|
||||
<videoalpha youtube="" sub="{}">
|
||||
@@ -666,7 +672,7 @@ class TestChecktranscripts(Basetranscripts):
|
||||
'type': 'not_video'
|
||||
}
|
||||
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())
|
||||
data = textwrap.dedent("""
|
||||
<not_video youtube="" sub="{}">
|
||||
|
||||
@@ -10,8 +10,9 @@ from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
|
||||
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 xmodule.modulestore.django import loc_mapper
|
||||
|
||||
|
||||
def parse_json(response):
|
||||
@@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client):
|
||||
if not isinstance(data, basestring):
|
||||
data = json.dumps(data or {})
|
||||
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)
|
||||
|
||||
def get_html(self, path, data=None, follow=False, **extra):
|
||||
@@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
display_name='Robot Super Course',
|
||||
)
|
||||
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):
|
||||
"""
|
||||
@@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
client = Client()
|
||||
client.login(username=uname, password=password)
|
||||
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
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.urlresolvers import reverse
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from django.http import HttpResponseNotFound
|
||||
@@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
__all__ = ['checklists_handler']
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@@ -85,8 +86,8 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g
|
||||
return JsonResponse(expanded_checklist)
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
( "Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
("Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
content_type="text/plain"
|
||||
)
|
||||
else:
|
||||
@@ -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.
|
||||
"""
|
||||
expanded_checklist = copy.deepcopy(checklist)
|
||||
oldurlconf_map = {
|
||||
"SettingsDetails": "settings_details",
|
||||
"SettingsGrading": "settings_grading"
|
||||
}
|
||||
|
||||
urlconf_map = {
|
||||
"ManageUsers": "course_team",
|
||||
"CourseOutline": "course"
|
||||
"CourseOutline": "course",
|
||||
"SettingsDetails": "settings/details",
|
||||
"SettingsGrading": "settings/grading",
|
||||
}
|
||||
|
||||
for item in expanded_checklist.get('items'):
|
||||
@@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist):
|
||||
ctx_loc = course_module.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
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
|
||||
|
||||
@@ -2,21 +2,19 @@ import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from django.http import (HttpResponse, HttpResponseBadRequest,
|
||||
HttpResponseForbidden)
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.exceptions import (ItemNotFoundError,
|
||||
InvalidLocationError)
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
|
||||
from xblock.fields import Scope
|
||||
from util.json_request import expect_json, JsonResponse
|
||||
@@ -25,7 +23,6 @@ from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitSt
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
from .helpers import _xmodule_recurse
|
||||
from .access import has_access
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xblock.plugin import PluginMissingError
|
||||
@@ -33,17 +30,13 @@ from xblock.runtime import Mixologist
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'edit_subsection',
|
||||
'edit_unit',
|
||||
'assignment_type_update',
|
||||
'create_draft',
|
||||
'publish_draft',
|
||||
'unpublish_unit',
|
||||
'subsection_handler',
|
||||
'unit_handler'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
# NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
|
||||
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
@@ -58,93 +51,87 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
"Edit the subsection of a course"
|
||||
# check that we have permissions to edit this item
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
def subsection_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
The restful handler for subsection-specific requests.
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
GET
|
||||
html: return html page for editing a subsection
|
||||
json: not currently supported
|
||||
"""
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
try:
|
||||
old_location, course, item, lms_link = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
preview_link = get_lms_link_for_item(old_location, course_id=course.location.course_id, preview=True)
|
||||
|
||||
lms_link = get_lms_link_for_item(
|
||||
location, course_id=course.location.course_id
|
||||
)
|
||||
preview_link = get_lms_link_for_item(
|
||||
location, course_id=course.location.course_id, preview=True
|
||||
)
|
||||
# make sure that location references a 'sequential', otherwise return
|
||||
# BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# make sure that location references a 'sequential', otherwise return
|
||||
# BadRequest
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
parent_locs = modulestore().get_parent_locations(old_location, None)
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
'Multiple (or none) parents have been found for %s',
|
||||
location
|
||||
unicode(locator)
|
||||
)
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
# the fields from any mixins that have been added
|
||||
fields = getattr(item, 'unmixed_class', item.__class__).fields
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in fields.values()
|
||||
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
# the fields from any mixins that have been added
|
||||
fields = getattr(item, 'unmixed_class', item.__class__).fields
|
||||
course_locator = loc_mapper().translate_location(
|
||||
course.location.course_id, course.location, False, True
|
||||
)
|
||||
|
||||
policy_metadata = dict(
|
||||
(field.name, field.read_from(item))
|
||||
for field
|
||||
in fields.values()
|
||||
if field.name not in ['display_name', 'start', 'due', 'format']
|
||||
and field.scope == Scope.settings
|
||||
)
|
||||
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_unit_state(unit)
|
||||
if state == UnitState.public or state == UnitState.draft:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
locator = loc_mapper().translate_location(
|
||||
course.location.course_id, item.location, False, True
|
||||
)
|
||||
|
||||
return render_to_response(
|
||||
'edit_subsection.html',
|
||||
{
|
||||
'subsection': item,
|
||||
'context_course': course,
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
# For grader, which is not yet converted
|
||||
'parent_location': course.location,
|
||||
'parent_item': parent,
|
||||
'locator': locator,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
}
|
||||
)
|
||||
return render_to_response(
|
||||
'edit_subsection.html',
|
||||
{
|
||||
'subsection': item,
|
||||
'context_course': course,
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course_locator).graders),
|
||||
'parent_item': parent,
|
||||
'locator': locator,
|
||||
'policy_metadata': policy_metadata,
|
||||
'subsection_units': subsection_units,
|
||||
'can_view_live': can_view_live
|
||||
}
|
||||
)
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
def load_mixed_class(category):
|
||||
def _load_mixed_class(category):
|
||||
"""
|
||||
Load an XBlock by category name, and apply all defined mixins
|
||||
"""
|
||||
@@ -153,139 +140,125 @@ def load_mixed_class(category):
|
||||
return mixologist.mix(component_class)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def edit_unit(request, location):
|
||||
def unit_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
Display an editing page for the specified module.
|
||||
The restful handler for unit-specific requests.
|
||||
|
||||
Expects a GET request with the parameter `id`.
|
||||
|
||||
id: A Location URL
|
||||
GET
|
||||
html: return html page for editing a unit
|
||||
json: not currently supported
|
||||
"""
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
try:
|
||||
old_location, course, item, lms_link = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
lms_link = get_lms_link_for_item(
|
||||
item.location,
|
||||
course_id=course.location.course_id
|
||||
)
|
||||
|
||||
# Note that the unit_state (draft, public, private) does not match up with the published value
|
||||
# passed to translate_location. The two concepts are different at this point.
|
||||
unit_locator = loc_mapper().translate_location(
|
||||
course.location.course_id, Location(location), False, True
|
||||
)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = load_mixed_class(category)
|
||||
# add the default template
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
if hasattr(component_class, 'display_name'):
|
||||
display_name = component_class.display_name.default or 'Blank'
|
||||
else:
|
||||
display_name = 'Blank'
|
||||
component_templates[category].append((
|
||||
display_name,
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
filter_templates = getattr(component_class, 'filter_templates', None)
|
||||
if not filter_templates or filter_templates(template, course):
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
|
||||
# enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
if isinstance(course_advanced_keys, list):
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the
|
||||
# class? i.e., can an advanced have more than one entry in the
|
||||
# menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = load_mixed_class(category)
|
||||
|
||||
component_templates['advanced'].append((
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = _load_mixed_class(category)
|
||||
# add the default template
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
if hasattr(component_class, 'display_name'):
|
||||
display_name = component_class.display_name.default or 'Blank'
|
||||
else:
|
||||
display_name = 'Blank'
|
||||
component_templates[category].append((
|
||||
display_name,
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
if hasattr(component_class, 'templates'):
|
||||
for template in component_class.templates():
|
||||
filter_templates = getattr(component_class, 'filter_templates', None)
|
||||
if not filter_templates or filter_templates(template, course):
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the
|
||||
# course author configures an advanced component which does
|
||||
# not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the
|
||||
# non-existent component type by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %s",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
[
|
||||
component.location.url(),
|
||||
loc_mapper().translate_location(
|
||||
course.location.course_id, component.location, False, True
|
||||
# Check if there are any advanced modules specified in the course policy.
|
||||
# These modules should be specified as a list of strings, where the strings
|
||||
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
|
||||
# enabled for the course.
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
if isinstance(course_advanced_keys, list):
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the
|
||||
# class? i.e., can an advanced have more than one entry in the
|
||||
# menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = _load_mixed_class(category)
|
||||
|
||||
component_templates['advanced'].append(
|
||||
(
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
)
|
||||
)
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the
|
||||
# course author configures an advanced component which does
|
||||
# not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the
|
||||
# non-existent component type by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error(
|
||||
"Improper format for course advanced keys! %s",
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
[
|
||||
# TODO: old location needed for video transcripts.
|
||||
component.location.url(),
|
||||
loc_mapper().translate_location(
|
||||
course.location.course_id, component.location, False, True
|
||||
)
|
||||
]
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
for component
|
||||
in item.get_children()
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(
|
||||
location, None
|
||||
)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection_locs = modulestore().get_parent_locations(old_location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection.location, None
|
||||
)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE')
|
||||
|
||||
preview_lms_link = (
|
||||
preview_lms_link = (
|
||||
'//{preview_lms_base}/courses/{org}/{course}/'
|
||||
'{course_name}/courseware/{section}/{subsection}/{index}'
|
||||
).format(
|
||||
@@ -299,102 +272,46 @@ def edit_unit(request, location):
|
||||
index=index
|
||||
)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
# Still needed for creating a draft.
|
||||
'unit_location': location,
|
||||
'unit_locator': unit_locator,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': compute_unit_state(item),
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
"""
|
||||
CRUD operations on assignment types for sections and subsections and
|
||||
anything else gradable.
|
||||
"""
|
||||
location = Location(['i4x', org, course, category, name])
|
||||
if not has_access(request.user, location):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
if request.method == 'GET':
|
||||
rsp = CourseGradingModel.get_section_grader_type(location)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
rsp = CourseGradingModel.update_section_grader_type(
|
||||
location, request.json
|
||||
)
|
||||
return JsonResponse(rsp)
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
'unit_locator': locator,
|
||||
'components': components,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': compute_unit_state(item),
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def create_draft(request):
|
||||
"Create a draft"
|
||||
location = request.json['id']
|
||||
def _get_item_in_course(request, locator):
|
||||
"""
|
||||
Helper method for getting the old location, containing course,
|
||||
item, and lms_link for a given locator.
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
Verifies that the caller has permission to access this item.
|
||||
"""
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is
|
||||
# implicit, because modulestore is a Draft modulestore)
|
||||
modulestore().convert_to_draft(location)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_location = loc_mapper().translate_locator_to_location(locator, True)
|
||||
course = modulestore().get_item(course_location)
|
||||
item = modulestore().get_item(old_location, depth=1)
|
||||
lms_link = get_lms_link_for_item(old_location, course_id=course.location.course_id)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def publish_draft(request):
|
||||
"""
|
||||
Publish a draft
|
||||
"""
|
||||
location = request.json['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(
|
||||
item,
|
||||
lambda i: modulestore().publish(i.location, request.user.id)
|
||||
)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def unpublish_unit(request):
|
||||
"Unpublish a unit"
|
||||
location = request.json['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location)
|
||||
_xmodule_recurse(item, lambda i: modulestore().unpublish(i.location))
|
||||
|
||||
return HttpResponse()
|
||||
return old_location, course, item, lms_link
|
||||
|
||||
@@ -32,8 +32,7 @@ from contentstore.course_info_model import (
|
||||
from contentstore.utils import (
|
||||
get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab,
|
||||
get_modulestore)
|
||||
from models.settings.course_details import (
|
||||
CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
@@ -53,13 +52,12 @@ from student.models import CourseEnrollment
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from contentstore import utils
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
|
||||
'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
'course_config_advanced_page',
|
||||
'course_settings_updates',
|
||||
'course_grader_updates',
|
||||
'course_advanced_updates', 'textbook_index', 'textbook_by_id',
|
||||
'create_textbook']
|
||||
|
||||
@@ -190,10 +188,8 @@ def course_index(request, course_id, branch, version_guid, block):
|
||||
'lms_link': lms_link,
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(
|
||||
CourseGradingModel.fetch(course.location).graders
|
||||
CourseGradingModel.fetch(location).graders
|
||||
),
|
||||
# This is used by course grader, which has not yet been updated.
|
||||
'parent_location': course.location,
|
||||
'parent_locator': location,
|
||||
'new_section_category': 'chapter',
|
||||
'new_subsection_category': 'sequential',
|
||||
@@ -394,54 +390,106 @@ def course_info_update_handler(
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
@require_http_methods(("GET", "PUT", "POST"))
|
||||
@expect_json
|
||||
def settings_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to
|
||||
the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
Course settings for dates and about pages
|
||||
GET
|
||||
html: get the page
|
||||
json: get the CourseDetails model
|
||||
PUT
|
||||
json: update the Course and About xblocks through the CourseDetails model
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
course_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
|
||||
new_loc = loc_mapper().translate_location(location.course_id, location, False, True)
|
||||
upload_asset_url = new_loc.url_reverse('assets/', '')
|
||||
upload_asset_url = locator.url_reverse('assets/')
|
||||
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'details_url': reverse(course_settings_updates,
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"}),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get(
|
||||
'ENABLE_MKTG_SITE', False
|
||||
),
|
||||
'upload_asset_url': upload_asset_url
|
||||
})
|
||||
return render_to_response('settings.html', {
|
||||
'context_course': course_module,
|
||||
'course_locator': locator,
|
||||
'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_old_location),
|
||||
'course_image_url': utils.course_image_url(course_module),
|
||||
'details_url': locator.url_reverse('/settings/details/'),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get(
|
||||
'ENABLE_MKTG_SITE', False
|
||||
),
|
||||
'upload_asset_url': upload_asset_url
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(
|
||||
CourseDetails.fetch(locator),
|
||||
# encoder serializes dates, old locations, and instances
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(
|
||||
CourseDetails.update_from_json(locator, request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@expect_json
|
||||
def grading_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None, grader_index=None):
|
||||
"""
|
||||
Send models and views as well as html for editing the course settings to
|
||||
the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
Course Grading policy configuration
|
||||
GET
|
||||
html: get the page
|
||||
json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders)
|
||||
json w/ grader_index: get the specific grader
|
||||
PUT
|
||||
json no grader_index: update the Course through the CourseGrading model
|
||||
json w/ grader_index: create or update the specific grader (create if index out of range)
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
course_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
course_details = CourseGradingModel.fetch(locator)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_locator': locator,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder),
|
||||
'grading_url': locator.url_reverse('/settings/grading/'),
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
if grader_index is None:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.fetch(locator),
|
||||
# encoder serializes dates, old locations, and instances
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else:
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(locator, grader_index))
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
# None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader
|
||||
if grader_index is None:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.update_from_json(locator, request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
else:
|
||||
return JsonResponse(
|
||||
CourseGradingModel.update_grader_from_json(locator, request.json)
|
||||
)
|
||||
elif request.method == "DELETE" and grader_index is not None:
|
||||
CourseGradingModel.delete_grader(locator, grader_index)
|
||||
return JsonResponse()
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -460,75 +508,11 @@ def course_config_advanced_page(request, org, course, name):
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_locator': loc_mapper().translate_location(location.course_id, location, False, True),
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_settings_updates(request, org, course, name, section):
|
||||
"""
|
||||
Restful CRUD operations on course settings. This differs from
|
||||
get_course_settings by communicating purely through json (not rendering any
|
||||
html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
section: one of details, faculty, grading, problems, discussions
|
||||
"""
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if section == 'details':
|
||||
manager = CourseDetails
|
||||
elif section == 'grading':
|
||||
manager = CourseGradingModel
|
||||
else:
|
||||
return
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(
|
||||
manager.fetch(Location(['i4x', org, course, 'course', name])),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(
|
||||
manager.update_from_json(request.json),
|
||||
encoder=CourseSettingsEncoder
|
||||
)
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_grader_updates(request, org, course, name, grader_index=None):
|
||||
"""
|
||||
Restful CRUD operations on course_info updates. This differs from
|
||||
get_course_settings by communicating purely through json (not rendering any
|
||||
html) and handles section level operations rather than whole page.
|
||||
|
||||
org, course: Attributes of the Location for the item to edit
|
||||
"""
|
||||
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(CourseGradingModel.fetch_grader(
|
||||
Location(location), grader_index
|
||||
))
|
||||
elif request.method == "DELETE":
|
||||
# ??? Should this return anything? Perhaps success fail?
|
||||
CourseGradingModel.delete_grader(Location(location), grader_index)
|
||||
return JsonResponse()
|
||||
else: # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_grader_from_json(
|
||||
Location(location),
|
||||
request.json
|
||||
))
|
||||
|
||||
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
|
||||
@@ -14,7 +14,6 @@ from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.core.exceptions import SuspiciousOperation, PermissionDenied
|
||||
@@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
"size": size,
|
||||
"deleteUrl": "",
|
||||
"deleteType": "",
|
||||
"url": location.url_reverse('import/', ''),
|
||||
"url": location.url_reverse('import'),
|
||||
"thumbnailUrl": ""
|
||||
}]
|
||||
})
|
||||
@@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
course_module = modulestore().get_item(old_location)
|
||||
return render_to_response('import.html', {
|
||||
'context_course': course_module,
|
||||
'successful_import_redirect_url': location.url_reverse("course/", ""),
|
||||
'import_status_url': location.url_reverse("import_status/", "fillerName"),
|
||||
'successful_import_redirect_url': location.url_reverse("course"),
|
||||
'import_status_url': location.url_reverse("import_status", "fillerName"),
|
||||
})
|
||||
else:
|
||||
return HttpResponseNotFound()
|
||||
@@ -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.
|
||||
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:
|
||||
name = old_location.name
|
||||
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=
|
||||
# if we have a nested exception, then we'll show the more generic error message
|
||||
pass
|
||||
|
||||
unit_locator = loc_mapper().translate_location(old_location.course_id, parent.location, False, True)
|
||||
|
||||
return render_to_response('export.html', {
|
||||
'context_course': course_module,
|
||||
'in_err': True,
|
||||
'raw_err_msg': str(e),
|
||||
'failed_module': failed_item,
|
||||
'unit': unit,
|
||||
'edit_unit_url': reverse('edit_unit', kwargs={
|
||||
'location': parent.location
|
||||
}) if parent else '',
|
||||
'course_home_url': location.url_reverse("course/", ""),
|
||||
'edit_unit_url': unit_locator.url_reverse("unit") if parent else "",
|
||||
'course_home_url': location.url_reverse("course"),
|
||||
'export_url': export_url
|
||||
|
||||
})
|
||||
@@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid=
|
||||
'in_err': True,
|
||||
'unit': None,
|
||||
'raw_err_msg': str(e),
|
||||
'course_home_url': location.url_reverse("course/", ""),
|
||||
'course_home_url': location.url_reverse("course"),
|
||||
'export_url': export_url
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from functools import partial
|
||||
from static_replace import replace_static_urls
|
||||
from xmodule_modifiers import wrap_xblock
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator
|
||||
from student.models import CourseEnrollment
|
||||
from django.http import HttpResponseBadRequest
|
||||
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']
|
||||
|
||||
@@ -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.
|
||||
GET
|
||||
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
|
||||
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:
|
||||
:data: the new value for the data.
|
||||
: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
|
||||
to None! Absent ones will be left alone.
|
||||
: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.
|
||||
|
||||
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:
|
||||
:parent_locator: parent for new xblock, required
|
||||
:category: type of xblock, required
|
||||
@@ -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.
|
||||
"""
|
||||
if course_id is not None:
|
||||
location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, location):
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
|
||||
if request.method == 'GET':
|
||||
rsp = _get_module_info(location)
|
||||
return JsonResponse(rsp)
|
||||
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)
|
||||
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':
|
||||
delete_children = str_to_bool(request.REQUEST.get('recurse', '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=
|
||||
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.
|
||||
return _save_item(
|
||||
location,
|
||||
request,
|
||||
locator,
|
||||
old_location,
|
||||
data=request.json.get('data'),
|
||||
children=request.json.get('children'),
|
||||
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'):
|
||||
return _create_item(request)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
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:
|
||||
store.update_item(item_location, data)
|
||||
else:
|
||||
@@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None
|
||||
if existing_item.category == 'video':
|
||||
manage_video_subtitles_save(existing_item, existing_item)
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return JsonResponse({
|
||||
result = {
|
||||
'id': unicode(usage_loc),
|
||||
'data': data,
|
||||
'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
|
||||
@@ -192,10 +252,7 @@ def _create_item(request):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(category).get_item(parent_location)
|
||||
# Necessary to set revision=None or else metadata inheritance does not work
|
||||
# (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)
|
||||
dest_location = parent_location.replace(category=category, name=uuid4().hex)
|
||||
|
||||
# get the metadata, display_name, and definition from the request
|
||||
metadata = {}
|
||||
@@ -224,7 +281,7 @@ def _create_item(request):
|
||||
|
||||
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)
|
||||
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):
|
||||
|
||||
@@ -3,7 +3,7 @@ from functools import partial
|
||||
|
||||
from django.conf import settings
|
||||
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 mitxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
@@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .helpers import render_from_lms
|
||||
from .access import has_access
|
||||
from ..utils import get_course_for_item
|
||||
|
||||
__all__ = ['preview_handler', 'preview_component']
|
||||
__all__ = ['preview_handler']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -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`
|
||||
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)
|
||||
|
||||
descriptor = modulestore().get_item(location)
|
||||
instance = load_preview_module(request, descriptor)
|
||||
instance = _load_preview_module(request, descriptor)
|
||||
# Let the module handle the AJAX
|
||||
req = django_to_webob_request(request)
|
||||
try:
|
||||
@@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''):
|
||||
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
|
||||
"""
|
||||
An XModule ModuleSystem for use in Studio previews
|
||||
@@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method
|
||||
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
|
||||
rendering module previews.
|
||||
@@ -135,7 +108,7 @@ def preview_module_system(request, descriptor):
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.runtime.resources_fs,
|
||||
get_module=partial(load_preview_module, request),
|
||||
get_module=partial(_load_preview_module, request),
|
||||
render_template=render_from_lms,
|
||||
debug=True,
|
||||
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):
|
||||
)
|
||||
|
||||
|
||||
def load_preview_module(request, descriptor):
|
||||
def _load_preview_module(request, descriptor):
|
||||
"""
|
||||
Return a preview XModule instantiated from the supplied descriptor.
|
||||
|
||||
@@ -171,7 +144,7 @@ def load_preview_module(request, descriptor):
|
||||
"""
|
||||
student_data = DbModel(SessionKeyValueStore(request))
|
||||
descriptor.bind_for_student(
|
||||
preview_module_system(request, descriptor),
|
||||
_preview_module_system(request, descriptor),
|
||||
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
|
||||
)
|
||||
return descriptor
|
||||
@@ -182,7 +155,7 @@ def get_preview_html(request, descriptor):
|
||||
Returns the HTML returned by the XModule's student_view,
|
||||
specified by the descriptor and idx.
|
||||
"""
|
||||
module = load_preview_module(request, descriptor)
|
||||
module = _load_preview_module(request, descriptor)
|
||||
try:
|
||||
content = module.render("student_view").content
|
||||
except Exception as exc: # pylint: disable=W0703
|
||||
|
||||
@@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from external_auth.views import ssl_login_shortcut
|
||||
|
||||
__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks']
|
||||
__all__ = ['signup', 'login_page', 'howitworks']
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -22,13 +22,6 @@ def signup(request):
|
||||
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
|
||||
@ensure_csrf_cookie
|
||||
def login_page(request):
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Views related to course tabs
|
||||
"""
|
||||
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.core.exceptions import PermissionDenied
|
||||
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 xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
@@ -19,7 +20,7 @@ from ..utils import get_modulestore
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
__all__ = ['edit_tabs', 'reorder_static_tabs']
|
||||
__all__ = ['tabs_handler']
|
||||
|
||||
|
||||
def initialize_course_tabs(course):
|
||||
@@ -43,107 +44,113 @@ def initialize_course_tabs(course):
|
||||
|
||||
modulestore('direct').update_metadata(course.location.url(), own_metadata(course))
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def reorder_static_tabs(request):
|
||||
"Order the static tabs in the requested order"
|
||||
def get_location_for_tab(tab):
|
||||
tab_locator = BlockUsageLocator(tab)
|
||||
return loc_mapper().translate_locator_to_location(tab_locator)
|
||||
|
||||
tabs = request.json['tabs']
|
||||
course_location = loc_mapper().translate_locator_to_location(BlockUsageLocator(tabs[0]), get_course=True)
|
||||
|
||||
if not has_access(request.user, course_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
course = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
# 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
|
||||
# that we know about) otherwise we can drop some!
|
||||
|
||||
existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# load all reference tabs, return BadRequest if we can't find any of them
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(get_location_for_tab(tab))
|
||||
if item is None:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append({'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name})
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
# TODO: above two lines are used for the primitive-save case. Maybe factor them out?
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@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)
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
def tabs_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
The restful handler for static tabs.
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
GET
|
||||
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.
|
||||
|
||||
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()
|
||||
|
||||
# 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:
|
||||
initialize_course_tabs(course_item)
|
||||
old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
store = get_modulestore(old_location)
|
||||
course_item = store.get_item(old_location)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
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))
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug'])
|
||||
static_tabs.append(modulestore('direct').get_item(static_tab_loc))
|
||||
tabs = request.json['tabs']
|
||||
|
||||
components = [
|
||||
[
|
||||
static_tab.location.url(),
|
||||
# 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
|
||||
# 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']
|
||||
if len(existing_static_tabs) != len(tabs):
|
||||
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
|
||||
tab_items = []
|
||||
for tab in tabs:
|
||||
item = modulestore('direct').get_item(get_location_for_tab(tab))
|
||||
if item is None:
|
||||
return JsonResponse(
|
||||
{"error": "no tab for found location {}".format(tab)}, status=400
|
||||
)
|
||||
|
||||
tab_items.append(item)
|
||||
|
||||
# now just go through the existing course_tabs and re-order the static tabs
|
||||
reordered_tabs = []
|
||||
static_tab_idx = 0
|
||||
for tab in course_item.tabs:
|
||||
if tab['type'] == 'static_tab':
|
||||
reordered_tabs.append(
|
||||
{'type': 'static_tab',
|
||||
'name': tab_items[static_tab_idx].display_name,
|
||||
'url_slug': tab_items[static_tab_idx].location.name,
|
||||
}
|
||||
)
|
||||
static_tab_idx += 1
|
||||
else:
|
||||
reordered_tabs.append(tab)
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course_item.tabs = reordered_tabs
|
||||
modulestore('direct').update_metadata(course_item.location, own_metadata(course_item))
|
||||
return JsonResponse()
|
||||
else:
|
||||
raise NotImplementedError('Creating or changing tab content is not supported.')
|
||||
elif request.method == 'GET': # assume html
|
||||
# see tabs have been uninitialized (e.g. supporting courses created before tab support in studio)
|
||||
if course_item.tabs is None or len(course_item.tabs) == 0:
|
||||
initialize_course_tabs(course_item)
|
||||
|
||||
# first get all static tabs from the tabs list
|
||||
# we do this because this is also the order in which items are displayed in the LMS
|
||||
static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab']
|
||||
|
||||
static_tabs = []
|
||||
for static_tab_ref in static_tabs_refs:
|
||||
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))
|
||||
|
||||
components = [
|
||||
loc_mapper().translate_location(
|
||||
course_item.location.course_id, static_tab.location, False, True
|
||||
)
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
for static_tab
|
||||
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', {
|
||||
'context_course': course_item,
|
||||
'components': components,
|
||||
'locator': course_locator
|
||||
})
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'context_course': course_item,
|
||||
'components': components,
|
||||
'course_locator': locator
|
||||
})
|
||||
else:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
|
||||
# "primitive" tab edit functions driven by the command line.
|
||||
@@ -167,7 +174,7 @@ def primitive_delete(course, num):
|
||||
# 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.
|
||||
# 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):
|
||||
@@ -176,11 +183,5 @@ def primitive_insert(course, num, tab_type, name):
|
||||
new_tab = {u'type': unicode(tab_type), u'name': unicode(name)}
|
||||
tabs = course.tabs
|
||||
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))
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
from contentstore.utils import get_modulestore, course_image_url
|
||||
from models.settings import course_grading
|
||||
from contentstore.utils import update_item
|
||||
from xmodule.fields import Date
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
|
||||
|
||||
class CourseDetails(object):
|
||||
def __init__(self, location):
|
||||
self.course_location = location # a Location obj
|
||||
def __init__(self, org, course_id, run):
|
||||
# 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.end_date = None # 'end'
|
||||
self.enrollment_start = None
|
||||
@@ -31,12 +36,9 @@ class CourseDetails(object):
|
||||
"""
|
||||
Fetch the course details for the given course from persistence and return a CourseDetails model.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
course = cls(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_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.start_date = descriptor.start
|
||||
course.end_date = descriptor.end
|
||||
@@ -45,7 +47,7 @@ class CourseDetails(object):
|
||||
course.course_image_name = descriptor.course_image
|
||||
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:
|
||||
course.syllabus = get_modulestore(temploc).get_item(temploc).data
|
||||
except ItemNotFoundError:
|
||||
@@ -73,14 +75,12 @@ class CourseDetails(object):
|
||||
return course
|
||||
|
||||
@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
|
||||
"""
|
||||
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
course_location = Location(jsondict['course_location'])
|
||||
# Will probably want to cache the inflight courses because every blur generates an update
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
@@ -134,11 +134,11 @@ class CourseDetails(object):
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
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
|
||||
# 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'])
|
||||
|
||||
temploc = temploc.replace(name='overview')
|
||||
@@ -151,7 +151,7 @@ class CourseDetails(object):
|
||||
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
|
||||
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
|
||||
return CourseDetails.fetch(course_location)
|
||||
|
||||
@@ -188,6 +188,9 @@ class CourseDetails(object):
|
||||
|
||||
# TODO move to a more general util?
|
||||
class CourseSettingsEncoder(json.JSONEncoder):
|
||||
"""
|
||||
Serialize CourseDetails, CourseGradingModel, datetime, and old Locations
|
||||
"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
|
||||
return obj.__dict__
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from datetime import timedelta
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xblock.fields import Scope
|
||||
|
||||
|
||||
class CourseGradingModel(object):
|
||||
@@ -9,22 +10,20 @@ class CourseGradingModel(object):
|
||||
"""
|
||||
# Within this class, allow access to protected members of client classes.
|
||||
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
|
||||
# pylint: disable=W0212
|
||||
def __init__(self, course_descriptor):
|
||||
self.course_location = course_descriptor.location
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.graders = [
|
||||
CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)
|
||||
] # weights transformed to ints [0..100]
|
||||
self.grade_cutoffs = course_descriptor.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
"""
|
||||
Fetch the course details for the given course from persistence and return a CourseDetails model.
|
||||
Fetch the course grading policy for the given course from persistence and return a CourseGradingModel.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
model = cls(descriptor)
|
||||
return model
|
||||
@@ -35,12 +34,8 @@ class CourseGradingModel(object):
|
||||
Fetch the course's nth grader
|
||||
Returns an empty dict if there's no such grader.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
|
||||
# # but that would require not using CourseDescriptor's field directly. Opinions?
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
index = int(index)
|
||||
if len(descriptor.raw_grader) > index:
|
||||
@@ -57,44 +52,22 @@ class CourseGradingModel(object):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def fetch_cutoffs(course_location):
|
||||
"""
|
||||
Fetch the course's grade cutoffs.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
return descriptor.grade_cutoffs
|
||||
|
||||
@staticmethod
|
||||
def fetch_grace_period(course_location):
|
||||
"""
|
||||
Fetch the course's default grace period.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
return {'grace_period': CourseGradingModel.convert_set_grace_period(descriptor)}
|
||||
|
||||
@staticmethod
|
||||
def update_from_json(jsondict):
|
||||
def update_from_json(course_location, jsondict):
|
||||
"""
|
||||
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
|
||||
Probably not the usual path for updates as it's too coarse grained.
|
||||
"""
|
||||
course_location = Location(jsondict['course_location'])
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
|
||||
|
||||
descriptor.raw_grader = graders_parsed
|
||||
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
|
||||
|
||||
@@ -106,12 +79,8 @@ class CourseGradingModel(object):
|
||||
Create or update the grader of the given type (string key) for the given course. Returns the modified
|
||||
grader which is a full model on the client but not on the server (just a dict)
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
# # ??? it would be good if these had the course_location in them so that they stand alone sufficiently
|
||||
# # but that would require not using CourseDescriptor's field directly. Opinions?
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
# parse removes the id; so, grab it before parse
|
||||
index = int(grader.get('id', len(descriptor.raw_grader)))
|
||||
@@ -122,10 +91,9 @@ class CourseGradingModel(object):
|
||||
else:
|
||||
descriptor.raw_grader.append(grader)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
|
||||
@@ -135,16 +103,13 @@ class CourseGradingModel(object):
|
||||
Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra
|
||||
db fetch).
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
descriptor.grade_cutoffs = cutoffs
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
return cutoffs
|
||||
|
||||
@@ -155,8 +120,8 @@ class CourseGradingModel(object):
|
||||
grace_period entry in an enclosing dict. It is also safe to call this method with a value of
|
||||
None for graceperiodjson.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
# Before a graceperiod has ever been created, it will be None (once it has been
|
||||
# created, it cannot be set back to None).
|
||||
@@ -164,81 +129,67 @@ class CourseGradingModel(object):
|
||||
if 'grace_period' in graceperiodjson:
|
||||
graceperiodjson = graceperiodjson['grace_period']
|
||||
|
||||
# lms requires these to be in a fixed order
|
||||
grace_timedelta = timedelta(**graceperiodjson)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.graceperiod = grace_timedelta
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(course_old_location).update_metadata(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_grader(course_location, index):
|
||||
"""
|
||||
Delete the grader of the given type from the given course.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
index = int(index)
|
||||
if index < len(descriptor.raw_grader):
|
||||
del descriptor.raw_grader[index]
|
||||
# force propagation to definition
|
||||
descriptor.raw_grader = descriptor.raw_grader
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
|
||||
get_modulestore(course_old_location).update_item(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_grace_period(course_location):
|
||||
"""
|
||||
Delete the course's default grace period.
|
||||
Delete the course's grace period.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
course_old_location = loc_mapper().translate_locator_to_location(course_location)
|
||||
descriptor = get_modulestore(course_old_location).get_item(course_old_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
del descriptor.graceperiod
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(course_old_location).update_metadata(
|
||||
course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_section_grader_type(location):
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
descriptor = get_modulestore(old_location).get_item(old_location)
|
||||
return {
|
||||
"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
|
||||
"location": unicode(location),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_section_grader_type(location, jsondict):
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
|
||||
descriptor.format = jsondict.get('graderType')
|
||||
def update_section_grader_type(descriptor, grader_type):
|
||||
if grader_type is not None and grader_type != u"Not Graded":
|
||||
descriptor.format = grader_type
|
||||
descriptor.graded = True
|
||||
else:
|
||||
del descriptor.format
|
||||
del descriptor.graded
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(location).update_metadata(location, descriptor._field_data._kvs._metadata)
|
||||
get_modulestore(descriptor.location).update_metadata(
|
||||
descriptor.location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings)
|
||||
)
|
||||
return {'graderType': grader_type}
|
||||
|
||||
@staticmethod
|
||||
def convert_set_grace_period(descriptor):
|
||||
|
||||
@@ -166,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
|
||||
if SEGMENT_IO_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
|
||||
|
||||
|
||||
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"]
|
||||
if AWS_SECRET_ACCESS_KEY == "":
|
||||
AWS_SECRET_ACCESS_KEY = None
|
||||
|
||||
DATABASES = AUTH_TOKENS['DATABASES']
|
||||
MODULESTORE = AUTH_TOKENS['MODULESTORE']
|
||||
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
||||
|
||||
@@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
################################# LMS INTEGRATION #############################
|
||||
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000"
|
||||
LMS_BASE = "localhost:8000"
|
||||
MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
|
||||
@@ -197,7 +197,8 @@ define([
|
||||
"js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_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
|
||||
# isolation issues with Squire.js
|
||||
|
||||
@@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
|
||||
@handoutsEdit.$el.find('.edit-button').click()
|
||||
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)
|
||||
@@ -1,12 +1,9 @@
|
||||
define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
|
||||
define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) ->
|
||||
|
||||
describe "ModuleEdit", ->
|
||||
beforeEach ->
|
||||
@stubModule = jasmine.createSpy("Module")
|
||||
@stubModule.id = 'stub-id'
|
||||
@stubModule.get = (param)->
|
||||
if param == 'old_id'
|
||||
return 'stub-old-id'
|
||||
@stubModule = new ModuleModel
|
||||
id: "stub-id"
|
||||
|
||||
setFixtures """
|
||||
<li class="component" id="stub-id">
|
||||
@@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
|
||||
@moduleEdit.render()
|
||||
|
||||
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]()
|
||||
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
|
||||
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
|
||||
|
||||
@@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base
|
||||
|
||||
appendSetFixtures """
|
||||
<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>
|
||||
</li>
|
||||
</section>
|
||||
|
||||
@@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
payload
|
||||
(data) =>
|
||||
@model.set(id: data.locator)
|
||||
@model.set(old_id: data.id)
|
||||
@$el.data('id', data.id)
|
||||
@$el.data('locator', data.locator)
|
||||
@render()
|
||||
)
|
||||
|
||||
render: ->
|
||||
if @model.get('old_id')
|
||||
@$el.load("/preview_component/#{@model.get('old_id')}", =>
|
||||
if @model.id
|
||||
@$el.load(@model.url(), =>
|
||||
@loadDisplay()
|
||||
@delegateEvents()
|
||||
)
|
||||
|
||||
@@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
initialize: =>
|
||||
@$('.component').each((idx, element) =>
|
||||
model = new ModuleModel({
|
||||
id: $(element).data('locator'),
|
||||
old_id:$(element).data('id')
|
||||
id: $(element).data('locator')
|
||||
})
|
||||
|
||||
new ModuleEditView(
|
||||
@@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
|
||||
analytics.track "Reordered Static Pages",
|
||||
course: course_location_analytics
|
||||
|
||||
saving = new NotificationView.Mini({title: gettext("Saving…")})
|
||||
saving.show()
|
||||
|
||||
$.ajax({
|
||||
type:'POST',
|
||||
url: '/reorder_static_tabs',
|
||||
url: @model.url(),
|
||||
data: JSON.stringify({
|
||||
tabs : tabs
|
||||
}),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
}).success(=> saving.hide())
|
||||
|
||||
addNewTab: (event) =>
|
||||
event.preventDefault()
|
||||
|
||||
@@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@$('.component').each (idx, element) =>
|
||||
model = new ModuleModel
|
||||
id: $(element).data('locator')
|
||||
old_id: $(element).data('id')
|
||||
new ModuleEditView
|
||||
el: element,
|
||||
onDelete: @deleteComponent,
|
||||
@@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@wait(true)
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: @model.urlRoot + "/" + @$el.data('locator') + "?" + $.param({recurse: true})
|
||||
url: @model.url() + "?" + $.param({recurse: true})
|
||||
}).success(=>
|
||||
|
||||
analytics.track "Deleted Draft",
|
||||
@@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
createDraft: (event) ->
|
||||
@wait(true)
|
||||
|
||||
$.postJSON('/create_draft', {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: 'create_draft'
|
||||
}, =>
|
||||
analytics.track "Created Draft",
|
||||
course: course_location_analytics
|
||||
@@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
@wait(true)
|
||||
@saveDraft()
|
||||
|
||||
$.postJSON('/publish_draft', {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: 'make_public'
|
||||
}, =>
|
||||
analytics.track "Published Draft",
|
||||
course: course_location_analytics
|
||||
@@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
|
||||
setVisibility: (event) ->
|
||||
if @$('.visibility-select').val() == 'private'
|
||||
target_url = '/unpublish_unit'
|
||||
action = 'make_private'
|
||||
visibility = "private"
|
||||
else
|
||||
target_url = '/publish_draft'
|
||||
action = 'make_public'
|
||||
visibility = "public"
|
||||
|
||||
@wait(true)
|
||||
|
||||
$.postJSON(target_url, {
|
||||
id: @$el.data('id')
|
||||
$.postJSON(@model.url(), {
|
||||
publish: action
|
||||
}, =>
|
||||
analytics.track "Set Unit Visibility",
|
||||
course: course_location_analytics
|
||||
|
||||
@@ -237,7 +237,7 @@ function createNewUnit(e) {
|
||||
|
||||
function(data) {
|
||||
// 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
|
||||
|
||||
var CourseGraderCollection = Backbone.Collection.extend({
|
||||
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() {
|
||||
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) {
|
||||
define(["backbone", "underscore"], function(Backbone, _) {
|
||||
var AssignmentGrade = Backbone.Model.extend({
|
||||
defaults : {
|
||||
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral
|
||||
location : null // A location object
|
||||
graderType : null, // the type label (string). May be "Not Graded" which implies None.
|
||||
locator : null // locator for the block
|
||||
},
|
||||
initialize : function(attrs) {
|
||||
if (attrs['assignmentUrl']) {
|
||||
this.set('location', new Location(attrs['assignmentUrl'], {parse: true}));
|
||||
}
|
||||
},
|
||||
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 "";
|
||||
idAttribute: 'locator',
|
||||
urlRoot : '/xblock/',
|
||||
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'});
|
||||
}
|
||||
});
|
||||
return AssignmentGrade;
|
||||
|
||||
@@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) {
|
||||
url: '',
|
||||
|
||||
defaults: {
|
||||
"courseId": "", // the location url
|
||||
"updates" : null, // UpdateCollection
|
||||
"handouts": null // HandoutCollection
|
||||
},
|
||||
|
||||
idAttribute : "courseId"
|
||||
}
|
||||
});
|
||||
return CourseInfo;
|
||||
});
|
||||
|
||||
14
cms/static/js/models/explicit_url.js
Normal file
14
cms/static/js/models/explicit_url.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) {
|
||||
define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
|
||||
|
||||
var CourseDetails = Backbone.Model.extend({
|
||||
defaults: {
|
||||
location : null, // the course's Location model, required
|
||||
org : '',
|
||||
course_id: '',
|
||||
run: '',
|
||||
start_date: null, // maps to 'start'
|
||||
end_date: null, // maps to 'end'
|
||||
enrollment_start: null,
|
||||
@@ -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)
|
||||
parse: function(attributes) {
|
||||
if (attributes['course_location']) {
|
||||
attributes.location = new Location(attributes.course_location, {parse:true});
|
||||
}
|
||||
if (attributes['start_date']) {
|
||||
attributes.start_date = new Date(attributes.start_date);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"],
|
||||
|
||||
var CourseGradingPolicy = Backbone.Model.extend({
|
||||
defaults : {
|
||||
course_location : null,
|
||||
graders : null, // CourseGraderCollection
|
||||
grade_cutoffs : null, // CourseGradeCutoff model
|
||||
grace_period : null // either null or { hours: n, minutes: m, ...}
|
||||
},
|
||||
parse: function(attributes) {
|
||||
if (attributes['course_location']) {
|
||||
attributes.course_location = new Location(attributes.course_location, {parse:true});
|
||||
}
|
||||
if (attributes['graders']) {
|
||||
var graderCollection;
|
||||
// interesting race condition: if {parse:true} when newing, then parse called before .attributes created
|
||||
@@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
|
||||
}
|
||||
else {
|
||||
graderCollection = new CourseGraderCollection(attributes.graders, {parse:true});
|
||||
graderCollection.course_location = attributes['course_location'] || this.get('course_location');
|
||||
}
|
||||
attributes.graders = graderCollection;
|
||||
}
|
||||
@@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({
|
||||
}
|
||||
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() {
|
||||
var newDate = new Date();
|
||||
if (this.has('grace_period') && this.get('grace_period')['hours'])
|
||||
|
||||
12
cms/static/js/spec/models/explicit_url_spec.js
Normal file
12
cms/static/js/spec/models/explicit_url_spec.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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"
|
||||
model: this.model
|
||||
}))
|
||||
);
|
||||
$('.handouts-content').html(this.model.get('data'));
|
||||
this.$preview = this.$el.find('.handouts-content');
|
||||
this.$form = this.$el.find(".edit-handouts-form");
|
||||
this.$editor = this.$form.find('.handouts-content-editor');
|
||||
@@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification"
|
||||
},
|
||||
|
||||
onSave: function(event) {
|
||||
this.model.set('data', this.$codeMirror.getValue());
|
||||
var saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
this.$form.hide();
|
||||
this.closeEditor();
|
||||
|
||||
analytics.track('Saved Course Handouts', {
|
||||
'course': course_location_analytics
|
||||
});
|
||||
$('#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());
|
||||
var saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
saving.hide();
|
||||
}
|
||||
});
|
||||
this.render();
|
||||
this.$form.hide();
|
||||
this.closeEditor();
|
||||
|
||||
analytics.track('Saved Course Handouts', {
|
||||
'course': course_location_analytics
|
||||
});
|
||||
}else{
|
||||
$('#handout_error').addClass('is-shown');
|
||||
$('.save-button').addClass('is-disabled');
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
onCancel: function(event) {
|
||||
$('#handout_error').removeClass('is-shown');
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
this.$form.hide();
|
||||
this.closeEditor();
|
||||
},
|
||||
|
||||
closeEditor: function() {
|
||||
$('#handout_error').removeClass('is-shown');
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
this.$form.hide();
|
||||
ModalUtils.hideModalCover();
|
||||
this.$form.find('.CodeMirror').remove();
|
||||
|
||||
@@ -6,7 +6,10 @@ define(["codemirror", "utility"],
|
||||
var $codeMirror = CodeMirror.fromTextArea(textArea, {
|
||||
mode: "text/html",
|
||||
lineNumbers: true,
|
||||
lineWrapping: true
|
||||
lineWrapping: true,
|
||||
onChange: function () {
|
||||
$('.save-button').removeClass('is-disabled');
|
||||
}
|
||||
});
|
||||
$codeMirror.setValue(content);
|
||||
$codeMirror.clearHistory();
|
||||
|
||||
@@ -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>' +
|
||||
'</ul>');
|
||||
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')});
|
||||
// TODO throw exception if graders is null
|
||||
this.graders = this.options['graders'];
|
||||
|
||||
@@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({
|
||||
initialize : function() {
|
||||
this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>');
|
||||
// fill in fields
|
||||
this.$el.find("#course-name").val(this.model.get('location').get('name'));
|
||||
this.$el.find("#course-organization").val(this.model.get('location').get('org'));
|
||||
this.$el.find("#course-number").val(this.model.get('location').get('course'));
|
||||
this.$el.find("#course-organization").val(this.model.get('org'));
|
||||
this.$el.find("#course-number").val(this.model.get('course_id'));
|
||||
this.$el.find("#course-name").val(this.model.get('run'));
|
||||
this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' });
|
||||
|
||||
// Avoid showing broken image on mistyped/nonexistent image
|
||||
|
||||
@@ -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>
|
||||
<div class="modal-body">
|
||||
<h1 class="title">${_("Upload New File")}</h1>
|
||||
<p class="file-name"></a>
|
||||
<p class="file-name">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul
|
||||
var editor = new CourseInfoEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model : new CourseInfoModel({
|
||||
courseId : '${context_course.location}',
|
||||
updates : course_updates,
|
||||
base_asset_url : '${base_asset_url}',
|
||||
handouts : course_handouts
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<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({
|
||||
el: $('.main-wrapper'),
|
||||
model: new Backbone.Model({
|
||||
id: '${locator}'
|
||||
}),
|
||||
model: model,
|
||||
mast: $('.wrapper-mast')
|
||||
});
|
||||
});
|
||||
@@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView)
|
||||
|
||||
<div class="tab-list">
|
||||
<ol class='components'>
|
||||
% for id, locator in components:
|
||||
<li class="component" data-id="${id}" data-locator="${locator}"/>
|
||||
% for locator in components:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
% endfor
|
||||
|
||||
<li class="new-component-item">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="window-contents">
|
||||
<div class="scheduled-date-input row">
|
||||
@@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm
|
||||
// but we really should change that behavior.
|
||||
if (!window.graderTypes) {
|
||||
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
|
||||
window.graderTypes.course_location = new Location('${parent_location}');
|
||||
}
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<h2 class="title">Course Handouts</h2>
|
||||
<%if (model.get('data') != null) { %>
|
||||
<div class="handouts-content">
|
||||
<%= model.get('data') %>
|
||||
|
||||
</div>
|
||||
<% } else {%>
|
||||
<p>${_("You have no handouts defined")}</p>
|
||||
<% } %>
|
||||
<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">
|
||||
<textarea class="handouts-content-editor text-editor"></textarea>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
// but we really should change that behavior.
|
||||
if (!window.graderTypes) {
|
||||
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
|
||||
window.graderTypes.course_location = new Location('${parent_location}');
|
||||
}
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
@@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
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}">
|
||||
|
||||
<%include file="widgets/_ui-dnd-indicator-before.html" />
|
||||
@@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
<div class="section-item">
|
||||
<div class="details">
|
||||
<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="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -69,17 +68,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<ol class="list-input">
|
||||
<li class="field text is-not-editable" id="field-course-organization">
|
||||
<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 class="field text is-not-editable" id="field-course-number">
|
||||
<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 class="field text is-not-editable" id="field-course-name">
|
||||
<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>
|
||||
</ol>
|
||||
|
||||
@@ -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">
|
||||
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
|
||||
<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>
|
||||
|
||||
<ul class="list-actions">
|
||||
<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"${context_course.display_name_with_default}",%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"${context_course.display_name_with_default}",%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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -199,7 +203,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<%def name='overview_text()'><%
|
||||
a_link_start = '<a class="link-courseURL" rel="external" href="'
|
||||
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}</%def>
|
||||
<span class="tip tip-stacked">${overview_text()}</span>
|
||||
@@ -211,15 +215,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<div class="current current-course-image">
|
||||
% if context_course.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>
|
||||
|
||||
<% ctx_loc = context_course.location %>
|
||||
<span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href='${upload_asset_url}'>${_("files & uploads")}</a></span>
|
||||
<span class="msg msg-help">
|
||||
${_("You can manage this image along with all of your other <a href='{}'>files & uploads</a>").format(upload_asset_url)}
|
||||
</span>
|
||||
|
||||
% else:
|
||||
<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 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
|
||||
@@ -286,14 +291,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<div class="bit">
|
||||
% 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
|
||||
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>
|
||||
<nav class="nav-related">
|
||||
<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="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -96,8 +96,8 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<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 & 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/details/')}">${_("Details & Schedule")}</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>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -28,9 +28,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
|
||||
$("label").removeClass("is-focused");
|
||||
});
|
||||
|
||||
var model = new CourseGradingPolicyModel(${course_details|n},{parse:true});
|
||||
model.urlRoot = '${grading_url}';
|
||||
var editor = new GradingView({
|
||||
el: $('.settings-grading'),
|
||||
model : new CourseGradingPolicyModel(${course_details|n},{parse:true})
|
||||
model : model
|
||||
});
|
||||
|
||||
editor.render();
|
||||
@@ -138,13 +140,12 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
|
||||
% if context_course:
|
||||
<%
|
||||
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/', '')
|
||||
course_team_url = course_locator.url_reverse('course_team/')
|
||||
%>
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<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 & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${course_locator.url_reverse('settings/details/')}">${_("Details & Schedule")}</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>
|
||||
</ul>
|
||||
|
||||
@@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
|
||||
</%block>
|
||||
<%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="alert editing-draft-alert">
|
||||
<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"
|
||||
<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">
|
||||
% for id, locator in components:
|
||||
<li class="component" data-id="${id}" data-locator="${locator}"/>
|
||||
<li class="component" data-locator="${locator}" data-id="${id}" />
|
||||
% endfor
|
||||
<li class="new-component-item adding">
|
||||
<div class="new-component">
|
||||
@@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
</article>
|
||||
</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="unit-settings window">
|
||||
<h4 class="header">${_("Unit Settings")}</h4>
|
||||
@@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
% endif
|
||||
${_("with the subsection {link_start}{name}{link_end}").format(
|
||||
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>',
|
||||
)}
|
||||
</p>
|
||||
@@ -180,14 +187,10 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
</div>
|
||||
<ol>
|
||||
<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>
|
||||
<ol>
|
||||
<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="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
index_url = location.url_reverse('course/')
|
||||
checklists_url = location.url_reverse('checklists/')
|
||||
course_team_url = location.url_reverse('course_team/')
|
||||
assets_url = location.url_reverse('assets/')
|
||||
import_url = location.url_reverse('import/')
|
||||
course_info_url = location.url_reverse('course_info/')
|
||||
export_url = location.url_reverse('export/', '')
|
||||
index_url = location.url_reverse('course')
|
||||
checklists_url = location.url_reverse('checklists')
|
||||
course_team_url = location.url_reverse('course_team')
|
||||
assets_url = location.url_reverse('assets')
|
||||
import_url = location.url_reverse('import')
|
||||
course_info_url = location.url_reverse('course_info')
|
||||
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">
|
||||
<span class="sr">${_("Current Course:")}</span>
|
||||
@@ -48,7 +51,7 @@
|
||||
<a href="${course_info_url}">${_("Updates")}</a>
|
||||
</li>
|
||||
<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 class="nav-item nav-course-courseware-uploads">
|
||||
<a href="${assets_url}">${_("Files & Uploads")}</a>
|
||||
@@ -68,10 +71,10 @@
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<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 & Details")}</a>
|
||||
<a href="${settings_url}">${_("Schedule & Details")}</a>
|
||||
</li>
|
||||
<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 class="nav-item nav-course-settings-team">
|
||||
<a href="${course_team_url}">${_("Course Team")}</a>
|
||||
|
||||
@@ -75,7 +75,9 @@
|
||||
<img src="${static.url("img/string-example.png")}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>= dog</code></pre>
|
||||
<pre><code>= dog
|
||||
or= cat
|
||||
or= mouse</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<%!
|
||||
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'):
|
||||
<!-- begin Segment.io -->
|
||||
<script type="text/javascript">
|
||||
// if inside course, inject the course location into the JS namespace
|
||||
%if context_course:
|
||||
var course_location_analytics = "${context_course.location}";
|
||||
var course_location_analytics = "${locator}";
|
||||
%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])};
|
||||
@@ -22,7 +33,7 @@
|
||||
<!-- dummy segment.io -->
|
||||
<script type="text/javascript">
|
||||
%if context_course:
|
||||
var course_location_analytics = "${context_course.location}";
|
||||
var course_location_analytics = "${locator}";
|
||||
%endif
|
||||
var analytics = {
|
||||
"track": function() {}
|
||||
|
||||
@@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
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-name">${unit.display_name_with_default}</span>
|
||||
</a>
|
||||
|
||||
39
cms/urls.py
39
cms/urls.py
@@ -11,10 +11,6 @@ from ratelimitbackend import admin
|
||||
admin.autodiscover()
|
||||
|
||||
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/download$', 'contentstore.views.download_transcripts', name='download_transcripts'),
|
||||
@@ -24,22 +20,9 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_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>[^/]*))?$',
|
||||
'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.
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$',
|
||||
'contentstore.views.course_config_advanced_page', name='course_advanced_settings'),
|
||||
@@ -47,12 +30,6 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$',
|
||||
'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>[^/]+)$',
|
||||
'contentstore.views.textbook_index', name='textbook_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$',
|
||||
@@ -79,18 +56,12 @@ urlpatterns = patterns('', # nopep8
|
||||
# User creation and updating views
|
||||
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'^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
|
||||
url(r'^login_post$', 'student.views.login_user', name='login_post'),
|
||||
|
||||
url(r'^logout$', 'student.views.logout_user', name='logout'),
|
||||
)
|
||||
|
||||
@@ -98,7 +69,12 @@ urlpatterns += patterns(
|
||||
urlpatterns += patterns(
|
||||
'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'),
|
||||
|
||||
# (?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_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'),
|
||||
@@ -107,6 +83,8 @@ urlpatterns += patterns(
|
||||
'course_info_update_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)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
|
||||
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
|
||||
@@ -114,6 +92,9 @@ urlpatterns += patterns(
|
||||
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)^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 = {
|
||||
|
||||
@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase):
|
||||
self.user_bad_passwd.password = UNUSABLE_PASSWORD
|
||||
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):
|
||||
"""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_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.content, json.dumps({'success': False,
|
||||
'error': 'Invalid e-mail or user'}))
|
||||
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True,
|
||||
'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):
|
||||
"""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_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.content, json.dumps({'success': False,
|
||||
'error': 'Invalid e-mail or user'}))
|
||||
self.assertEquals(bad_email_resp.content, json.dumps({'success': True,
|
||||
'value': "('registration/password_reset_done.html', [])"}))
|
||||
|
||||
@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.
|
||||
@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase):
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False, })
|
||||
'show_survey_button': False,
|
||||
})
|
||||
|
||||
cert_status = {'status': 'unavailable'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False})
|
||||
|
||||
cert_status = {'status': 'generating', 'grade': '67'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'show_survey_button': False,
|
||||
'mode': None
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', 'grade': '67'}
|
||||
cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67',
|
||||
'mode': 'verified'
|
||||
})
|
||||
|
||||
download_url = 'http://s3.edx/cert'
|
||||
cert_status = {'status': 'downloadable', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'ready',
|
||||
'show_disabled_download_button': False,
|
||||
@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase):
|
||||
'download_url': download_url,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
# Test a course that doesn't have a survey specified
|
||||
course2 = Mock(end_of_course_survey_url=None)
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
'download_url': download_url, 'mode': 'honor'}
|
||||
self.assertEqual(_cert_info(user, course2, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False,
|
||||
'grade': '67'
|
||||
'grade': '67',
|
||||
'mode': 'honor'
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ def _cert_info(user, course, cert_status):
|
||||
default_info = {'status': default_status,
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False}
|
||||
'show_survey_button': False,
|
||||
}
|
||||
|
||||
if cert_status is None:
|
||||
return default_info
|
||||
@@ -203,7 +204,8 @@ def _cert_info(user, course, cert_status):
|
||||
|
||||
d = {'status': status,
|
||||
'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
|
||||
course.end_of_course_survey_url is not None):
|
||||
@@ -296,7 +298,7 @@ def complete_course_mode_info(course_id, enrollment):
|
||||
def dashboard(request):
|
||||
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
|
||||
# enrollments, because it could have been a data push snafu.
|
||||
course_enrollment_pairs = []
|
||||
@@ -1229,11 +1231,8 @@ def password_reset(request):
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
request=request,
|
||||
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', {})}))
|
||||
else:
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'error': _('Invalid e-mail or user')}))
|
||||
|
||||
|
||||
def password_reset_confirm_wrapper(
|
||||
@@ -1515,4 +1514,4 @@ def change_email_settings(request):
|
||||
log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id))
|
||||
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
@@ -946,17 +946,34 @@ class NumericalResponse(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'
|
||||
hint_tag = 'stringhint'
|
||||
allowed_inputfields = ['textline']
|
||||
required_attributes = ['answer']
|
||||
max_inputfields = 1
|
||||
correct_answer = None
|
||||
correct_answer = []
|
||||
SEPARATOR = '_or_'
|
||||
|
||||
def setup_response(self):
|
||||
self.correct_answer = contextualize_text(
|
||||
self.xml.get('answer'), self.context).strip()
|
||||
self.correct_answer = [contextualize_text(answer, self.context).strip()
|
||||
for answer in self.xml.get('answer').split(self.SEPARATOR)]
|
||||
|
||||
def get_score(self, student_answers):
|
||||
'''Grade a string response '''
|
||||
@@ -966,23 +983,25 @@ class StringResponse(LoncapaResponse):
|
||||
|
||||
def check_string(self, expected, given):
|
||||
if self.xml.get('type') == 'ci':
|
||||
return given.lower() == expected.lower()
|
||||
return given == expected
|
||||
return given.lower() in [i.lower() for i in expected]
|
||||
return given in expected
|
||||
|
||||
def check_hint_condition(self, hxml_set, student_answers):
|
||||
given = student_answers[self.answer_id].strip()
|
||||
hints_to_show = []
|
||||
for hxml in hxml_set:
|
||||
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):
|
||||
hints_to_show.append(name)
|
||||
log.debug('hints_to_show = %s', hints_to_show)
|
||||
return hints_to_show
|
||||
|
||||
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 @@
|
||||
% else:
|
||||
<% my_id = content_node.get('contents','') %>
|
||||
<% 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
|
||||
<span class="mock_label">
|
||||
${content_node['tail_text']}
|
||||
|
||||
@@ -500,6 +500,7 @@ class StringResponseTest(ResponseTest):
|
||||
xml_factory_class = StringResponseXMLFactory
|
||||
|
||||
def test_case_sensitive(self):
|
||||
# Test single answer
|
||||
problem = self.build_problem(answer="Second", case_sensitive=True)
|
||||
|
||||
# Exact string should be correct
|
||||
@@ -509,7 +510,20 @@ class StringResponseTest(ResponseTest):
|
||||
self.assert_grade(problem, "Other String", "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):
|
||||
# Test single answer
|
||||
problem = self.build_problem(answer="Second", case_sensitive=False)
|
||||
|
||||
# Both versions of the string should be allowed, regardless
|
||||
@@ -520,9 +534,28 @@ class StringResponseTest(ResponseTest):
|
||||
# Other strings are not allowed
|
||||
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):
|
||||
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"),
|
||||
("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",
|
||||
case_sensitive=False,
|
||||
@@ -550,6 +583,14 @@ class StringResponseTest(ResponseTest):
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
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):
|
||||
problem = self.build_problem(
|
||||
answer="Michigan",
|
||||
|
||||
@@ -597,6 +597,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
@property
|
||||
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']
|
||||
|
||||
@raw_grader.setter
|
||||
|
||||
@@ -726,21 +726,24 @@ section.problem {
|
||||
}
|
||||
|
||||
a.full {
|
||||
@include position(absolute, 0 0 1px 0);
|
||||
@include position(absolute, 0 0px 1px 0px);
|
||||
@include box-sizing(border-box);
|
||||
display: block;
|
||||
padding: 4px;
|
||||
width: 100%;
|
||||
background: #f3f3f3;
|
||||
text-align: right;
|
||||
font-size: .8em;
|
||||
font-size: 1em;
|
||||
|
||||
&.full-top{
|
||||
@include position(absolute, 1px 0px auto 0px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.external-grader-message {
|
||||
section {
|
||||
padding-top: $baseline/2;
|
||||
padding-top: ($baseline*1.5);
|
||||
padding-left: $baseline;
|
||||
background-color: #fafafa;
|
||||
color: #2c2c2c;
|
||||
|
||||
115
common/lib/xmodule/xmodule/js/spec/collapsible.coffee
Normal file
115
common/lib/xmodule/xmodule/js/spec/collapsible.coffee
Normal file
@@ -0,0 +1,115 @@
|
||||
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')
|
||||
@@ -104,45 +104,45 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
|
||||
Enter the number of fingers on a human hand:
|
||||
= 5
|
||||
|
||||
|
||||
[Explanation]
|
||||
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
|
||||
|
||||
|
||||
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
|
||||
|
||||
|
||||
If you look at your hand, you can count that you have five fingers.
|
||||
[Explanation]
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p>
|
||||
|
||||
|
||||
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
|
||||
|
||||
|
||||
<p>Enter the numerical value of Pi:</p>
|
||||
<numericalresponse answer="3.14159">
|
||||
<responseparam type="tolerance" default=".02" />
|
||||
<formulaequationinput />
|
||||
</numericalresponse>
|
||||
|
||||
|
||||
<p>Enter the approximate value of 502*9:</p>
|
||||
<numericalresponse answer="4518">
|
||||
<responseparam type="tolerance" default="15%" />
|
||||
<formulaequationinput />
|
||||
</numericalresponse>
|
||||
|
||||
|
||||
<p>Enter the number of fingers on a human hand:</p>
|
||||
<numericalresponse answer="5">
|
||||
<formulaequationinput />
|
||||
</numericalresponse>
|
||||
|
||||
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
|
||||
|
||||
<p>Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.</p>
|
||||
|
||||
|
||||
<p>Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.</p>
|
||||
|
||||
|
||||
<p>If you look at your hand, you can count that you have five fingers.</p>
|
||||
|
||||
</div>
|
||||
@@ -161,12 +161,27 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
</numericalresponse>
|
||||
|
||||
|
||||
</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', ->
|
||||
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.
|
||||
|
||||
|
||||
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
|
||||
|
||||
|
||||
What Apple device competed with the portable CD player?
|
||||
( ) The iPad
|
||||
( ) Napster
|
||||
@@ -174,16 +189,16 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
( ) The vegetable peeler
|
||||
( ) Android
|
||||
( ) The Beatles
|
||||
|
||||
|
||||
[Explanation]
|
||||
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
|
||||
[Explanation]
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>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.</p>
|
||||
|
||||
|
||||
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
|
||||
|
||||
|
||||
<p>What Apple device competed with the portable CD player?</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
@@ -195,76 +210,102 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
<choice correct="false">The Beatles</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
|
||||
|
||||
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
</problem>""")
|
||||
it 'converts OptionResponse to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
|
||||
|
||||
|
||||
The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.
|
||||
|
||||
|
||||
Translation between Option Response and __________ is extremely straightforward:
|
||||
[[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]]
|
||||
|
||||
|
||||
[Explanation]
|
||||
Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
|
||||
[Explanation]
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.</p>
|
||||
|
||||
|
||||
<p>The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.</p>
|
||||
|
||||
|
||||
<p>Translation between Option Response and __________ is extremely straightforward:</p>
|
||||
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('Multiple Choice','String Response','Numerical Response','External Response','Image Response')" correct="Multiple Choice"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
|
||||
|
||||
<p>Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.</p>
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
</problem>""")
|
||||
it 'converts StringResponse to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.
|
||||
|
||||
|
||||
The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
|
||||
|
||||
|
||||
Which US state has Lansing as its capital?
|
||||
= Michigan
|
||||
|
||||
|
||||
[Explanation]
|
||||
Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.
|
||||
[Explanation]
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.</p>
|
||||
|
||||
|
||||
<p>The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.</p>
|
||||
|
||||
|
||||
<p>Which US state has Lansing as its capital?</p>
|
||||
<stringresponse answer="Michigan" type="ci">
|
||||
<textline size="20"/>
|
||||
</stringresponse>
|
||||
|
||||
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
|
||||
|
||||
<p>Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.</p>
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
</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>""")
|
||||
@@ -273,26 +314,26 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""Not a header
|
||||
A header
|
||||
==============
|
||||
|
||||
|
||||
Multiple choice w/ parentheticals
|
||||
( ) option (with parens)
|
||||
( ) xd option (x)
|
||||
()) parentheses inside
|
||||
() no space b4 close paren
|
||||
|
||||
|
||||
Choice checks
|
||||
[ ] option1 [x]
|
||||
[x] correct
|
||||
[x] redundant
|
||||
[(] distractor
|
||||
[] no space
|
||||
|
||||
|
||||
Option with multiple correct ones
|
||||
[[one option, (correct one), (should not be correct)]]
|
||||
|
||||
|
||||
Option with embedded parens
|
||||
[[My (heart), another, (correct)]]
|
||||
|
||||
|
||||
What happens w/ empty correct options?
|
||||
[[()]]
|
||||
|
||||
@@ -300,21 +341,21 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
|
||||
[explanation]
|
||||
orphaned start
|
||||
|
||||
|
||||
No p tags in the below
|
||||
<script type='javascript'>
|
||||
var two = 2;
|
||||
|
||||
console.log(two * 2);
|
||||
</script>
|
||||
|
||||
|
||||
But in this there should be
|
||||
<div>
|
||||
Great ideas require offsetting.
|
||||
|
||||
|
||||
bad tests require drivel
|
||||
</div>
|
||||
|
||||
|
||||
[code]
|
||||
Code should be nicely monospaced.
|
||||
[/code]
|
||||
@@ -322,7 +363,7 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>Not a header</p>
|
||||
<h1>A header</h1>
|
||||
|
||||
|
||||
<p>Multiple choice w/ parentheticals</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
@@ -332,7 +373,7 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
<choice correct="false">no space b4 close paren</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
|
||||
<p>Choice checks</p>
|
||||
<choiceresponse>
|
||||
<checkboxgroup direction="vertical">
|
||||
@@ -343,25 +384,25 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
<choice correct="false">no space</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
|
||||
|
||||
<p>Option with multiple correct ones</p>
|
||||
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('one option','correct one','should not be correct')" correct="correct one"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
|
||||
<p>Option with embedded parens</p>
|
||||
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('My (heart)','another','correct')" correct="correct"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
|
||||
<p>What happens w/ empty correct options?</p>
|
||||
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('')" correct=""></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
|
||||
<solution>
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
@@ -379,14 +420,14 @@ describe 'MarkdownEditingDescriptor', ->
|
||||
|
||||
console.log(two * 2);
|
||||
</script>
|
||||
|
||||
|
||||
<p>But in this there should be</p>
|
||||
<div>
|
||||
<p>Great ideas require offsetting.</p>
|
||||
|
||||
|
||||
<p>bad tests require drivel</p>
|
||||
</div>
|
||||
|
||||
|
||||
<pre><code>
|
||||
Code should be nicely monospaced.
|
||||
</code></pre>
|
||||
|
||||
@@ -271,7 +271,17 @@
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
beforeEach(function () {
|
||||
|
||||
@@ -457,7 +457,17 @@
|
||||
});
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -538,7 +548,17 @@
|
||||
});
|
||||
|
||||
// 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 () {
|
||||
initialize();
|
||||
});
|
||||
@@ -683,7 +703,17 @@
|
||||
});
|
||||
|
||||
// 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
|
||||
// bit.
|
||||
jasmine.Clock.tick(1000);
|
||||
|
||||
@@ -72,7 +72,17 @@
|
||||
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 () {
|
||||
// Captions should not be "sticky" for the autohide mechanism
|
||||
// to work.
|
||||
|
||||
@@ -4,11 +4,21 @@
|
||||
videoProgressSlider, videoSpeedControl, videoVolumeControl,
|
||||
oldOTBD;
|
||||
|
||||
function initialize(fixture) {
|
||||
if (typeof fixture === 'undefined') {
|
||||
loadFixtures('video_all.html');
|
||||
} else {
|
||||
function initialize(fixture, params) {
|
||||
if (_.isString(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');
|
||||
@@ -532,8 +542,54 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Disabled 10/24/13 due to flakiness in master
|
||||
xdescribe('updatePlayTime', function () {
|
||||
describe('update with start & end time', 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 () {
|
||||
initialize();
|
||||
|
||||
@@ -548,7 +604,7 @@
|
||||
duration = videoPlayer.duration();
|
||||
|
||||
if (duration > 0) {
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -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('when the video player is not full screen', function () {
|
||||
beforeEach(function () {
|
||||
|
||||
@@ -154,7 +154,17 @@
|
||||
});
|
||||
|
||||
// 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 () {
|
||||
videoProgressSlider.onSlide(
|
||||
jQuery.Event('slide'), { value: 20 }
|
||||
@@ -220,7 +230,17 @@
|
||||
});
|
||||
|
||||
// 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 () {
|
||||
videoProgressSlider.onStop(
|
||||
jQuery.Event('stop'), { value: 20 }
|
||||
@@ -285,6 +305,55 @@
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class @Collapsible
|
||||
|
||||
# Set of library functions that provide a simple way to add collapsible
|
||||
# functionality to elements.
|
||||
# functionality to elements.
|
||||
|
||||
# setCollapsibles:
|
||||
# Scan element's content for generic collapsible containers
|
||||
@@ -9,12 +9,15 @@ class @Collapsible
|
||||
###
|
||||
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
|
||||
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
|
||||
short_custom = el.find('.shortform-custom')
|
||||
short_custom = el.find('.shortform-custom')
|
||||
# set up each one individually
|
||||
short_custom.each (index, elt) =>
|
||||
open_text = $(elt).data('open-text')
|
||||
@@ -31,13 +34,18 @@ class @Collapsible
|
||||
|
||||
@toggleFull: (event, open_text, close_text) =>
|
||||
event.preventDefault()
|
||||
$(event.target).parent().siblings().slideToggle()
|
||||
$(event.target).parent().parent().toggleClass('open')
|
||||
parent = $(event.target).parent()
|
||||
parent.siblings().slideToggle()
|
||||
parent.parent().toggleClass('open')
|
||||
if $(event.target).text() == open_text
|
||||
new_text = close_text
|
||||
else
|
||||
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) =>
|
||||
event.preventDefault()
|
||||
|
||||
@@ -228,11 +228,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
});
|
||||
|
||||
// replace string and numerical
|
||||
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) {
|
||||
var string;
|
||||
var floatValue = parseFloat(p);
|
||||
xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) {
|
||||
var string,
|
||||
answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'),
|
||||
floatValue = parseFloat(answersList[0]);
|
||||
|
||||
if(!isNaN(floatValue)) {
|
||||
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
|
||||
var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]);
|
||||
if(params) {
|
||||
string = '<numericalresponse answer="' + floatValue + '">\n';
|
||||
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
|
||||
@@ -242,10 +244,16 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
string += ' <formulaequationinput />\n';
|
||||
string += '</numericalresponse>\n\n';
|
||||
} 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;
|
||||
});
|
||||
});
|
||||
|
||||
// replace selects
|
||||
xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) {
|
||||
@@ -262,13 +270,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
selectString += '</optionresponse>\n\n';
|
||||
return selectString;
|
||||
});
|
||||
|
||||
|
||||
// replace explanations
|
||||
xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) {
|
||||
var selectString = '<solution>\n<div class="detailed-solution">\nExplanation\n\n' + p1 + '\n</div>\n</solution>';
|
||||
return selectString;
|
||||
});
|
||||
|
||||
|
||||
// replace code blocks
|
||||
xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) {
|
||||
var selectString = '<pre><code>\n' + p1 + '</code></pre>';
|
||||
@@ -293,7 +301,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
|
||||
// rid white space
|
||||
xml = xml.replace(/\n\n\n/g, '\n');
|
||||
|
||||
|
||||
// surround w/ problem tag
|
||||
xml = '<problem>\n' + xml + '\n</problem>';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// VideoPlayer module.
|
||||
define(
|
||||
'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) {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) {
|
||||
|
||||
state.videoPlayer.initialSeekToStartTime = true;
|
||||
|
||||
state.videoPlayer.oneTimePauseAtEndTime = true;
|
||||
|
||||
// The initial value of the variable `seekToStartTimeOldSpeed`
|
||||
// should always differ from the value returned by the duration
|
||||
// function.
|
||||
// At the start, the initial value of the variable
|
||||
// `seekToStartTimeOldSpeed` should always differ from the value
|
||||
// returned by the duration function.
|
||||
state.videoPlayer.seekToStartTimeOldSpeed = 'void';
|
||||
|
||||
state.videoPlayer.playerVars = {
|
||||
@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) {
|
||||
|
||||
// This function gets the video's current play position in time
|
||||
// (currentTime) and its duration.
|
||||
// It is called at a regular interval when the video is playing (see
|
||||
// below).
|
||||
// It is called at a regular interval when the video is playing.
|
||||
function update() {
|
||||
this.videoPlayer.currentTime = this.videoPlayer.player
|
||||
.getCurrentTime();
|
||||
@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) {
|
||||
if (isFinite(this.videoPlayer.currentTime)) {
|
||||
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
|
||||
|
||||
// We need to pause the video is current time is smaller (or equal)
|
||||
// than end time. Also, we must make sure that the end time is the
|
||||
// one that was set in the configuration parameter. If it differs,
|
||||
// this means that it was either reset to the end, or the duration
|
||||
// changed it's value.
|
||||
// We need to pause the video if current time is smaller (or equal)
|
||||
// than end time. Also, we must make sure that this is only done
|
||||
// once.
|
||||
//
|
||||
// In the case of YouTube Flash mode, we must remember that the
|
||||
// start and end times are rescaled based on the current speed of
|
||||
// the video.
|
||||
// If `endTime` is not `null`, then we are safe to pause the
|
||||
// video. `endTime` will be set to `null`, and this if statement
|
||||
// will not be executed on next runs.
|
||||
if (
|
||||
this.videoPlayer.endTime <= this.videoPlayer.currentTime &&
|
||||
this.videoPlayer.oneTimePauseAtEndTime
|
||||
this.videoPlayer.endTime != null &&
|
||||
this.videoPlayer.endTime <= this.videoPlayer.currentTime
|
||||
) {
|
||||
this.videoPlayer.oneTimePauseAtEndTime = false;
|
||||
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) {
|
||||
}
|
||||
);
|
||||
|
||||
// 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.endTime = duration;
|
||||
this.videoPlayer.endTime = null;
|
||||
|
||||
this.videoPlayer.player.seekTo(newTime, true);
|
||||
|
||||
@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) {
|
||||
var time = this.videoPlayer.duration();
|
||||
|
||||
this.trigger('videoControl.pause', null);
|
||||
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
|
||||
end: true
|
||||
});
|
||||
|
||||
if (this.config.show_captions) {
|
||||
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
|
||||
// `duration`. In this case, slider doesn't reach the end point of
|
||||
// timeline.
|
||||
@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) {
|
||||
|
||||
this.trigger('videoControl.play', null);
|
||||
|
||||
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
|
||||
end: false
|
||||
});
|
||||
|
||||
if (this.config.show_captions) {
|
||||
this.trigger('videoCaption.play', null);
|
||||
}
|
||||
@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) {
|
||||
|
||||
function updatePlayTime(time) {
|
||||
var duration = this.videoPlayer.duration(),
|
||||
durationChange;
|
||||
durationChange, tempStartTime, tempEndTime;
|
||||
|
||||
if (
|
||||
duration > 0 &&
|
||||
@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) {
|
||||
this.videoPlayer.initialSeekToStartTime === false
|
||||
) {
|
||||
durationChange = true;
|
||||
} else {
|
||||
} else { // this.videoPlayer.initialSeekToStartTime === true
|
||||
this.videoPlayer.initialSeekToStartTime = false;
|
||||
|
||||
durationChange = false;
|
||||
}
|
||||
|
||||
this.videoPlayer.initialSeekToStartTime = false;
|
||||
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
|
||||
// to the fact of speed change (duration change). This happens when
|
||||
// in YouTube Flash mode. There each speed is a different video,
|
||||
@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) {
|
||||
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 (
|
||||
this.videoPlayer.endTime === null ||
|
||||
this.videoPlayer.endTime !== null &&
|
||||
this.videoPlayer.endTime > duration
|
||||
) {
|
||||
this.videoPlayer.endTime = duration;
|
||||
} else {
|
||||
this.videoPlayer.endTime = null;
|
||||
} else if (this.videoPlayer.endTime !== null) {
|
||||
if (this.currentPlayerMode === 'flash') {
|
||||
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
|
||||
// whole slider).
|
||||
// whole slider). Remember that endTime === null means the end time
|
||||
// is set to the end of video by default.
|
||||
if (!(
|
||||
this.videoPlayer.startTime === 0 &&
|
||||
this.videoPlayer.endTime === duration
|
||||
this.videoPlayer.endTime === null
|
||||
)) {
|
||||
this.trigger(
|
||||
'videoProgressSlider.updateStartEndTimeRegion',
|
||||
@@ -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(
|
||||
|
||||
@@ -41,7 +41,8 @@ function () {
|
||||
onSlide: onSlide,
|
||||
onStop: onStop,
|
||||
updatePlayTime: updatePlayTime,
|
||||
updateStartEndTimeRegion: updateStartEndTimeRegion
|
||||
updateStartEndTimeRegion: updateStartEndTimeRegion,
|
||||
notifyThroughHandleEnd: notifyThroughHandleEnd
|
||||
};
|
||||
|
||||
state.bindTo(methodsDict, state.videoProgressSlider, state);
|
||||
@@ -111,11 +112,6 @@ function () {
|
||||
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;
|
||||
|
||||
// If end is set to null, then we set it to the end of the video. We
|
||||
@@ -199,8 +195,6 @@ function () {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera
|
||||
// noticed
|
||||
function updatePlayTime(params) {
|
||||
var time = Math.floor(params.time),
|
||||
duration = Math.floor(params.duration);
|
||||
@@ -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
|
||||
// format.
|
||||
function getTimeDescription(time) {
|
||||
|
||||
@@ -204,21 +204,16 @@ class LocMapperStore(object):
|
||||
self._decode_from_mongo(old_name),
|
||||
None)
|
||||
elif usage_id == locator.usage_id:
|
||||
# figure out revision
|
||||
# enforce the draft only if category in [..] logic
|
||||
if category in draft.DIRECT_ONLY_CATEGORIES:
|
||||
revision = None
|
||||
elif locator.branch == candidate['draft_branch']:
|
||||
revision = draft.DRAFT
|
||||
else:
|
||||
revision = None
|
||||
# Always return revision=None because the
|
||||
# old draft module store wraps locations as draft before
|
||||
# trying to access things.
|
||||
return Location(
|
||||
'i4x',
|
||||
candidate['_id']['org'],
|
||||
candidate['_id']['course'],
|
||||
category,
|
||||
self._decode_from_mongo(old_name),
|
||||
revision)
|
||||
None)
|
||||
return None
|
||||
|
||||
def add_block_location_translator(self, location, old_course_id=None, usage_id=None):
|
||||
|
||||
@@ -778,11 +778,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
children: A list of child item identifiers
|
||||
"""
|
||||
|
||||
# We expect the children IDs to always be the non-draft version. With view refactoring
|
||||
# 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})
|
||||
self._update_single_item(location, {'definition.children': children})
|
||||
# recompute (and update) the metadata inheritance tree which is cached
|
||||
self.refresh_cached_metadata_inheritance_tree(Location(location))
|
||||
# fire signal that we've written to DB
|
||||
|
||||
@@ -81,7 +81,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
try:
|
||||
return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth))
|
||||
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):
|
||||
"""
|
||||
@@ -169,7 +169,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
try:
|
||||
draft_item = self.get_item(location)
|
||||
if not getattr(draft_item, 'is_draft', False):
|
||||
self.convert_to_draft(as_published(location))
|
||||
self.convert_to_draft(location)
|
||||
except ItemNotFoundError, e:
|
||||
if not allow_not_found:
|
||||
raise e
|
||||
@@ -187,7 +187,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
draft_loc = as_draft(location)
|
||||
draft_item = self.get_item(location)
|
||||
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)
|
||||
|
||||
@@ -203,7 +203,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
draft_item = self.get_item(location)
|
||||
|
||||
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:
|
||||
del metadata['is_draft']
|
||||
@@ -262,7 +262,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
"""
|
||||
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)
|
||||
|
||||
def _query_children_for_cache_children(self, items):
|
||||
|
||||
@@ -88,7 +88,7 @@ class SplitMigrator(object):
|
||||
index_info = self.split_modulestore.get_course_index_info(course_version_locator)
|
||||
versions = index_info['versions']
|
||||
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
|
||||
# children which meant some pointers were to non-existent locations in 'direct'
|
||||
|
||||
@@ -22,5 +22,4 @@ class DefinitionLazyLoader(object):
|
||||
Fetch the definition. Note, the caller should replace this lazy
|
||||
loader pointer with the result so as not to fetch more than once
|
||||
"""
|
||||
return self.modulestore.definitions.find_one(
|
||||
{'_id': self.definition_locator.definition_id})
|
||||
return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import threading
|
||||
import datetime
|
||||
import logging
|
||||
import pymongo
|
||||
import re
|
||||
from importlib import import_module
|
||||
from path import path
|
||||
@@ -21,6 +20,7 @@ from .caching_descriptor_system import CachingDescriptorSystem
|
||||
from xblock.fields import Scope
|
||||
from xblock.runtime import Mixologist
|
||||
from bson.objectid import ObjectId
|
||||
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
#==============================================================================
|
||||
@@ -49,7 +49,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
A Mongodb backed ModuleStore supporting versions, inheritance,
|
||||
and sharing.
|
||||
"""
|
||||
# pylint: disable=W0201
|
||||
def __init__(self, doc_store_config, fs_root, render_template,
|
||||
default_class=None,
|
||||
error_tracker=null_error_tracker,
|
||||
@@ -62,44 +61,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
super(SplitMongoModuleStore, self).__init__(**kwargs)
|
||||
self.loc_mapper = loc_mapper
|
||||
|
||||
def do_connection(
|
||||
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.db = 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.db.authenticate(user, password)
|
||||
|
||||
self.course_index = self.db[collection + '.active_versions']
|
||||
self.structures = self.db[collection + '.structures']
|
||||
self.definitions = self.db[collection + '.definitions']
|
||||
|
||||
do_connection(**doc_store_config)
|
||||
self.db_connection = MongoConnection(**doc_store_config)
|
||||
self.db = self.db_connection.database
|
||||
|
||||
# Code review question: How should I expire entries?
|
||||
# _add_cache could use a lru mechanism to control the cache size?
|
||||
self.thread_cache = threading.local()
|
||||
|
||||
|
||||
# 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}
|
||||
|
||||
if default_class is not None:
|
||||
module_path, _, class_name = default_class.rpartition('.')
|
||||
class_ = getattr(import_module(module_path), class_name)
|
||||
@@ -138,7 +106,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
block['definition'] = DefinitionLazyLoader(self, block['definition'])
|
||||
else:
|
||||
# Load all descendants by id
|
||||
descendent_definitions = self.definitions.find({
|
||||
descendent_definitions = self.db_connection.find_matching_definitions({
|
||||
'_id': {'$in': [block['definition']
|
||||
for block in new_module_data.itervalues()]}})
|
||||
# turn into a map
|
||||
@@ -226,7 +194,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
if course_locator.course_id is not None and course_locator.branch is not None:
|
||||
# use the course_id
|
||||
index = self.course_index.find_one({'_id': course_locator.course_id})
|
||||
index = self.db_connection.get_course_index(course_locator.course_id)
|
||||
if index is None:
|
||||
raise ItemNotFoundError(course_locator)
|
||||
if course_locator.branch not in index['versions']:
|
||||
@@ -241,7 +209,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
# cast string to ObjectId if necessary
|
||||
version_guid = course_locator.as_object_id(version_guid)
|
||||
entry = self.structures.find_one({'_id': version_guid})
|
||||
entry = self.db_connection.get_structure(version_guid)
|
||||
|
||||
# b/c more than one course can use same structure, the 'course_id' and 'branch' are not intrinsic to structure
|
||||
# and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so,
|
||||
@@ -269,7 +237,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
if qualifiers is None:
|
||||
qualifiers = {}
|
||||
qualifiers.update({"versions.{}".format(branch): {"$exists": True}})
|
||||
matching = self.course_index.find(qualifiers)
|
||||
matching = self.db_connection.find_matching_course_indexes(qualifiers)
|
||||
|
||||
# collect ids and then query for those
|
||||
version_guids = []
|
||||
@@ -279,7 +247,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
version_guids.append(version_guid)
|
||||
id_version_map[version_guid] = structure['_id']
|
||||
|
||||
course_entries = self.structures.find({'_id': {'$in': version_guids}})
|
||||
course_entries = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}})
|
||||
|
||||
# get the block for the course element (s/b the root)
|
||||
result = []
|
||||
@@ -455,7 +423,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
"""
|
||||
if course_locator.course_id is None:
|
||||
return None
|
||||
index = self.course_index.find_one({'_id': course_locator.course_id})
|
||||
index = self.db_connection.get_course_index(course_locator.course_id)
|
||||
return index
|
||||
|
||||
# TODO figure out a way to make this info accessible from the course descriptor
|
||||
@@ -487,7 +455,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
'edited_on': when the change was made
|
||||
}
|
||||
"""
|
||||
definition = self.definitions.find_one({'_id': definition_locator.definition_id})
|
||||
definition = self.db_connection.get_definition(definition_locator.definition_id)
|
||||
if definition is None:
|
||||
return None
|
||||
return definition['edit_info']
|
||||
@@ -509,14 +477,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
# TODO if depth is significant, it may make sense to get all that have the same original_version
|
||||
# and reconstruct the subtree from version_guid
|
||||
next_entries = self.structures.find({'previous_version' : version_guid})
|
||||
next_entries = self.db_connection.find_matching_structures({'previous_version' : version_guid})
|
||||
# must only scan cursor's once
|
||||
next_versions = [struct for struct in next_entries]
|
||||
result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]}
|
||||
depth = 1
|
||||
while depth < version_history_depth and len(next_versions) > 0:
|
||||
depth += 1
|
||||
next_entries = self.structures.find({'previous_version':
|
||||
next_entries = self.db_connection.find_matching_structures({'previous_version':
|
||||
{'$in': [struct['_id'] for struct in next_versions]}})
|
||||
next_versions = [struct for struct in next_entries]
|
||||
for course_structure in next_versions:
|
||||
@@ -537,7 +505,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
course_struct = self._lookup_course(block_locator.version_agnostic())['structure']
|
||||
usage_id = block_locator.usage_id
|
||||
update_version_field = 'blocks.{}.edit_info.update_version'.format(usage_id)
|
||||
all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'],
|
||||
all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'],
|
||||
update_version_field: {'$exists': True}})
|
||||
# find (all) root versions and build map previous: [successors]
|
||||
possible_roots = []
|
||||
@@ -596,7 +564,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
"original_version": new_id,
|
||||
}
|
||||
}
|
||||
self.definitions.insert(document)
|
||||
self.db_connection.insert_definition(document)
|
||||
definition_locator = DefinitionLocator(new_id)
|
||||
return definition_locator
|
||||
|
||||
@@ -618,7 +586,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
# if this looks in cache rather than fresh fetches, then it will probably not detect
|
||||
# actual change b/c the descriptor and cache probably point to the same objects
|
||||
old_definition = self.definitions.find_one({'_id': definition_locator.definition_id})
|
||||
old_definition = self.db_connection.get_definition(definition_locator.definition_id)
|
||||
if old_definition is None:
|
||||
raise ItemNotFoundError(definition_locator.url())
|
||||
|
||||
@@ -630,7 +598,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
# previous version id
|
||||
old_definition['edit_info']['previous_version'] = definition_locator.definition_id
|
||||
self.definitions.insert(old_definition)
|
||||
self.db_connection.insert_definition(old_definition)
|
||||
return DefinitionLocator(old_definition['_id']), True
|
||||
else:
|
||||
return definition_locator, False
|
||||
@@ -657,7 +625,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
:param course_blocks: the current list of blocks.
|
||||
:param category:
|
||||
"""
|
||||
existing_uses = self.course_index.find({"_id": {"$regex": id_root}})
|
||||
existing_uses = self.db_connection.find_matching_course_indexes({"_id": {"$regex": id_root}})
|
||||
if existing_uses.count() > 0:
|
||||
max_found = 0
|
||||
matcher = re.compile(id_root + r'(\d+)')
|
||||
@@ -779,11 +747,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
parent['edit_info']['update_version'] = new_id
|
||||
if continue_version:
|
||||
# db update
|
||||
self.structures.update({'_id': new_id}, new_structure)
|
||||
self.db_connection.update_structure(new_structure)
|
||||
# clear cache so things get refetched and inheritance recomputed
|
||||
self._clear_cache(new_id)
|
||||
else:
|
||||
self.structures.insert(new_structure)
|
||||
self.db_connection.insert_structure(new_structure)
|
||||
|
||||
# update the index entry if appropriate
|
||||
if index_entry is not None:
|
||||
@@ -856,7 +824,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
'original_version': definition_id,
|
||||
}
|
||||
}
|
||||
self.definitions.insert(definition_entry)
|
||||
self.db_connection.insert_definition(definition_entry)
|
||||
|
||||
new_id = ObjectId()
|
||||
draft_structure = {
|
||||
@@ -880,7 +848,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
}
|
||||
}
|
||||
}
|
||||
self.structures.insert(draft_structure)
|
||||
self.db_connection.insert_structure(draft_structure)
|
||||
|
||||
if versions_dict is None:
|
||||
versions_dict = {master_branch: new_id}
|
||||
@@ -898,20 +866,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
if block_fields is not None:
|
||||
root_block['fields'].update(block_fields)
|
||||
if definition_fields is not None:
|
||||
definition = self.definitions.find_one({'_id': root_block['definition']})
|
||||
definition = self.db_connection.get_definition(root_block['definition'])
|
||||
definition['fields'].update(definition_fields)
|
||||
definition['edit_info']['previous_version'] = definition['_id']
|
||||
definition['edit_info']['edited_by'] = user_id
|
||||
definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
definition['_id'] = ObjectId()
|
||||
self.definitions.insert(definition)
|
||||
self.db_connection.insert_definition(definition)
|
||||
root_block['definition'] = definition['_id']
|
||||
root_block['edit_info']['edited_on'] = datetime.datetime.now(UTC)
|
||||
root_block['edit_info']['edited_by'] = user_id
|
||||
root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version')
|
||||
root_block['edit_info']['update_version'] = new_id
|
||||
|
||||
self.structures.insert(draft_structure)
|
||||
self.db_connection.insert_structure(draft_structure)
|
||||
versions_dict[master_branch] = new_id
|
||||
|
||||
# create the index entry
|
||||
@@ -926,7 +894,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
'edited_by': user_id,
|
||||
'edited_on': datetime.datetime.now(UTC),
|
||||
'versions': versions_dict}
|
||||
self.course_index.insert(index_entry)
|
||||
self.db_connection.insert_course_index(index_entry)
|
||||
return self.get_course(CourseLocator(course_id=new_id, branch=master_branch))
|
||||
|
||||
def update_item(self, descriptor, user_id, force=False):
|
||||
@@ -978,7 +946,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
'previous_version': block_data['edit_info']['update_version'],
|
||||
'update_version': new_id,
|
||||
}
|
||||
self.structures.insert(new_structure)
|
||||
self.db_connection.insert_structure(new_structure)
|
||||
# update the index entry if appropriate
|
||||
if index_entry is not None:
|
||||
self._update_head(index_entry, descriptor.location.branch, new_id)
|
||||
@@ -1016,7 +984,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id)
|
||||
|
||||
if is_updated:
|
||||
self.structures.insert(new_structure)
|
||||
self.db_connection.insert_structure(new_structure)
|
||||
|
||||
# update the index entry if appropriate
|
||||
if index_entry is not None:
|
||||
@@ -1115,31 +1083,18 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
'''Deprecated, use update_item.'''
|
||||
raise NotImplementedError('use update_item')
|
||||
|
||||
def update_course_index(self, course_locator, new_values_dict, update_versions=False):
|
||||
def update_course_index(self, updated_index_entry):
|
||||
"""
|
||||
Change the given course's index entry for the given fields. new_values_dict
|
||||
should be a subset of the dict returned by get_course_index_info.
|
||||
It cannot include '_id' (will raise IllegalArgument).
|
||||
Provide update_versions=True if you intend this to replace the versions hash.
|
||||
Change the given course's index entry.
|
||||
|
||||
Note, this operation can be dangerous and break running courses.
|
||||
|
||||
If the dict includes versions and not update_versions, it will raise an exception.
|
||||
|
||||
If the dict includes edited_on or edited_by, it will raise an exception
|
||||
|
||||
Does not return anything useful.
|
||||
"""
|
||||
# TODO how should this log the change? edited_on and edited_by for this entry
|
||||
# has the semantic of who created the course and when; so, changing those will lose
|
||||
# that information.
|
||||
if '_id' in new_values_dict:
|
||||
raise ValueError("Cannot override _id")
|
||||
if 'edited_on' in new_values_dict or 'edited_by' in new_values_dict:
|
||||
raise ValueError("Cannot set edited_on or edited_by")
|
||||
if not update_versions and 'versions' in new_values_dict:
|
||||
raise ValueError("Cannot override versions without setting update_versions")
|
||||
self.course_index.update({'_id': course_locator.course_id},
|
||||
{'$set': new_values_dict})
|
||||
self.db_connection.update_course_index(updated_index_entry)
|
||||
|
||||
def delete_item(self, usage_locator, user_id, delete_children=False, force=False):
|
||||
"""
|
||||
@@ -1182,7 +1137,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
remove_subtree(usage_locator.usage_id)
|
||||
|
||||
# update index if appropriate and structures
|
||||
self.structures.insert(new_structure)
|
||||
self.db_connection.insert_structure(new_structure)
|
||||
|
||||
result = CourseLocator(version_guid=new_id)
|
||||
|
||||
@@ -1204,11 +1159,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
|
||||
:param course_id: uses course_id rather than locator to emphasize its global effect
|
||||
"""
|
||||
index = self.course_index.find_one({'_id': course_id})
|
||||
index = self.db_connection.get_course_index(course_id)
|
||||
if index is None:
|
||||
raise ItemNotFoundError(course_id)
|
||||
# this is the only real delete in the system. should it do something else?
|
||||
self.course_index.remove(index['_id'])
|
||||
self.db_connection.delete_course_index(index['_id'])
|
||||
|
||||
def get_errored_courses(self):
|
||||
"""
|
||||
@@ -1296,7 +1251,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
block['fields']["children"] = [
|
||||
usage_id for usage_id in block['fields']["children"] if usage_id in original_structure['blocks']
|
||||
]
|
||||
self.structures.update({'_id': original_structure['_id']}, original_structure)
|
||||
self.db_connection.update_structure(original_structure)
|
||||
# clear cache again b/c inheritance may be wrong over orphans
|
||||
self._clear_cache(original_structure['_id'])
|
||||
|
||||
@@ -1379,7 +1334,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
index_entry = self.course_index.find_one({'_id': locator.course_id})
|
||||
index_entry = self.db_connection.get_course_index(locator.course_id)
|
||||
is_head = (
|
||||
locator.version_guid is None or
|
||||
index_entry['versions'][locator.branch] == locator.version_guid
|
||||
@@ -1424,9 +1379,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
|
||||
:param course_locator:
|
||||
:param new_id:
|
||||
"""
|
||||
self.course_index.update(
|
||||
{"_id": index_entry["_id"]},
|
||||
{"$set": {"versions.{}".format(branch): new_id}})
|
||||
index_entry['versions'][branch] = new_id
|
||||
self.db_connection.update_course_index(index_entry)
|
||||
|
||||
def _partition_fields_by_scope(self, category, fields):
|
||||
"""
|
||||
|
||||
@@ -274,7 +274,9 @@ class TestLocationMapper(unittest.TestCase):
|
||||
course_id=prob_locator.course_id, branch='draft', usage_id=prob_locator.usage_id
|
||||
)
|
||||
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(
|
||||
course_id=new_style_course_id, usage_id='problem2', branch='production'
|
||||
)
|
||||
|
||||
@@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase):
|
||||
dbref = self.loc_mapper.db
|
||||
dbref.drop_collection(self.loc_mapper.location_map)
|
||||
split_db = self.split_mongo.db
|
||||
split_db.drop_collection(split_db.course_index)
|
||||
split_db.drop_collection(split_db.structures)
|
||||
split_db.drop_collection(split_db.definitions)
|
||||
split_db.drop_collection(self.split_mongo.db_connection.course_index)
|
||||
split_db.drop_collection(self.split_mongo.db_connection.structures)
|
||||
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
|
||||
dbref.drop_collection(self.old_mongo.collection)
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
self.assertEqual(course_info['org'], 'moreFunky')
|
||||
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
|
||||
versions = course_info['versions']
|
||||
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)
|
||||
self.assertEqual(str(course.location.version_guid), self.GUID_D1)
|
||||
|
||||
# an allowed but not recommended way to publish a course
|
||||
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"))
|
||||
self.assertEqual(str(course.location.version_guid), self.GUID_D1)
|
||||
|
||||
@@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest):
|
||||
self.assertEqual(new_course.location.usage_id, 'top')
|
||||
self.assertEqual(new_course.category, 'chapter')
|
||||
# look at db to verify
|
||||
db_structure = modulestore().structures.find_one({
|
||||
'_id': new_course.location.as_object_id(new_course.location.version_guid)
|
||||
})
|
||||
db_structure = modulestore().db_connection.get_structure(
|
||||
new_course.location.as_object_id(new_course.location.version_guid)
|
||||
)
|
||||
self.assertIsNotNone(db_structure, "Didn't find course")
|
||||
self.assertNotIn('course', db_structure['blocks'])
|
||||
self.assertIn('top', db_structure['blocks'])
|
||||
|
||||
@@ -97,16 +97,15 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
if len(draft_verticals) > 0:
|
||||
draft_course_dir = export_fs.makeopendir('drafts')
|
||||
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)
|
||||
# Don't try to export orphaned items.
|
||||
if len(parent_locs) > 0:
|
||||
logging.debug('parent_locs = {0}'.format(parent_locs))
|
||||
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
|
||||
sequential = modulestore.get_item(Location(parent_locs[0]))
|
||||
index = sequential.children.index(draft_vertical.location.url())
|
||||
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
|
||||
draft_vertical.export_to_xml(draft_course_dir)
|
||||
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
|
||||
# Don't try to export orphaned items.
|
||||
if len(parent_locs) > 0:
|
||||
logging.debug('parent_locs = {0}'.format(parent_locs))
|
||||
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
|
||||
sequential = modulestore.get_item(Location(parent_locs[0]))
|
||||
index = sequential.children.index(draft_vertical.location.url())
|
||||
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
|
||||
draft_vertical.export_to_xml(draft_course_dir)
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_id, course_location, category_type, dirname, file_suffix=''):
|
||||
|
||||
@@ -25,9 +25,8 @@ if Backbone?
|
||||
@add model
|
||||
model
|
||||
|
||||
retrieveAnotherPage: (mode, options={}, sort_options={})->
|
||||
@current_page += 1
|
||||
data = { page: @current_page }
|
||||
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
|
||||
data = { page: @current_page + 1 }
|
||||
switch mode
|
||||
when 'search'
|
||||
url = DiscussionUtil.urlFor 'search'
|
||||
@@ -59,6 +58,7 @@ if Backbone?
|
||||
@reset new_collection
|
||||
@pages = response.num_pages
|
||||
@current_page = response.page
|
||||
error: error
|
||||
|
||||
sortByDate: (thread) ->
|
||||
#
|
||||
|
||||
@@ -36,12 +36,15 @@ if Backbone?
|
||||
event.preventDefault()
|
||||
@newPostForm.slideUp(300)
|
||||
|
||||
hideDiscussion: ->
|
||||
@$("section.discussion").slideUp()
|
||||
@toggleDiscussionBtn.removeClass('shown')
|
||||
@toggleDiscussionBtn.find('.button-text').html("Show Discussion")
|
||||
@showed = false
|
||||
|
||||
toggleDiscussion: (event) ->
|
||||
if @showed
|
||||
@$("section.discussion").slideUp()
|
||||
@toggleDiscussionBtn.removeClass('shown')
|
||||
@toggleDiscussionBtn.find('.button-text').html("Show Discussion")
|
||||
@showed = false
|
||||
@hideDiscussion()
|
||||
else
|
||||
@toggleDiscussionBtn.addClass('shown')
|
||||
@toggleDiscussionBtn.find('.button-text').html("Hide Discussion")
|
||||
@@ -51,9 +54,17 @@ if Backbone?
|
||||
@showed = true
|
||||
else
|
||||
$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")
|
||||
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
|
||||
DiscussionUtil.safeAjax
|
||||
@@ -63,6 +74,7 @@ if Backbone?
|
||||
type: "GET"
|
||||
dataType: 'json'
|
||||
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
|
||||
error: error
|
||||
|
||||
renderDiscussion: ($elem, response, textStatus, discussionId) =>
|
||||
window.user = new DiscussionUser(response.user_info)
|
||||
@@ -131,5 +143,14 @@ if Backbone?
|
||||
navigateToPage: (event) =>
|
||||
event.preventDefault()
|
||||
window.history.pushState({}, window.document.title, event.target.href)
|
||||
currPage = @page
|
||||
@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
|
||||
"notifications_status" : "/notification_prefs/status"
|
||||
}[name]
|
||||
|
||||
@makeFocusTrap: (elem) ->
|
||||
elem.keydown(
|
||||
(event) ->
|
||||
if event.which == 9 # Tab
|
||||
event.preventDefault()
|
||||
)
|
||||
|
||||
@discussionAlert: (header, body) ->
|
||||
if $("#discussion-alert").length == 0
|
||||
alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none")
|
||||
@@ -99,12 +106,7 @@ class @DiscussionUtil
|
||||
" <button class='dismiss'>OK</button>" +
|
||||
"</div>"
|
||||
)
|
||||
# Capture focus
|
||||
alertDiv.find("button").keydown(
|
||||
(event) ->
|
||||
if event.which == 9 # Tab
|
||||
event.preventDefault()
|
||||
)
|
||||
@makeFocusTrap(alertDiv.find("button"))
|
||||
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none")
|
||||
alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200})
|
||||
$("body").append(alertDiv).append(alertTrigger)
|
||||
|
||||
@@ -124,8 +124,11 @@ if Backbone?
|
||||
loadMorePages: (event) ->
|
||||
if event
|
||||
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")
|
||||
loadingDiv = @$(".more-pages .loading-animation")
|
||||
DiscussionUtil.makeFocusTrap(loadingDiv)
|
||||
loadingDiv.focus()
|
||||
options = {}
|
||||
switch @mode
|
||||
when 'search'
|
||||
@@ -156,7 +159,11 @@ if Backbone?
|
||||
$(".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) =>
|
||||
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
(function () {
|
||||
var update = function () {
|
||||
// Whenever a value changes create a new serialized version of this
|
||||
// problem's inputs and set the hidden input fields value to equal it.
|
||||
var parent = $(this).closest('.problems-wrapper');
|
||||
// problem's inputs and set the hidden input field's value to equal it.
|
||||
var parent = $(this).closest('section.choicetextinput');
|
||||
// find the closest parent problems-wrapper and use that as the problem
|
||||
// grab the input id from the input
|
||||
// real_input is the hidden input field
|
||||
var real_input = $('input.choicetextvalue', parent);
|
||||
var all_inputs = $('.choicetextinput .ctinput', parent);
|
||||
var all_inputs = $('input.ctinput', parent);
|
||||
var user_inputs = {};
|
||||
$(all_inputs).each(function (index, elt) {
|
||||
var node = $(elt);
|
||||
|
||||
3
common/static/js/vendor/backbone-min.js
vendored
3
common/static/js/vendor/backbone-min.js
vendored
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import datetime
|
||||
from pytz import UTC
|
||||
from django.conf import 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.django import editable_modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
@@ -25,6 +25,7 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
|
||||
"""
|
||||
def setUp(self):
|
||||
self.store = editable_modulestore()
|
||||
self.factory = RequestFactory()
|
||||
self.course = CourseFactory.create()
|
||||
self.course.days_early_for_beta = 5
|
||||
self.course.enrollment_start = datetime.datetime.now(UTC) + datetime.timedelta(days=3)
|
||||
@@ -32,7 +33,11 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
|
||||
|
||||
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE)
|
||||
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
|
||||
|
||||
@override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user