Resolve conflicts merging 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>
|
||||
|
||||
@@ -13,9 +13,36 @@ Blades: Added grading support for LTI module. LTI providers can now grade
|
||||
student's work and send edX scores. OAuth1 based authentication
|
||||
implemented. BLD-384.
|
||||
|
||||
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.
|
||||
|
||||
LMS: Add a user-visible alert modal when a forums AJAX request fails.
|
||||
|
||||
@@ -36,7 +63,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.
|
||||
@@ -54,6 +82,9 @@ key in course settings. (BLD-426)
|
||||
|
||||
Blades: Fix bug when the speed can only be changed when the video is playing.
|
||||
|
||||
LMS: The dialogs on the wiki "changes" page are now accessible to screen
|
||||
readers. Now all wiki pages have been made accessible. (LMS-1337)
|
||||
|
||||
LMS: Change bulk email implementation to use less memory, and to better handle
|
||||
duplicate tasks in celery.
|
||||
|
||||
@@ -70,8 +101,8 @@ client error are correctly passed through to the client.
|
||||
LMS: Improve performance of page load and thread list load for
|
||||
discussion tab
|
||||
|
||||
LMS: The wiki markup cheatsheet dialog is now accessible to people with
|
||||
disabilites. (LMS-1303)
|
||||
LMS: The wiki markup cheatsheet dialog is now accessible to screen readers.
|
||||
(LMS-1303)
|
||||
|
||||
Common: Add skip links for accessibility to CMS and LMS. (LMS-1311)
|
||||
|
||||
|
||||
@@ -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()])
|
||||
|
||||
|
||||
|
||||
@@ -53,30 +53,33 @@ Feature: CMS.Video Component
|
||||
Then Captions become "invisible"
|
||||
|
||||
# 8
|
||||
Scenario: Open captions never become invisible
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are open
|
||||
Then Captions are "visible"
|
||||
And I hover over button "CC"
|
||||
Then Captions are "visible"
|
||||
And I hover over button "volume"
|
||||
Then Captions are "visible"
|
||||
# Disabled 11/26 due to flakiness in master
|
||||
#Scenario: Open captions never become invisible
|
||||
# Given I have created a Video component with subtitles
|
||||
# And Make sure captions are open
|
||||
# Then Captions are "visible"
|
||||
# And I hover over button "CC"
|
||||
# Then Captions are "visible"
|
||||
# And I hover over button "volume"
|
||||
# Then Captions are "visible"
|
||||
|
||||
# 9
|
||||
Scenario: Closed captions are invisible when mouse doesn't hover on CC button
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are closed
|
||||
Then Captions become "invisible"
|
||||
And I hover over button "volume"
|
||||
Then Captions are "invisible"
|
||||
# Disabled 11/26 due to flakiness in master
|
||||
#Scenario: Closed captions are invisible when mouse doesn't hover on CC button
|
||||
# Given I have created a Video component with subtitles
|
||||
# And Make sure captions are closed
|
||||
# Then Captions become "invisible"
|
||||
# And I hover over button "volume"
|
||||
# Then Captions are "invisible"
|
||||
|
||||
# 10
|
||||
Scenario: When enter key is pressed on a caption shows an outline around it
|
||||
Given I have created a Video component with subtitles
|
||||
And Make sure captions are opened
|
||||
Then I focus on caption line with data-index "0"
|
||||
Then I press "enter" button on caption line with data-index "0"
|
||||
And I see caption line with data-index "0" has class "focused"
|
||||
# Disabled 11/26 due to flakiness in master
|
||||
#Scenario: When enter key is pressed on a caption shows an outline around it
|
||||
# Given I have created a Video component with subtitles
|
||||
# And Make sure captions are opened
|
||||
# Then I focus on caption line with data-index "0"
|
||||
# Then I press "enter" button on caption line with data-index "0"
|
||||
# And I see caption line with data-index "0" has class "focused"
|
||||
|
||||
# 11
|
||||
Scenario: When start end end times are specified, a range on slider is shown
|
||||
|
||||
@@ -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,39 @@ 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')
|
||||
test_get_html('settings/advanced')
|
||||
|
||||
# import page
|
||||
resp = self.client.get_html(new_location.url_reverse('import/', ''))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# 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 +1691,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 +1891,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 +1960,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 +1997,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 +2011,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,14 +6,12 @@ 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
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import get_modulestore
|
||||
from contentstore.utils import get_modulestore, EXTRA_TAB_PANELS
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
@@ -21,6 +19,8 @@ from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.fields import Date
|
||||
|
||||
from .utils import CourseTestCase
|
||||
from xmodule.modulestore.django import loc_mapper, modulestore
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -377,15 +418,19 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
def setUp(self):
|
||||
CourseTestCase.setUp(self)
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
self.fullcourse_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
self.fullcourse = CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
self.course_setting_url = self.course_locator.url_reverse('settings/advanced')
|
||||
self.fullcourse_setting_url = loc_mapper().translate_location(
|
||||
self.fullcourse.location.course_id,
|
||||
self.fullcourse.location, False, True
|
||||
).url_reverse('settings/advanced')
|
||||
|
||||
def test_fetch_initial_fields(self):
|
||||
test_model = CourseMetadata.fetch(self.course.location)
|
||||
test_model = CourseMetadata.fetch(self.course)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
test_model = CourseMetadata.fetch(self.fullcourse_location)
|
||||
test_model = CourseMetadata.fetch(self.fullcourse)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
@@ -394,17 +439,17 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_update_from_json(self):
|
||||
test_model = CourseMetadata.update_from_json(self.course.location, {
|
||||
test_model = CourseMetadata.update_from_json(self.course, {
|
||||
"advertised_start": "start A",
|
||||
"testcenter_info": {"c": "test"},
|
||||
"days_early_for_beta": 2
|
||||
})
|
||||
self.update_check(test_model)
|
||||
# try fresh fetch to ensure persistence
|
||||
test_model = CourseMetadata.fetch(self.course.location)
|
||||
fresh = modulestore().get_item(self.course_location)
|
||||
test_model = CourseMetadata.fetch(fresh)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
test_model = CourseMetadata.update_from_json(self.course.location, {
|
||||
test_model = CourseMetadata.update_from_json(fresh, {
|
||||
"advertised_start": "start B",
|
||||
"display_name": "jolly roger"}
|
||||
)
|
||||
@@ -418,13 +463,15 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
|
||||
self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
|
||||
self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
|
||||
self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value")
|
||||
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
|
||||
self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
|
||||
|
||||
def test_delete_key(self):
|
||||
test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']})
|
||||
test_model = CourseMetadata.update_from_json(
|
||||
self.fullcourse, {
|
||||
"unsetKeys": ['showanswer', 'xqa_key']
|
||||
}
|
||||
)
|
||||
# ensure no harm
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
@@ -434,27 +481,113 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
def test_http_fetch_initial_fields(self):
|
||||
response = self.client.get_json(self.course_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
|
||||
response = self.client.get_json(self.fullcourse_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
|
||||
self.assertIn('display_name', test_model, 'full missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
self.assertIn('showanswer', test_model, 'showanswer field ')
|
||||
self.assertIn('xqa_key', test_model, 'xqa_key field ')
|
||||
|
||||
def test_http_update_from_json(self):
|
||||
response = self.client.ajax_post(self.course_setting_url, {
|
||||
"advertised_start": "start A",
|
||||
"testcenter_info": {"c": "test"},
|
||||
"days_early_for_beta": 2,
|
||||
"unsetKeys": ['showanswer', 'xqa_key'],
|
||||
})
|
||||
test_model = json.loads(response.content)
|
||||
self.update_check(test_model)
|
||||
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
response = self.client.get_json(self.course_setting_url)
|
||||
test_model = json.loads(response.content)
|
||||
self.update_check(test_model)
|
||||
# now change some of the existing metadata
|
||||
response = self.client.ajax_post(self.course_setting_url, {
|
||||
"advertised_start": "start B",
|
||||
"display_name": "jolly roger"
|
||||
})
|
||||
test_model = json.loads(response.content)
|
||||
self.assertIn('display_name', test_model, 'Missing editable metadata field')
|
||||
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
|
||||
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
|
||||
self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value")
|
||||
|
||||
def test_advanced_components_munge_tabs(self):
|
||||
"""
|
||||
Test that adding and removing specific advanced components adds and removes tabs.
|
||||
"""
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), self.course.tabs)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), self.course.tabs)
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: ["combinedopenended"]
|
||||
})
|
||||
course = modulestore().get_item(self.course_location)
|
||||
self.assertIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), course.tabs)
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: []
|
||||
})
|
||||
course = modulestore().get_item(self.course_location)
|
||||
self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs)
|
||||
|
||||
|
||||
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 +595,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
|
||||
|
||||
@@ -27,13 +27,11 @@ from xmodule.modulestore.exceptions import (
|
||||
ItemNotFoundError, InvalidLocationError)
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from contentstore.course_info_model import (
|
||||
get_course_updates, update_course_updates, delete_course_update)
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
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,14 +51,13 @@ 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',
|
||||
'course_config_advanced_page',
|
||||
'course_settings_updates',
|
||||
'course_grader_updates',
|
||||
'course_advanced_updates', 'textbook_index', 'textbook_by_id',
|
||||
'settings_handler',
|
||||
'grading_handler',
|
||||
'advanced_settings_handler',
|
||||
'textbook_index', 'textbook_by_id',
|
||||
'create_textbook']
|
||||
|
||||
|
||||
@@ -177,7 +174,6 @@ def course_index(request, course_id, branch, version_guid, block):
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
old_location = loc_mapper().translate_locator_to_location(location)
|
||||
|
||||
lms_link = get_lms_link_for_item(old_location)
|
||||
@@ -190,10 +186,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',
|
||||
@@ -232,14 +226,20 @@ def create_new_course(request):
|
||||
pass
|
||||
if existing_course is not None:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'ErrMsg': _(
|
||||
'There is already a course defined with the same '
|
||||
'organization, course number, and course run. Please '
|
||||
'change either organization or course number to be '
|
||||
'unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'unique.'
|
||||
),
|
||||
'OrgErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'
|
||||
),
|
||||
'CourseErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'
|
||||
),
|
||||
})
|
||||
|
||||
# dhm: this query breaks the abstraction, but I'll fix it when I do my suspended refactoring of this
|
||||
@@ -254,12 +254,15 @@ def create_new_course(request):
|
||||
courses = modulestore().collection.find(course_search_location, fields=('_id'))
|
||||
if courses.count() > 0:
|
||||
return JsonResponse({
|
||||
'ErrMsg': _('There is already a course defined with the same '
|
||||
'ErrMsg': _(
|
||||
'There is already a course defined with the same '
|
||||
'organization and course number. Please '
|
||||
'change at least one field to be unique.'),
|
||||
'OrgErrMsg': _('Please change either the organization or '
|
||||
'OrgErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
'CourseErrMsg': _('Please change either the organization or '
|
||||
'CourseErrMsg': _(
|
||||
'Please change either the organization or '
|
||||
'course number so that it is unique.'),
|
||||
})
|
||||
|
||||
@@ -347,9 +350,8 @@ def course_info_handler(request, tag=None, course_id=None, branch=None, version_
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@expect_json
|
||||
def course_info_update_handler(
|
||||
request, tag=None, course_id=None, branch=None, version_guid=None, block=None, provided_id=None
|
||||
):
|
||||
def course_info_update_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None,
|
||||
provided_id=None):
|
||||
"""
|
||||
restful CRUD operations on course_info updates.
|
||||
provided_id should be none if it's new (create) and index otherwise.
|
||||
@@ -394,232 +396,206 @@ def course_info_update_handler(
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def get_course_settings(request, org, course, name):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
new_loc = loc_mapper().translate_location(location.course_id, location, False, True)
|
||||
upload_asset_url = new_loc.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
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_graders_page(request, org, course, name):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
course_details = CourseGradingModel.fetch(location)
|
||||
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_config_advanced_page(request, org, course, name):
|
||||
"""
|
||||
Send models and views as well as html for editing the advanced course
|
||||
settings to the client.
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'course_location': location,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
|
||||
})
|
||||
|
||||
|
||||
@require_http_methods(("GET", "PUT", "POST"))
|
||||
@expect_json
|
||||
def settings_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block)
|
||||
if not has_access(request.user, locator):
|
||||
raise PermissionDenied()
|
||||
|
||||
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)
|
||||
|
||||
upload_asset_url = locator.url_reverse('assets/')
|
||||
|
||||
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_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
|
||||
@expect_json
|
||||
def course_advanced_updates(request, org, course, name):
|
||||
def grading_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None, grader_index=None):
|
||||
"""
|
||||
Restful CRUD operations on metadata. The payload is a json rep of the
|
||||
metadata dicts. For delete, otoh, the payload is either a key or a list of
|
||||
keys to delete.
|
||||
|
||||
org, course: 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()
|
||||
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(location))
|
||||
elif request.method == 'DELETE':
|
||||
return JsonResponse(CourseMetadata.delete_key(
|
||||
location,
|
||||
json.loads(request.body)
|
||||
))
|
||||
else:
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
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)
|
||||
|
||||
# Check to see if the user instantiated any advanced components. This
|
||||
# is a hack that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically
|
||||
# if the user has indicated that they want to edit the
|
||||
# combinedopendended or peergrading module
|
||||
# 2) adds/removes the notes panel tab to a course automatically if
|
||||
# the user has indicated that they want the notes module enabled in
|
||||
# their course
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request.json:
|
||||
# Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
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()
|
||||
|
||||
# Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
# Check to see if the user instantiated any notes or open ended
|
||||
# components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(
|
||||
tab_type,
|
||||
course_module
|
||||
)
|
||||
# If a tab has been added to the course, then send the
|
||||
# metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should not be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
# Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True
|
||||
break
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type:
|
||||
# Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(
|
||||
tab_type, course_module
|
||||
)
|
||||
# pylint: disable=invalid-name
|
||||
def _config_course_advanced_components(request, course_module):
|
||||
"""
|
||||
Check to see if the user instantiated any advanced components. This
|
||||
is a hack that does the following :
|
||||
1) adds/removes the open ended panel tab to a course automatically
|
||||
if the user has indicated that they want to edit the
|
||||
combinedopendended or peergrading module
|
||||
2) adds/removes the notes panel tab to a course automatically if
|
||||
the user has indicated that they want the notes module enabled in
|
||||
their course
|
||||
"""
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
filter_tabs = True # Exceptional conditions will pull this to False
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended':OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes':NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
# Check to see if the user instantiated any notes or open ended
|
||||
# components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
|
||||
# If a tab has been added to the course, then send the
|
||||
# metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs': new_tabs})
|
||||
# Indicate that tabs should *not* be filtered out of
|
||||
# Indicate that tabs should not be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
location,
|
||||
request.json,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest(
|
||||
"Incorrect setting format. " + str(err),
|
||||
content_type="text/plain"
|
||||
)
|
||||
filter_tabs = False # Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True #break
|
||||
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type: # Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request.json.update({'tabs':new_tabs})
|
||||
# Indicate that tabs should *not* be filtered out of
|
||||
# the metadata
|
||||
filter_tabs = False
|
||||
|
||||
return filter_tabs
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@expect_json
|
||||
def advanced_settings_handler(request, course_id=None, branch=None, version_guid=None, block=None, tag=None):
|
||||
"""
|
||||
Course settings configuration
|
||||
GET
|
||||
html: get the page
|
||||
json: get the model
|
||||
PUT, POST
|
||||
json: update the Course's settings. The payload is a json rep of the
|
||||
metadata dicts. The dict can include a "unsetKeys" entry which is a list
|
||||
of keys whose values to unset: i.e., revert to default
|
||||
"""
|
||||
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_old_location = loc_mapper().translate_locator_to_location(locator)
|
||||
course_module = modulestore().get_item(course_old_location)
|
||||
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
|
||||
return render_to_response('settings_advanced.html', {
|
||||
'context_course': course_module,
|
||||
'advanced_dict': json.dumps(CourseMetadata.fetch(course_module)),
|
||||
'advanced_settings_url': locator.url_reverse('settings/advanced')
|
||||
})
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseMetadata.fetch(course_module))
|
||||
else:
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = _config_course_advanced_components(request, course_module)
|
||||
try:
|
||||
return JsonResponse(CourseMetadata.update_from_json(
|
||||
course_module,
|
||||
request.json,
|
||||
filter_tabs=filter_tabs
|
||||
))
|
||||
except (TypeError, ValueError) as err:
|
||||
return HttpResponseBadRequest(
|
||||
"Incorrect setting format. {}".format(err),
|
||||
content_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
class TextbookValidationError(Exception):
|
||||
|
||||
@@ -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
|
||||
@@ -27,16 +32,13 @@ class CourseDetails(object):
|
||||
self.course_image_asset_path = "" # URL of the course image
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
def fetch(cls, course_locator):
|
||||
"""
|
||||
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_locator)
|
||||
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_locator, 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_locator)
|
||||
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,9 +151,9 @@ 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)
|
||||
return CourseDetails.fetch(course_locator)
|
||||
|
||||
@staticmethod
|
||||
def parse_video_tag(raw_video):
|
||||
@@ -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):
|
||||
def fetch(cls, course_locator):
|
||||
"""
|
||||
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_locator)
|
||||
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,48 +52,26 @@ 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_locator, 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_locator)
|
||||
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'])
|
||||
CourseGradingModel.update_grace_period_from_json(course_locator, jsondict['grace_period'])
|
||||
|
||||
return CourseGradingModel.fetch(course_location)
|
||||
return CourseGradingModel.fetch(course_locator)
|
||||
|
||||
@staticmethod
|
||||
def update_grader_from_json(course_location, grader):
|
||||
@@ -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):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from xmodule.modulestore import Location
|
||||
from xblock.fields import Scope
|
||||
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.fields import Scope
|
||||
from cms.xmodule_namespace import CmsBlockMixin
|
||||
|
||||
|
||||
@@ -20,21 +20,18 @@ class CourseMetadata(object):
|
||||
'tabs',
|
||||
'graceperiod',
|
||||
'checklists',
|
||||
'show_timezone'
|
||||
'show_timezone',
|
||||
'format',
|
||||
'graded',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
def fetch(cls, descriptor):
|
||||
"""
|
||||
Fetch the key:value editable course details for the given course from
|
||||
persistence and return a CourseMetadata model.
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
course = {}
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
result = {}
|
||||
|
||||
for field in descriptor.fields.values():
|
||||
if field.name in CmsBlockMixin.fields:
|
||||
@@ -46,19 +43,17 @@ class CourseMetadata(object):
|
||||
if field.name in cls.FILTERED_LIST:
|
||||
continue
|
||||
|
||||
course[field.name] = field.read_json(descriptor)
|
||||
result[field.name] = field.read_json(descriptor)
|
||||
|
||||
return course
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def update_from_json(cls, course_location, jsondict, filter_tabs=True):
|
||||
def update_from_json(cls, descriptor, jsondict, filter_tabs=True):
|
||||
"""
|
||||
Decode the json into CourseMetadata and save any changed attrs to the db.
|
||||
|
||||
Ensures none of the fields are in the blacklist.
|
||||
"""
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
|
||||
# Copy the filtered list to avoid permanently changing the class attribute.
|
||||
@@ -72,39 +67,17 @@ class CourseMetadata(object):
|
||||
if key in filtered_list:
|
||||
continue
|
||||
|
||||
if key == "unsetKeys":
|
||||
dirty = True
|
||||
for unset in val:
|
||||
descriptor.fields[unset].delete_from(descriptor)
|
||||
|
||||
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
|
||||
dirty = True
|
||||
value = descriptor.fields[key].from_json(val)
|
||||
setattr(descriptor, key, value)
|
||||
|
||||
if dirty:
|
||||
# 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,
|
||||
own_metadata(descriptor))
|
||||
get_modulestore(descriptor.location).update_metadata(descriptor.location, own_metadata(descriptor))
|
||||
|
||||
# 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 it persisted correctly
|
||||
return cls.fetch(course_location)
|
||||
|
||||
@classmethod
|
||||
def delete_key(cls, course_location, payload):
|
||||
'''
|
||||
Remove the given metadata key(s) from the course. payload can be a
|
||||
single key or [key..]
|
||||
'''
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
for key in payload['deleteKeys']:
|
||||
if hasattr(descriptor, key):
|
||||
delattr(descriptor, key)
|
||||
|
||||
# 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,
|
||||
own_metadata(descriptor))
|
||||
|
||||
return cls.fetch(course_location)
|
||||
return cls.fetch(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
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
&.is-shown {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.is-hiding {
|
||||
bottom: -($ui-notification-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
// studio - elements - xmodules
|
||||
// studio - elements - xmodules & xblocks
|
||||
// ====================
|
||||
|
||||
// general - display mode (xblock-student_view or xmodule_display)
|
||||
.xmodule_display, .xblock-student_view {
|
||||
|
||||
// font styling
|
||||
i, em {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// Video Alpha
|
||||
|
||||
@@ -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,10 +4,8 @@
|
||||
|
||||
<%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
|
||||
%>
|
||||
|
||||
<%block name="jsextra">
|
||||
@@ -69,17 +67,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 +88,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 +202,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 +214,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,16 +290,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s
|
||||
<div class="bit">
|
||||
% 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/', '')
|
||||
grading_config_url = course_locator.url_reverse('settings/grading/')
|
||||
advanced_config_url = course_locator.url_reverse('settings/advanced/')
|
||||
%>
|
||||
<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>
|
||||
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%block name="title">${_("Advanced Settings")}</%block>
|
||||
<%block name="bodyclass">is-signedin course advanced view-settings</%block>
|
||||
@@ -28,7 +26,7 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
|
||||
|
||||
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
|
||||
var advancedModel = new AdvancedSettingsModel(${advanced_dict | n}, {parse: true});
|
||||
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
|
||||
advancedModel.url = "${advanced_settings_url}";
|
||||
|
||||
var editor = new AdvancedSettingsView({
|
||||
el: $('.settings-advanced'),
|
||||
@@ -91,13 +89,15 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting
|
||||
<%
|
||||
ctx_loc = context_course.location
|
||||
location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True)
|
||||
details_url = location.url_reverse('settings/details/')
|
||||
grading_url = location.url_reverse('settings/grading/')
|
||||
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.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="${details_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
from contentstore import utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
|
||||
<%block name="header_extras">
|
||||
@@ -28,9 +27,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();
|
||||
@@ -137,16 +138,16 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings
|
||||
<div class="bit">
|
||||
% 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/')
|
||||
advanced_settings_url = course_locator.url_reverse('settings/advanced/')
|
||||
detailed_settings_url = course_locator.url_reverse('settings/details/')
|
||||
%>
|
||||
<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="${detailed_settings_url}">${_("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>
|
||||
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -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>
|
||||
@@ -175,19 +182,15 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
<div class="row wrapper-unit-id">
|
||||
<p class="unit-id">
|
||||
<span class="label">${_("Unit Identifier:")}</span>
|
||||
<input type="text" class="url value" value="${unit.location.name}" disabled />
|
||||
<input type="text" class="url value" value="${unit.location.name}" readonly />
|
||||
</p>
|
||||
</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,17 @@
|
||||
<%
|
||||
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/')
|
||||
advanced_settings_url = location.url_reverse('settings/advanced/')
|
||||
tabs_url = location.url_reverse('tabs')
|
||||
%>
|
||||
<h2 class="info-course">
|
||||
<span class="sr">${_("Current Course:")}</span>
|
||||
@@ -48,7 +52,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,16 +72,16 @@
|
||||
<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>
|
||||
</li>
|
||||
<li class="nav-item nav-course-settings-advanced">
|
||||
<a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a>
|
||||
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="row">
|
||||
<h6>${_("Heading 1")}</h6>
|
||||
<div class="col sample heading-1">
|
||||
<img src="${static.url("/img/header-example.png")}" />
|
||||
<img src="${static.url("img/header-example.png")}" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>H1
|
||||
@@ -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>
|
||||
|
||||
47
cms/urls.py
47
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,35 +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'),
|
||||
# This is the URL used by BackBone for updating and re-fetching the model.
|
||||
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 +49,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 +62,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 +76,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 +85,10 @@ 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'),
|
||||
url(r'(?ix)^settings/advanced/{}$'.format(parsers.URL_RE_SOURCE), 'advanced_settings_handler'),
|
||||
)
|
||||
|
||||
js_info_dict = {
|
||||
|
||||
@@ -21,7 +21,7 @@ from django.core.exceptions import ValidationError
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
|
||||
from django_cas.views import login as django_cas_login
|
||||
|
||||
from student.models import UserProfile, TestCenterUser, TestCenterRegistration
|
||||
from student.models import UserProfile
|
||||
|
||||
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
|
||||
from django.utils.http import urlquote, is_safe_url
|
||||
@@ -880,146 +880,7 @@ def provider_xrds(request):
|
||||
return response
|
||||
|
||||
|
||||
#-------------------
|
||||
# Pearson
|
||||
#-------------------
|
||||
def course_from_id(course_id):
|
||||
"""Return the CourseDescriptor corresponding to this course_id"""
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
return modulestore().get_instance(course_id, course_loc)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def test_center_login(request):
|
||||
''' Log in students taking exams via Pearson
|
||||
|
||||
Takes a POST request that contains the following keys:
|
||||
- code - a security code provided by Pearson
|
||||
- clientCandidateID
|
||||
- registrationID
|
||||
- exitURL - the url that we redirect to once we're done
|
||||
- vueExamSeriesCode - a code that indicates the exam that we're using
|
||||
'''
|
||||
# Imports from lms/djangoapps/courseware -- these should not be
|
||||
# in a common djangoapps.
|
||||
from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.model_data import FieldDataCache
|
||||
|
||||
# errors are returned by navigating to the error_url, adding a query parameter named "code"
|
||||
# which contains the error code describing the exceptional condition.
|
||||
def makeErrorURL(error_url, error_code):
|
||||
log.error("generating error URL with error code {}".format(error_code))
|
||||
return "{}?code={}".format(error_url, error_code)
|
||||
|
||||
# get provided error URL, which will be used as a known prefix for returning error messages to the
|
||||
# Pearson shell.
|
||||
error_url = request.POST.get("errorURL")
|
||||
|
||||
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
|
||||
# with the code we calculate for the same parameters.
|
||||
if 'code' not in request.POST:
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode"))
|
||||
code = request.POST.get("code")
|
||||
|
||||
# calculate SHA for query string
|
||||
# TODO: figure out how to get the original query string, so we can hash it and compare.
|
||||
|
||||
if 'clientCandidateID' not in request.POST:
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"))
|
||||
client_candidate_id = request.POST.get("clientCandidateID")
|
||||
|
||||
# TODO: check remaining parameters, and maybe at least log if they're not matching
|
||||
# expected values....
|
||||
# registration_id = request.POST.get("registrationID")
|
||||
# exit_url = request.POST.get("exitURL")
|
||||
|
||||
# find testcenter_user that matches the provided ID:
|
||||
try:
|
||||
testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"))
|
||||
|
||||
AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id))
|
||||
|
||||
# find testcenter_registration that matches the provided exam code:
|
||||
# Note that we could rely in future on either the registrationId or the exam code,
|
||||
# or possibly both. But for now we know what to do with an ExamSeriesCode,
|
||||
# while we currently have no record of RegistrationID values at all.
|
||||
if 'vueExamSeriesCode' not in request.POST:
|
||||
# we are not allowed to make up a new error code, according to Pearson,
|
||||
# so instead of "missingExamSeriesCode", we use a valid one that is
|
||||
# inaccurate but at least distinct. (Sigh.)
|
||||
AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"))
|
||||
exam_series_code = request.POST.get('vueExamSeriesCode')
|
||||
|
||||
registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code)
|
||||
if not registrations:
|
||||
AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"))
|
||||
|
||||
# TODO: figure out what to do if there are more than one registrations....
|
||||
# for now, just take the first...
|
||||
registration = registrations[0]
|
||||
|
||||
course_id = registration.course_id
|
||||
course = course_from_id(course_id) # assume it will be found....
|
||||
if not course:
|
||||
AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
|
||||
exam = course.get_test_center_exam(exam_series_code)
|
||||
if not exam:
|
||||
AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
|
||||
location = exam.exam_url
|
||||
log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
|
||||
# check if the test has already been taken
|
||||
timelimit_descriptor = modulestore().get_instance(course_id, Location(location))
|
||||
if not timelimit_descriptor:
|
||||
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
|
||||
|
||||
timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
|
||||
timelimit_descriptor, depth=None)
|
||||
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
|
||||
timelimit_module_cache, course_id, position=None)
|
||||
if not timelimit_module.category == 'timelimit':
|
||||
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
|
||||
|
||||
if timelimit_module and timelimit_module.has_ended:
|
||||
AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
|
||||
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"))
|
||||
|
||||
# check if we need to provide an accommodation:
|
||||
time_accommodation_mapping = {'ET12ET': 'ADDHALFTIME',
|
||||
'ET30MN': 'ADD30MIN',
|
||||
'ETDBTM': 'ADDDOUBLE', }
|
||||
|
||||
time_accommodation_code = None
|
||||
for code in registration.get_accommodation_codes():
|
||||
if code in time_accommodation_mapping:
|
||||
time_accommodation_code = time_accommodation_mapping[code]
|
||||
|
||||
if time_accommodation_code:
|
||||
timelimit_module.accommodation_code = time_accommodation_code
|
||||
AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
|
||||
|
||||
# UGLY HACK!!!
|
||||
# Login assumes that authentication has occurred, and that there is a
|
||||
# backend annotation on the user object, indicating which backend
|
||||
# against which the user was authenticated. We're authenticating here
|
||||
# against the registration entry, and assuming that the request given
|
||||
# this information is correct, we allow the user to be logged in
|
||||
# without a password. This could all be formalized in a backend object
|
||||
# that does the above checking.
|
||||
# TODO: (brian) create a backend class to do this.
|
||||
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
|
||||
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
|
||||
login(request, testcenteruser.user)
|
||||
AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location))
|
||||
|
||||
# And start the test:
|
||||
return jump_to(request, course_id, location)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
from optparse import make_option
|
||||
from json import dump
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from student.models import TestCenterRegistration
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
args = '<output JSON file>'
|
||||
help = """
|
||||
Dump information as JSON from TestCenterRegistration tables, including username and status.
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--course_id',
|
||||
action='store',
|
||||
dest='course_id',
|
||||
help='Specify a particular course.'),
|
||||
make_option('--exam_series_code',
|
||||
action='store',
|
||||
dest='exam_series_code',
|
||||
default=None,
|
||||
help='Specify a particular exam, using the Pearson code'),
|
||||
make_option('--accommodation_pending',
|
||||
action='store_true',
|
||||
dest='accommodation_pending',
|
||||
default=False,
|
||||
),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 1:
|
||||
outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
|
||||
else:
|
||||
outputfile = args[0]
|
||||
|
||||
# construct the query object to dump:
|
||||
registrations = TestCenterRegistration.objects.all()
|
||||
if 'course_id' in options and options['course_id']:
|
||||
registrations = registrations.filter(course_id=options['course_id'])
|
||||
if 'exam_series_code' in options and options['exam_series_code']:
|
||||
registrations = registrations.filter(exam_series_code=options['exam_series_code'])
|
||||
|
||||
# collect output:
|
||||
output = []
|
||||
for registration in registrations:
|
||||
if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending:
|
||||
continue
|
||||
record = {'username': registration.testcenter_user.user.username,
|
||||
'email': registration.testcenter_user.email,
|
||||
'first_name': registration.testcenter_user.first_name,
|
||||
'last_name': registration.testcenter_user.last_name,
|
||||
'client_candidate_id': registration.client_candidate_id,
|
||||
'client_authorization_id': registration.client_authorization_id,
|
||||
'course_id': registration.course_id,
|
||||
'exam_series_code': registration.exam_series_code,
|
||||
'accommodation_request': registration.accommodation_request,
|
||||
'accommodation_code': registration.accommodation_code,
|
||||
'registration_status': registration.registration_status(),
|
||||
'demographics_status': registration.demographics_status(),
|
||||
'accommodation_status': registration.accommodation_status(),
|
||||
}
|
||||
if len(registration.upload_error_message) > 0:
|
||||
record['registration_error'] = registration.upload_error_message
|
||||
if len(registration.testcenter_user.upload_error_message) > 0:
|
||||
record['demographics_error'] = registration.testcenter_user.upload_error_message
|
||||
if registration.needs_uploading:
|
||||
record['needs_uploading'] = True
|
||||
|
||||
output.append(record)
|
||||
|
||||
# dump output:
|
||||
with open(outputfile, 'w') as outfile:
|
||||
dump(output, outfile, indent=2)
|
||||
@@ -1,111 +0,0 @@
|
||||
import csv
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from optparse import make_option
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
CSV_TO_MODEL_FIELDS = OrderedDict([
|
||||
# Skipping optional field CandidateID
|
||||
("ClientCandidateID", "client_candidate_id"),
|
||||
("FirstName", "first_name"),
|
||||
("LastName", "last_name"),
|
||||
("MiddleName", "middle_name"),
|
||||
("Suffix", "suffix"),
|
||||
("Salutation", "salutation"),
|
||||
("Email", "email"),
|
||||
# Skipping optional fields Username and Password
|
||||
("Address1", "address_1"),
|
||||
("Address2", "address_2"),
|
||||
("Address3", "address_3"),
|
||||
("City", "city"),
|
||||
("State", "state"),
|
||||
("PostalCode", "postal_code"),
|
||||
("Country", "country"),
|
||||
("Phone", "phone"),
|
||||
("Extension", "extension"),
|
||||
("PhoneCountryCode", "phone_country_code"),
|
||||
("FAX", "fax"),
|
||||
("FAXCountryCode", "fax_country_code"),
|
||||
("CompanyName", "company_name"),
|
||||
# Skipping optional field CustomQuestion
|
||||
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
|
||||
])
|
||||
|
||||
# define defaults, even thought 'store_true' shouldn't need them.
|
||||
# (call_command will set None as default value for all options that don't have one,
|
||||
# so one cannot rely on presence/absence of flags in that world.)
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--dest-from-settings',
|
||||
action='store_true',
|
||||
dest='dest-from-settings',
|
||||
default=False,
|
||||
help='Retrieve the destination to export to from django.'),
|
||||
make_option('--destination',
|
||||
action='store',
|
||||
dest='destination',
|
||||
default=None,
|
||||
help='Where to store the exported files')
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
# then we will create the directory.
|
||||
# Name will use timestamp -- this is UTC, so it will look funny,
|
||||
# but it should at least be consistent with the other timestamps
|
||||
# used in the system.
|
||||
if 'dest-from-settings' in options and options['dest-from-settings']:
|
||||
if 'LOCAL_EXPORT' in settings.PEARSON:
|
||||
dest = settings.PEARSON['LOCAL_EXPORT']
|
||||
else:
|
||||
raise CommandError('--dest-from-settings was enabled but the'
|
||||
'PEARSON[LOCAL_EXPORT] setting was not set.')
|
||||
elif 'destination' in options and options['destination']:
|
||||
dest = options['destination']
|
||||
else:
|
||||
raise CommandError('--destination or --dest-from-settings must be used')
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
os.makedirs(dest)
|
||||
|
||||
destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat"))
|
||||
|
||||
# strings must be in latin-1 format. CSV parser will
|
||||
# otherwise convert unicode objects to ascii.
|
||||
def ensure_encoding(value):
|
||||
if isinstance(value, unicode):
|
||||
return value.encode('iso-8859-1')
|
||||
else:
|
||||
return value
|
||||
|
||||
# dump_all = options['dump_all']
|
||||
|
||||
with open(destfile, "wb") as outfile:
|
||||
writer = csv.DictWriter(outfile,
|
||||
Command.CSV_TO_MODEL_FIELDS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id'):
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
|
||||
writer.writerow(record)
|
||||
tcu.uploaded_at = uploaded_at
|
||||
tcu.save()
|
||||
@@ -1,103 +0,0 @@
|
||||
import csv
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from optparse import make_option
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
CSV_TO_MODEL_FIELDS = OrderedDict([
|
||||
('AuthorizationTransactionType', 'authorization_transaction_type'),
|
||||
('AuthorizationID', 'authorization_id'),
|
||||
('ClientAuthorizationID', 'client_authorization_id'),
|
||||
('ClientCandidateID', 'client_candidate_id'),
|
||||
('ExamAuthorizationCount', 'exam_authorization_count'),
|
||||
('ExamSeriesCode', 'exam_series_code'),
|
||||
('Accommodations', 'accommodation_code'),
|
||||
('EligibilityApptDateFirst', 'eligibility_appointment_date_first'),
|
||||
('EligibilityApptDateLast', 'eligibility_appointment_date_last'),
|
||||
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
|
||||
])
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--dest-from-settings',
|
||||
action='store_true',
|
||||
dest='dest-from-settings',
|
||||
default=False,
|
||||
help='Retrieve the destination to export to from django.'),
|
||||
make_option('--destination',
|
||||
action='store',
|
||||
dest='destination',
|
||||
default=None,
|
||||
help='Where to store the exported files'),
|
||||
make_option('--dump_all',
|
||||
action='store_true',
|
||||
dest='dump_all',
|
||||
default=False,
|
||||
),
|
||||
make_option('--force_add',
|
||||
action='store_true',
|
||||
dest='force_add',
|
||||
default=False,
|
||||
),
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
# then we will create the directory.
|
||||
# Name will use timestamp -- this is UTC, so it will look funny,
|
||||
# but it should at least be consistent with the other timestamps
|
||||
# used in the system.
|
||||
if 'dest-from-settings' in options and options['dest-from-settings']:
|
||||
if 'LOCAL_EXPORT' in settings.PEARSON:
|
||||
dest = settings.PEARSON['LOCAL_EXPORT']
|
||||
else:
|
||||
raise CommandError('--dest-from-settings was enabled but the'
|
||||
'PEARSON[LOCAL_EXPORT] setting was not set.')
|
||||
elif 'destination' in options and options['destination']:
|
||||
dest = options['destination']
|
||||
else:
|
||||
raise CommandError('--destination or --dest-from-settings must be used')
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
os.makedirs(dest)
|
||||
|
||||
destfile = os.path.join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat"))
|
||||
|
||||
dump_all = options['dump_all']
|
||||
|
||||
with open(destfile, "wb") as outfile:
|
||||
writer = csv.DictWriter(outfile,
|
||||
Command.CSV_TO_MODEL_FIELDS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcr in TestCenterRegistration.objects.order_by('id'):
|
||||
if dump_all or tcr.needs_uploading:
|
||||
record = dict((csv_field, getattr(tcr, model_field))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
|
||||
record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d")
|
||||
record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d")
|
||||
if record["Accommodations"] == ACCOMMODATION_REJECTED_CODE:
|
||||
record["Accommodations"] = ""
|
||||
if options['force_add']:
|
||||
record['AuthorizationTransactionType'] = 'Add'
|
||||
|
||||
writer.writerow(record)
|
||||
tcr.uploaded_at = uploaded_at
|
||||
tcr.save()
|
||||
@@ -1,119 +0,0 @@
|
||||
import csv
|
||||
from time import strptime, strftime
|
||||
from datetime import datetime
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
|
||||
from dogapi import dog_http_api
|
||||
from pytz import UTC
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
|
||||
import django_startup
|
||||
|
||||
from student.models import TestCenterUser, TestCenterRegistration
|
||||
|
||||
|
||||
django_startup.autostartup()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
args = '<input zip file>'
|
||||
help = """
|
||||
Import Pearson confirmation files and update TestCenterUser
|
||||
and TestCenterRegistration tables with status.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def datadog_error(string, tags):
|
||||
dog_http_api.event("Pearson Import", string, alert_type='error', tags=[tags])
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
if len(args) < 1:
|
||||
print Command.help
|
||||
return
|
||||
|
||||
source_zip = args[0]
|
||||
if not is_zipfile(source_zip):
|
||||
error = "Input file is not a zipfile: \"{}\"".format(source_zip)
|
||||
Command.datadog_error(error, source_zip)
|
||||
raise CommandError(error)
|
||||
|
||||
# loop through all files in zip, and process them based on filename prefix:
|
||||
with ZipFile(source_zip, 'r') as zipfile:
|
||||
for fileinfo in zipfile.infolist():
|
||||
with zipfile.open(fileinfo) as zipentry:
|
||||
if fileinfo.filename.startswith("eac-"):
|
||||
self.process_eac(zipentry)
|
||||
elif fileinfo.filename.startswith("vcdc-"):
|
||||
self.process_vcdc(zipentry)
|
||||
else:
|
||||
error = "Unrecognized confirmation file type\"{}\" in confirmation zip file \"{}\"".format(fileinfo.filename, zipfile)
|
||||
Command.datadog_error(error, source_zip)
|
||||
raise CommandError(error)
|
||||
|
||||
def process_eac(self, eacfile):
|
||||
print "processing eac"
|
||||
reader = csv.DictReader(eacfile, delimiter="\t")
|
||||
for row in reader:
|
||||
client_authorization_id = row['ClientAuthorizationID']
|
||||
if not client_authorization_id:
|
||||
if row['Status'] == 'Error':
|
||||
Command.datadog_error("Error in EAD file processing ({}): {}".format(row['Date'], row['Message']), eacfile.name)
|
||||
else:
|
||||
Command.datadog_error("Encountered bad record: {}".format(row), eacfile.name)
|
||||
else:
|
||||
try:
|
||||
registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id)
|
||||
Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name)
|
||||
# now update the record:
|
||||
registration.upload_status = row['Status']
|
||||
registration.upload_error_message = row['Message']
|
||||
try:
|
||||
registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad Date value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
|
||||
# store the authorization Id if one is provided. (For debugging)
|
||||
if row['AuthorizationID']:
|
||||
try:
|
||||
registration.authorization_id = int(row['AuthorizationID'])
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
|
||||
|
||||
registration.confirmed_at = datetime.now(UTC)
|
||||
registration.save()
|
||||
except TestCenterRegistration.DoesNotExist:
|
||||
Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name)
|
||||
|
||||
def process_vcdc(self, vcdcfile):
|
||||
print "processing vcdc"
|
||||
reader = csv.DictReader(vcdcfile, delimiter="\t")
|
||||
for row in reader:
|
||||
client_candidate_id = row['ClientCandidateID']
|
||||
if not client_candidate_id:
|
||||
if row['Status'] == 'Error':
|
||||
Command.datadog_error("Error in CDD file processing ({}): {}".format(row['Date'], row['Message']), vcdcfile.name)
|
||||
else:
|
||||
Command.datadog_error("Encountered bad record: {}".format(row), vcdcfile.name)
|
||||
else:
|
||||
try:
|
||||
tcuser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
|
||||
Command.datadog_error("Found demographics record for user {}".format(tcuser.user.username), vcdcfile.name)
|
||||
# now update the record:
|
||||
tcuser.upload_status = row['Status']
|
||||
tcuser.upload_error_message = row['Message']
|
||||
try:
|
||||
tcuser.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad Date value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name)
|
||||
# store the candidate Id if one is provided. (For debugging)
|
||||
if row['CandidateID']:
|
||||
try:
|
||||
tcuser.candidate_id = int(row['CandidateID'])
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad CandidateID value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name)
|
||||
tcuser.confirmed_at = datetime.utcnow()
|
||||
tcuser.save()
|
||||
except TestCenterUser.DoesNotExist:
|
||||
Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name)
|
||||
@@ -1,206 +0,0 @@
|
||||
from optparse import make_option
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser, TestCenterRegistration, TestCenterRegistrationForm, get_testcenter_registration
|
||||
from student.views import course_from_id
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
# registration info:
|
||||
make_option(
|
||||
'--accommodation_request',
|
||||
action='store',
|
||||
dest='accommodation_request',
|
||||
),
|
||||
make_option(
|
||||
'--accommodation_code',
|
||||
action='store',
|
||||
dest='accommodation_code',
|
||||
),
|
||||
make_option(
|
||||
'--client_authorization_id',
|
||||
action='store',
|
||||
dest='client_authorization_id',
|
||||
),
|
||||
# exam info:
|
||||
make_option(
|
||||
'--exam_series_code',
|
||||
action='store',
|
||||
dest='exam_series_code',
|
||||
),
|
||||
make_option(
|
||||
'--eligibility_appointment_date_first',
|
||||
action='store',
|
||||
dest='eligibility_appointment_date_first',
|
||||
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
|
||||
),
|
||||
make_option(
|
||||
'--eligibility_appointment_date_last',
|
||||
action='store',
|
||||
dest='eligibility_appointment_date_last',
|
||||
help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.'
|
||||
),
|
||||
# internal values:
|
||||
make_option(
|
||||
'--authorization_id',
|
||||
action='store',
|
||||
dest='authorization_id',
|
||||
help='ID we receive from Pearson for a particular authorization'
|
||||
),
|
||||
make_option(
|
||||
'--upload_status',
|
||||
action='store',
|
||||
dest='upload_status',
|
||||
help='status value assigned by Pearson'
|
||||
),
|
||||
make_option(
|
||||
'--upload_error_message',
|
||||
action='store',
|
||||
dest='upload_error_message',
|
||||
help='error message provided by Pearson on a failure.'
|
||||
),
|
||||
# control values:
|
||||
make_option(
|
||||
'--ignore_registration_dates',
|
||||
action='store_true',
|
||||
dest='ignore_registration_dates',
|
||||
help='find exam info for course based on exam_series_code, even if the exam is not active.'
|
||||
),
|
||||
make_option(
|
||||
'--create_dummy_exam',
|
||||
action='store_true',
|
||||
dest='create_dummy_exam',
|
||||
help='create dummy exam info for course, even if course exists'
|
||||
),
|
||||
)
|
||||
args = "<student_username course_id>"
|
||||
help = "Create or modify a TestCenterRegistration entry for a given Student"
|
||||
|
||||
@staticmethod
|
||||
def is_valid_option(option_name):
|
||||
base_options = set(option.dest for option in BaseCommand.option_list)
|
||||
return option_name not in base_options
|
||||
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = args[0]
|
||||
course_id = args[1]
|
||||
print username, course_id
|
||||
|
||||
our_options = dict((k, v) for k, v in options.items()
|
||||
if Command.is_valid_option(k) and v is not None)
|
||||
try:
|
||||
student = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise CommandError("User \"{}\" does not exist".format(username))
|
||||
|
||||
try:
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
raise CommandError("User \"{}\" does not have an existing demographics record".format(username))
|
||||
|
||||
# get an "exam" object. Check to see if a course_id was specified, and use information from that:
|
||||
exam = None
|
||||
create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam']
|
||||
if not create_dummy_exam:
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
if 'ignore_registration_dates' in our_options:
|
||||
examlist = [exam for exam in course.test_center_exams if exam.exam_series_code == our_options.get('exam_series_code')]
|
||||
exam = examlist[0] if len(examlist) > 0 else None
|
||||
else:
|
||||
exam = course.current_test_center_exam
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
else:
|
||||
# otherwise use explicit values (so we don't have to define a course):
|
||||
exam_name = "Dummy Placeholder Name"
|
||||
exam_info = {'Exam_Series_Code': our_options['exam_series_code'],
|
||||
'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'],
|
||||
'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'],
|
||||
}
|
||||
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
|
||||
# update option values for date_first and date_last to use YYYY-MM-DD format
|
||||
# instead of YYYY-MM-DDTHH:MM
|
||||
our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
|
||||
if exam is None:
|
||||
raise CommandError("Exam for course_id {} does not exist".format(course_id))
|
||||
|
||||
exam_code = exam.exam_series_code
|
||||
|
||||
UPDATE_FIELDS = ('accommodation_request',
|
||||
'accommodation_code',
|
||||
'client_authorization_id',
|
||||
'exam_series_code',
|
||||
'eligibility_appointment_date_first',
|
||||
'eligibility_appointment_date_last',
|
||||
)
|
||||
|
||||
# create and save the registration:
|
||||
needs_updating = False
|
||||
registrations = get_testcenter_registration(student, course_id, exam_code)
|
||||
if len(registrations) > 0:
|
||||
registration = registrations[0]
|
||||
for fieldname in UPDATE_FIELDS:
|
||||
if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]:
|
||||
needs_updating = True;
|
||||
else:
|
||||
accommodation_request = our_options.get('accommodation_request', '')
|
||||
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
|
||||
needs_updating = True
|
||||
|
||||
|
||||
if needs_updating:
|
||||
# first update the record with the new values, if any:
|
||||
for fieldname in UPDATE_FIELDS:
|
||||
if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields:
|
||||
registration.__setattr__(fieldname, our_options[fieldname])
|
||||
|
||||
# the registration form normally populates the data dict with
|
||||
# the accommodation request (if any). But here we want to
|
||||
# specify only those values that might change, so update the dict with existing
|
||||
# values.
|
||||
form_options = dict(our_options)
|
||||
for propname in TestCenterRegistrationForm.Meta.fields:
|
||||
if propname not in form_options:
|
||||
form_options[propname] = registration.__getattribute__(propname)
|
||||
form = TestCenterRegistrationForm(instance=registration, data=form_options)
|
||||
if form.is_valid():
|
||||
form.update_and_save()
|
||||
print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code)
|
||||
else:
|
||||
if (len(form.errors) > 0):
|
||||
print "Field Form errors encountered:"
|
||||
for fielderror in form.errors:
|
||||
for msg in form.errors[fielderror]:
|
||||
print "Field Form Error: {} -- {}".format(fielderror, msg)
|
||||
if (len(form.non_field_errors()) > 0):
|
||||
print "Non-field Form errors encountered:"
|
||||
for nonfielderror in form.non_field_errors:
|
||||
print "Non-field Form Error: %s" % nonfielderror
|
||||
|
||||
else:
|
||||
print "No changes necessary to make to existing user's registration."
|
||||
|
||||
# override internal values:
|
||||
change_internal = False
|
||||
if 'exam_series_code' in our_options:
|
||||
exam_code = our_options['exam_series_code']
|
||||
registration = get_testcenter_registration(student, course_id, exam_code)[0]
|
||||
for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']:
|
||||
if internal_field in our_options:
|
||||
registration.__setattr__(internal_field, our_options[internal_field])
|
||||
change_internal = True
|
||||
|
||||
if change_internal:
|
||||
print "Updated confirmation information in existing user's registration."
|
||||
registration.save()
|
||||
else:
|
||||
print "No changes necessary to make to confirmation information in existing user's registration."
|
||||
@@ -1,190 +0,0 @@
|
||||
from optparse import make_option
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser, TestCenterUserForm
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
# demographics:
|
||||
make_option(
|
||||
'--first_name',
|
||||
action='store',
|
||||
dest='first_name',
|
||||
),
|
||||
make_option(
|
||||
'--middle_name',
|
||||
action='store',
|
||||
dest='middle_name',
|
||||
),
|
||||
make_option(
|
||||
'--last_name',
|
||||
action='store',
|
||||
dest='last_name',
|
||||
),
|
||||
make_option(
|
||||
'--suffix',
|
||||
action='store',
|
||||
dest='suffix',
|
||||
),
|
||||
make_option(
|
||||
'--salutation',
|
||||
action='store',
|
||||
dest='salutation',
|
||||
),
|
||||
make_option(
|
||||
'--address_1',
|
||||
action='store',
|
||||
dest='address_1',
|
||||
),
|
||||
make_option(
|
||||
'--address_2',
|
||||
action='store',
|
||||
dest='address_2',
|
||||
),
|
||||
make_option(
|
||||
'--address_3',
|
||||
action='store',
|
||||
dest='address_3',
|
||||
),
|
||||
make_option(
|
||||
'--city',
|
||||
action='store',
|
||||
dest='city',
|
||||
),
|
||||
make_option(
|
||||
'--state',
|
||||
action='store',
|
||||
dest='state',
|
||||
help='Two letter code (e.g. MA)'
|
||||
),
|
||||
make_option(
|
||||
'--postal_code',
|
||||
action='store',
|
||||
dest='postal_code',
|
||||
),
|
||||
make_option(
|
||||
'--country',
|
||||
action='store',
|
||||
dest='country',
|
||||
help='Three letter country code (ISO 3166-1 alpha-3), like USA'
|
||||
),
|
||||
make_option(
|
||||
'--phone',
|
||||
action='store',
|
||||
dest='phone',
|
||||
help='Pretty free-form (parens, spaces, dashes), but no country code'
|
||||
),
|
||||
make_option(
|
||||
'--extension',
|
||||
action='store',
|
||||
dest='extension',
|
||||
),
|
||||
make_option(
|
||||
'--phone_country_code',
|
||||
action='store',
|
||||
dest='phone_country_code',
|
||||
help='Phone country code, just "1" for the USA'
|
||||
),
|
||||
make_option(
|
||||
'--fax',
|
||||
action='store',
|
||||
dest='fax',
|
||||
help='Pretty free-form (parens, spaces, dashes), but no country code'
|
||||
),
|
||||
make_option(
|
||||
'--fax_country_code',
|
||||
action='store',
|
||||
dest='fax_country_code',
|
||||
help='Fax country code, just "1" for the USA'
|
||||
),
|
||||
make_option(
|
||||
'--company_name',
|
||||
action='store',
|
||||
dest='company_name',
|
||||
),
|
||||
# internal values:
|
||||
make_option(
|
||||
'--client_candidate_id',
|
||||
action='store',
|
||||
dest='client_candidate_id',
|
||||
help='ID we assign a user to identify them to Pearson'
|
||||
),
|
||||
make_option(
|
||||
'--upload_status',
|
||||
action='store',
|
||||
dest='upload_status',
|
||||
help='status value assigned by Pearson'
|
||||
),
|
||||
make_option(
|
||||
'--upload_error_message',
|
||||
action='store',
|
||||
dest='upload_error_message',
|
||||
help='error message provided by Pearson on a failure.'
|
||||
),
|
||||
)
|
||||
args = "<student_username>"
|
||||
help = "Create or modify a TestCenterUser entry for a given Student"
|
||||
|
||||
@staticmethod
|
||||
def is_valid_option(option_name):
|
||||
base_options = set(option.dest for option in BaseCommand.option_list)
|
||||
return option_name not in base_options
|
||||
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = args[0]
|
||||
print username
|
||||
|
||||
our_options = dict((k, v) for k, v in options.items()
|
||||
if Command.is_valid_option(k) and v is not None)
|
||||
student = User.objects.get(username=username)
|
||||
try:
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
needs_updating = testcenter_user.needs_update(our_options)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
# do additional initialization here:
|
||||
testcenter_user = TestCenterUser.create(student)
|
||||
needs_updating = True
|
||||
|
||||
if needs_updating:
|
||||
# the registration form normally populates the data dict with
|
||||
# all values from the testcenter_user. But here we only want to
|
||||
# specify those values that change, so update the dict with existing
|
||||
# values.
|
||||
form_options = dict(our_options)
|
||||
for propname in TestCenterUser.user_provided_fields():
|
||||
if propname not in form_options:
|
||||
form_options[propname] = testcenter_user.__getattribute__(propname)
|
||||
form = TestCenterUserForm(instance=testcenter_user, data=form_options)
|
||||
if form.is_valid():
|
||||
form.update_and_save()
|
||||
else:
|
||||
errorlist = []
|
||||
if (len(form.errors) > 0):
|
||||
errorlist.append("Field Form errors encountered:")
|
||||
for fielderror in form.errors:
|
||||
errorlist.append("Field Form Error: {}".format(fielderror))
|
||||
if (len(form.non_field_errors()) > 0):
|
||||
errorlist.append("Non-field Form errors encountered:")
|
||||
for nonfielderror in form.non_field_errors:
|
||||
errorlist.append("Non-field Form Error: {}".format(nonfielderror))
|
||||
raise CommandError("\n".join(errorlist))
|
||||
else:
|
||||
print "No changes necessary to make to existing user's demographics."
|
||||
|
||||
# override internal values:
|
||||
change_internal = False
|
||||
testcenter_user = TestCenterUser.objects.get(user=student)
|
||||
for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']:
|
||||
if internal_field in our_options:
|
||||
testcenter_user.__setattr__(internal_field, our_options[internal_field])
|
||||
change_internal = True
|
||||
|
||||
if change_internal:
|
||||
testcenter_user.save()
|
||||
print "Updated confirmation information in existing user's demographics."
|
||||
else:
|
||||
print "No changes necessary to make to confirmation information in existing user's demographics."
|
||||
@@ -1,167 +0,0 @@
|
||||
from optparse import make_option
|
||||
import os
|
||||
from stat import S_ISDIR
|
||||
|
||||
import boto
|
||||
from dogapi import dog_http_api, dog_stats_api
|
||||
import paramiko
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import django_startup
|
||||
|
||||
|
||||
django_startup.autostartup()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """
|
||||
This command handles the importing and exporting of student records for
|
||||
Pearson. It uses some other Django commands to export and import the
|
||||
files and then uploads over SFTP to Pearson and stuffs the entry in an
|
||||
S3 bucket for archive purposes.
|
||||
|
||||
Usage: ./manage.py pearson-transfer --mode [import|export|both]
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--mode',
|
||||
action='store',
|
||||
dest='mode',
|
||||
default='both',
|
||||
choices=('import', 'export', 'both'),
|
||||
help='mode is import, export, or both'),
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
|
||||
if not hasattr(settings, 'PEARSON'):
|
||||
raise CommandError('No PEARSON entries in auth/env.json.')
|
||||
|
||||
# check settings needed for either import or export:
|
||||
for value in ['SFTP_HOSTNAME', 'SFTP_USERNAME', 'SFTP_PASSWORD', 'S3_BUCKET']:
|
||||
if value not in settings.PEARSON:
|
||||
raise CommandError('No entry in the PEARSON settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
|
||||
for value in ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY']:
|
||||
if not hasattr(settings, value):
|
||||
raise CommandError('No entry in the AWS settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
|
||||
# check additional required settings for import and export:
|
||||
if options['mode'] in ('export', 'both'):
|
||||
for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']:
|
||||
if value not in settings.PEARSON:
|
||||
raise CommandError('No entry in the PEARSON settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
# make sure that the import directory exists or can be created:
|
||||
source_dir = settings.PEARSON['LOCAL_EXPORT']
|
||||
if not os.path.isdir(source_dir):
|
||||
os.makedirs(source_dir)
|
||||
|
||||
if options['mode'] in ('import', 'both'):
|
||||
for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']:
|
||||
if value not in settings.PEARSON:
|
||||
raise CommandError('No entry in the PEARSON settings'
|
||||
'(env/auth.json) for {0}'.format(value))
|
||||
# make sure that the import directory exists or can be created:
|
||||
dest_dir = settings.PEARSON['LOCAL_IMPORT']
|
||||
if not os.path.isdir(dest_dir):
|
||||
os.makedirs(dest_dir)
|
||||
|
||||
|
||||
def sftp(files_from, files_to, mode, deleteAfterCopy=False):
|
||||
with dog_stats_api.timer('pearson.{0}'.format(mode), tags='sftp'):
|
||||
try:
|
||||
t = paramiko.Transport((settings.PEARSON['SFTP_HOSTNAME'], 22))
|
||||
t.connect(username=settings.PEARSON['SFTP_USERNAME'],
|
||||
password=settings.PEARSON['SFTP_PASSWORD'])
|
||||
sftp = paramiko.SFTPClient.from_transport(t)
|
||||
|
||||
if mode == 'export':
|
||||
try:
|
||||
sftp.chdir(files_to)
|
||||
except IOError:
|
||||
raise CommandError('SFTP destination path does not exist: {}'.format(files_to))
|
||||
for filename in os.listdir(files_from):
|
||||
sftp.put(files_from + '/' + filename, filename)
|
||||
if deleteAfterCopy:
|
||||
os.remove(os.path.join(files_from, filename))
|
||||
else:
|
||||
try:
|
||||
sftp.chdir(files_from)
|
||||
except IOError:
|
||||
raise CommandError('SFTP source path does not exist: {}'.format(files_from))
|
||||
for filename in sftp.listdir('.'):
|
||||
# skip subdirectories
|
||||
if not S_ISDIR(sftp.stat(filename).st_mode):
|
||||
sftp.get(filename, files_to + '/' + filename)
|
||||
# delete files from sftp server once they are successfully pulled off:
|
||||
if deleteAfterCopy:
|
||||
sftp.remove(filename)
|
||||
except:
|
||||
dog_http_api.event('pearson {0}'.format(mode),
|
||||
'sftp uploading failed',
|
||||
alert_type='error')
|
||||
raise
|
||||
finally:
|
||||
sftp.close()
|
||||
t.close()
|
||||
|
||||
def s3(files_from, bucket, mode, deleteAfterCopy=False):
|
||||
with dog_stats_api.timer('pearson.{0}'.format(mode), tags='s3'):
|
||||
try:
|
||||
for filename in os.listdir(files_from):
|
||||
source_file = os.path.join(files_from, filename)
|
||||
# use mode as name of directory into which to write files
|
||||
dest_file = os.path.join(mode, filename)
|
||||
upload_file_to_s3(bucket, source_file, dest_file)
|
||||
if deleteAfterCopy:
|
||||
os.remove(files_from + '/' + filename)
|
||||
except:
|
||||
dog_http_api.event('pearson {0}'.format(mode),
|
||||
's3 archiving failed')
|
||||
raise
|
||||
|
||||
def upload_file_to_s3(bucket, source_file, dest_file):
|
||||
"""
|
||||
Upload file to S3
|
||||
"""
|
||||
s3 = boto.connect_s3(settings.AWS_ACCESS_KEY_ID,
|
||||
settings.AWS_SECRET_ACCESS_KEY)
|
||||
from boto.s3.key import Key
|
||||
b = s3.get_bucket(bucket)
|
||||
k = Key(b)
|
||||
k.key = "{filename}".format(filename=dest_file)
|
||||
k.set_contents_from_filename(source_file)
|
||||
|
||||
def export_pearson():
|
||||
options = {'dest-from-settings': True}
|
||||
call_command('pearson_export_cdd', **options)
|
||||
call_command('pearson_export_ead', **options)
|
||||
mode = 'export'
|
||||
sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False)
|
||||
s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True)
|
||||
|
||||
def import_pearson():
|
||||
mode = 'import'
|
||||
try:
|
||||
sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True)
|
||||
s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False)
|
||||
except Exception as e:
|
||||
dog_http_api.event('Pearson Import failure', str(e))
|
||||
raise e
|
||||
else:
|
||||
for filename in os.listdir(settings.PEARSON['LOCAL_IMPORT']):
|
||||
filepath = os.path.join(settings.PEARSON['LOCAL_IMPORT'], filename)
|
||||
call_command('pearson_import_conf_zip', filepath)
|
||||
os.remove(filepath)
|
||||
|
||||
# actually do the work!
|
||||
if options['mode'] in ('export', 'both'):
|
||||
export_pearson()
|
||||
if options['mode'] in ('import', 'both'):
|
||||
import_pearson()
|
||||
@@ -1,380 +0,0 @@
|
||||
'''
|
||||
Created on Jan 17, 2013
|
||||
|
||||
@author: brian
|
||||
'''
|
||||
import logging
|
||||
import os
|
||||
from tempfile import mkdtemp
|
||||
import cStringIO
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from student.models import User, TestCenterUser, get_testcenter_registration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_tc_user(username):
|
||||
user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {
|
||||
'first_name': 'TestFirst',
|
||||
'last_name': 'TestLast',
|
||||
'address_1': 'Test Address',
|
||||
'city': 'TestCity',
|
||||
'state': 'Alberta',
|
||||
'postal_code': 'A0B 1C2',
|
||||
'country': 'CAN',
|
||||
'phone': '252-1866',
|
||||
'phone_country_code': '1',
|
||||
}
|
||||
call_command('pearson_make_tc_user', username, **options)
|
||||
return TestCenterUser.objects.get(user=user)
|
||||
|
||||
|
||||
def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None):
|
||||
|
||||
options = {'exam_series_code': exam_code,
|
||||
'eligibility_appointment_date_first': '2013-01-01T00:00',
|
||||
'eligibility_appointment_date_last': '2013-12-31T23:59',
|
||||
'accommodation_code': accommodation_code,
|
||||
'create_dummy_exam': True,
|
||||
}
|
||||
|
||||
call_command('pearson_make_tc_registration', username, course_id, **options)
|
||||
user = User.objects.get(username=username)
|
||||
registrations = get_testcenter_registration(user, course_id, exam_code)
|
||||
return registrations[0]
|
||||
|
||||
|
||||
def create_multiple_registrations(prefix='test'):
|
||||
username1 = '{}_multiple1'.format(prefix)
|
||||
create_tc_user(username1)
|
||||
create_tc_registration(username1)
|
||||
create_tc_registration(username1, course_id='org1/course2/term1')
|
||||
create_tc_registration(username1, exam_code='exam2')
|
||||
username2 = '{}_multiple2'.format(prefix)
|
||||
create_tc_user(username2)
|
||||
create_tc_registration(username2)
|
||||
username3 = '{}_multiple3'.format(prefix)
|
||||
create_tc_user(username3)
|
||||
create_tc_registration(username3, course_id='org1/course2/term1')
|
||||
username4 = '{}_multiple4'.format(prefix)
|
||||
create_tc_user(username4)
|
||||
create_tc_registration(username4, exam_code='exam2')
|
||||
|
||||
|
||||
def get_command_error_text(*args, **options):
|
||||
stderr_string = None
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = cStringIO.StringIO()
|
||||
try:
|
||||
call_command(*args, **options)
|
||||
except SystemExit, why1:
|
||||
# The goal here is to catch CommandError calls.
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message > 0):
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
else:
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
if stderr_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stderr_string
|
||||
|
||||
|
||||
def get_error_string_for_management_call(*args, **options):
|
||||
stdout_string = None
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
sys.stdout = cStringIO.StringIO()
|
||||
sys.stderr = cStringIO.StringIO()
|
||||
try:
|
||||
call_command(*args, **options)
|
||||
except SystemExit, why1:
|
||||
# The goal here is to catch CommandError calls.
|
||||
# But these are actually translated into nice messages,
|
||||
# and sys.exit(1) is then called. For testing, we
|
||||
# want to catch what sys.exit throws, and get the
|
||||
# relevant text either from stdout or stderr.
|
||||
if (why1.message == 1):
|
||||
stdout_string = sys.stdout.getvalue()
|
||||
stderr_string = sys.stderr.getvalue()
|
||||
else:
|
||||
raise why1
|
||||
except Exception, why:
|
||||
raise why
|
||||
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
if stdout_string is None:
|
||||
raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0]))
|
||||
return stdout_string, stderr_string
|
||||
|
||||
|
||||
def get_file_info(dirpath):
|
||||
filelist = os.listdir(dirpath)
|
||||
print 'Files found: {}'.format(filelist)
|
||||
numfiles = len(filelist)
|
||||
if numfiles == 1:
|
||||
filepath = os.path.join(dirpath, filelist[0])
|
||||
with open(filepath, 'r') as cddfile:
|
||||
filecontents = cddfile.readlines()
|
||||
numlines = len(filecontents)
|
||||
return filepath, numlines
|
||||
else:
|
||||
raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist))
|
||||
|
||||
|
||||
class PearsonTestCase(TestCase):
|
||||
'''
|
||||
Base class for tests running Pearson-related commands
|
||||
'''
|
||||
|
||||
def assertErrorContains(self, error_message, expected):
|
||||
self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected))
|
||||
|
||||
def setUp(self):
|
||||
self.import_dir = mkdtemp(prefix="import")
|
||||
self.addCleanup(shutil.rmtree, self.import_dir)
|
||||
self.export_dir = mkdtemp(prefix="export")
|
||||
self.addCleanup(shutil.rmtree, self.export_dir)
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
# and clean up the database:
|
||||
# TestCenterUser.objects.all().delete()
|
||||
# TestCenterRegistration.objects.all().delete()
|
||||
|
||||
|
||||
class PearsonCommandTestCase(PearsonTestCase):
|
||||
|
||||
def test_missing_demographic_fields(self):
|
||||
# We won't bother to test all details of form validation here.
|
||||
# It is enough to show that it works here, but deal with test cases for the form
|
||||
# validation in the student tests, not these management tests.
|
||||
username = 'baduser'
|
||||
User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass')
|
||||
options = {}
|
||||
error_string = get_command_error_text('pearson_make_tc_user', username, **options)
|
||||
self.assertTrue(error_string.find('Field Form errors encountered:') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: city') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: first_name') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: last_name') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: country') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: phone_country_code') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: phone') >= 0)
|
||||
self.assertTrue(error_string.find('Field Form Error: address_1') >= 0)
|
||||
self.assertErrorContains(error_string, 'Field Form Error: address_1')
|
||||
|
||||
def test_create_good_testcenter_user(self):
|
||||
testcenter_user = create_tc_user("test_good_user")
|
||||
self.assertIsNotNone(testcenter_user)
|
||||
|
||||
def test_create_good_testcenter_registration(self):
|
||||
username = 'test_good_registration'
|
||||
create_tc_user(username)
|
||||
registration = create_tc_registration(username)
|
||||
self.assertIsNotNone(registration)
|
||||
|
||||
def test_cdd_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_cdd', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
def test_ead_missing_option(self):
|
||||
error_string = get_command_error_text('pearson_export_ead', **{})
|
||||
self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used')
|
||||
|
||||
def test_export_single_cdd(self):
|
||||
# before we generate any tc_users, we expect there to be nothing to output:
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a tc_user should result in a line in the output
|
||||
username = 'test_single_cdd'
|
||||
create_tc_user(username)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
# output after registration should not have any entries again.
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
user_options = {'first_name': 'NewTestFirst', }
|
||||
call_command('pearson_make_tc_user', username, **user_options)
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
def test_export_single_ead(self):
|
||||
# before we generate any registrations, we expect there to be nothing to output:
|
||||
options = {'dest-from-settings': True}
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# generating a registration should result in a line in the output
|
||||
username = 'test_single_ead'
|
||||
create_tc_user(username)
|
||||
create_tc_registration(username)
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect ead file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
# output after registration should not have any entries again.
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines")
|
||||
os.remove(filepath)
|
||||
|
||||
# if we modify the record, then it should be output again:
|
||||
create_tc_registration(username, accommodation_code='EQPMNT')
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 2, "Expect ead file to have one non-header line")
|
||||
os.remove(filepath)
|
||||
|
||||
def test_export_multiple(self):
|
||||
create_multiple_registrations("export")
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}):
|
||||
options = {'dest-from-settings': True}
|
||||
call_command('pearson_export_cdd', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines))
|
||||
os.remove(filepath)
|
||||
|
||||
call_command('pearson_export_ead', **options)
|
||||
(filepath, numlines) = get_file_info(self.export_dir)
|
||||
self.assertEquals(numlines, 7, "Expect ead file to have six non-header lines: total was {}".format(numlines))
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
# def test_bad_demographic_option(self):
|
||||
# username = 'nonuser'
|
||||
# output_string, stderrmsg = get_error_string_for_management_call('pearson_make_tc_user', username, **{'--garbage' : None })
|
||||
# print stderrmsg
|
||||
# self.assertErrorContains(stderrmsg, 'Unexpected option')
|
||||
#
|
||||
# def test_missing_demographic_user(self):
|
||||
# username = 'nonuser'
|
||||
# output_string, error_string = get_error_string_for_management_call('pearson_make_tc_user', username, **{})
|
||||
# self.assertErrorContains(error_string, 'User matching query does not exist')
|
||||
|
||||
# credentials for a test SFTP site:
|
||||
SFTP_HOSTNAME = 'ec2-23-20-150-101.compute-1.amazonaws.com'
|
||||
SFTP_USERNAME = 'pearsontest'
|
||||
SFTP_PASSWORD = 'password goes here'
|
||||
|
||||
S3_BUCKET = 'edx-pearson-archive'
|
||||
AWS_ACCESS_KEY_ID = 'put yours here'
|
||||
AWS_SECRET_ACCESS_KEY = 'put yours here'
|
||||
|
||||
|
||||
class PearsonTransferTestCase(PearsonTestCase):
|
||||
'''
|
||||
Class for tests running Pearson transfers
|
||||
'''
|
||||
|
||||
def test_transfer_config(self):
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'})
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries')
|
||||
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'LOCAL_IMPORT': self.import_dir}):
|
||||
stderrmsg = get_command_error_text('pearson_transfer')
|
||||
self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings')
|
||||
|
||||
def test_transfer_export_missing_dest_dir(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('export_missing_dest')
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist')
|
||||
|
||||
def test_transfer_export(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations("transfer_export")
|
||||
with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir,
|
||||
'SFTP_EXPORT': 'results/topvue',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'export'}
|
||||
# call_command('pearson_transfer', **options)
|
||||
# # confirm that the export directory is still empty:
|
||||
# self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty")
|
||||
|
||||
def test_transfer_import_missing_source_dir(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'this/does/not/exist',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
stderrmsg = get_command_error_text('pearson_transfer', **options)
|
||||
self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist')
|
||||
|
||||
def test_transfer_import(self):
|
||||
raise SkipTest()
|
||||
create_multiple_registrations('import_missing_src')
|
||||
with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir,
|
||||
'SFTP_IMPORT': 'results',
|
||||
'SFTP_HOSTNAME': SFTP_HOSTNAME,
|
||||
'SFTP_USERNAME': SFTP_USERNAME,
|
||||
'SFTP_PASSWORD': SFTP_PASSWORD,
|
||||
'S3_BUCKET': S3_BUCKET,
|
||||
},
|
||||
AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY):
|
||||
options = {'mode': 'import'}
|
||||
call_command('pearson_transfer', **options)
|
||||
self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty")
|
||||
185
common/djangoapps/student/migrations/0029_remove_pearson.py
Normal file
185
common/djangoapps/student/migrations/0029_remove_pearson.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Deleting model 'TestCenterUser'
|
||||
db.delete_table('student_testcenteruser')
|
||||
|
||||
# Deleting model 'TestCenterRegistration'
|
||||
db.delete_table('student_testcenterregistration')
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Adding model 'TestCenterUser'
|
||||
db.create_table('student_testcenteruser', (
|
||||
('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)),
|
||||
('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)),
|
||||
('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
|
||||
('postal_code', self.gf('django.db.models.fields.CharField')(blank=True, max_length=16, db_index=True)),
|
||||
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
|
||||
('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)),
|
||||
('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)),
|
||||
('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('state', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
|
||||
('company_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=50, db_index=True)),
|
||||
('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
|
||||
('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)),
|
||||
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('phone', self.gf('django.db.models.fields.CharField')(max_length=35)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)),
|
||||
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(blank=True, null=True, db_index=True)),
|
||||
('extension', self.gf('django.db.models.fields.CharField')(blank=True, max_length=8, db_index=True)),
|
||||
('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)),
|
||||
('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, unique=True, db_index=True)),
|
||||
('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)),
|
||||
('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['TestCenterUser'])
|
||||
|
||||
# Adding model 'TestCenterRegistration'
|
||||
db.create_table('student_testcenterregistration', (
|
||||
('client_authorization_id', self.gf('django.db.models.fields.CharField')(max_length=20, unique=True, db_index=True)),
|
||||
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('authorization_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
|
||||
('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)),
|
||||
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)),
|
||||
('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)),
|
||||
('accommodation_request', self.gf('django.db.models.fields.CharField')(max_length=1024, blank=True)),
|
||||
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
('exam_series_code', self.gf('django.db.models.fields.CharField')(max_length=15, db_index=True)),
|
||||
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)),
|
||||
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
|
||||
('testcenter_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['student.TestCenterUser'])),
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['TestCenterRegistration'])
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.userstanding': {
|
||||
'Meta': {'object_name': 'UserStanding'},
|
||||
'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -11,7 +11,6 @@ file and check it in at the same time as your model changes. To do that,
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from datetime import datetime
|
||||
from random import randint
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -22,7 +21,7 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.dispatch import receiver, Signal
|
||||
import django.dispatch
|
||||
from django.forms import ModelForm, forms
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -37,7 +36,7 @@ from track.views import server_track
|
||||
from eventtracking import tracker
|
||||
|
||||
|
||||
unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"])
|
||||
unenroll_done = Signal(providing_args=["course_enrollment"])
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
AUDIT_LOG = logging.getLogger("audit")
|
||||
@@ -202,480 +201,6 @@ class UserProfile(models.Model):
|
||||
def set_meta(self, js):
|
||||
self.meta = json.dumps(js)
|
||||
|
||||
TEST_CENTER_STATUS_ACCEPTED = "Accepted"
|
||||
TEST_CENTER_STATUS_ERROR = "Error"
|
||||
|
||||
|
||||
class TestCenterUser(models.Model):
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
specifically for Pearson at this point. A few things to note:
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding.
|
||||
* While we have a lot of this demographic data in UserProfile, it's much
|
||||
more free-structured there. We'll try to pre-pop the form with data from
|
||||
UserProfile, but we'll need to have a step where people who are signing
|
||||
up re-enter their demographic data into the fields we specify.
|
||||
* Users are only created here if they register to take an exam in person.
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system, including oddities such as suffix having
|
||||
a limit of 255 while last_name only gets 50.
|
||||
|
||||
Also storing here the confirmation information received from Pearson (if any)
|
||||
as to the success or failure of the upload. (VCDC file)
|
||||
"""
|
||||
# Our own record keeping...
|
||||
user = models.ForeignKey(User, unique=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
# user_updated_at happens only when the user makes a change to their data,
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
|
||||
# Unique ID we assign our user for the Test Center.
|
||||
client_candidate_id = models.CharField(unique=True, max_length=50, db_index=True)
|
||||
|
||||
# Name
|
||||
first_name = models.CharField(max_length=30, db_index=True)
|
||||
last_name = models.CharField(max_length=50, db_index=True)
|
||||
middle_name = models.CharField(max_length=30, blank=True)
|
||||
suffix = models.CharField(max_length=255, blank=True)
|
||||
salutation = models.CharField(max_length=50, blank=True)
|
||||
|
||||
# Address
|
||||
address_1 = models.CharField(max_length=40)
|
||||
address_2 = models.CharField(max_length=40, blank=True)
|
||||
address_3 = models.CharField(max_length=40, blank=True)
|
||||
city = models.CharField(max_length=32, db_index=True)
|
||||
# state example: HI -- they have an acceptable list that we'll just plug in
|
||||
# state is required if you're in the US or Canada, but otherwise not.
|
||||
state = models.CharField(max_length=20, blank=True, db_index=True)
|
||||
# postal_code required if you're in the US or Canada
|
||||
postal_code = models.CharField(max_length=16, blank=True, db_index=True)
|
||||
# country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
|
||||
country = models.CharField(max_length=3, db_index=True)
|
||||
|
||||
# Phone
|
||||
phone = models.CharField(max_length=35)
|
||||
extension = models.CharField(max_length=8, blank=True, db_index=True)
|
||||
phone_country_code = models.CharField(max_length=3, db_index=True)
|
||||
fax = models.CharField(max_length=35, blank=True)
|
||||
# fax_country_code required *if* fax is present.
|
||||
fax_country_code = models.CharField(max_length=3, blank=True)
|
||||
|
||||
# Company
|
||||
company_name = models.CharField(max_length=50, blank=True, db_index=True)
|
||||
|
||||
# time at which edX sent the registration to the test center
|
||||
uploaded_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
|
||||
# confirmation back from the test center, as well as timestamps
|
||||
# on when they processed the request, and when we received
|
||||
# confirmation back.
|
||||
processed_at = models.DateTimeField(null=True, db_index=True)
|
||||
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
|
||||
upload_error_message = models.CharField(max_length=512, blank=True)
|
||||
# Unique ID given to us for this User by the Testing Center. It's null when
|
||||
# we first create the User entry, and may be assigned by Pearson later.
|
||||
# (However, it may never be set if we are always initiating such candidate creation.)
|
||||
candidate_id = models.IntegerField(null=True, db_index=True)
|
||||
confirmed_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@property
|
||||
def needs_uploading(self):
|
||||
return self.uploaded_at is None or self.uploaded_at < self.user_updated_at
|
||||
|
||||
@staticmethod
|
||||
def user_provided_fields():
|
||||
return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name']
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
return self.user.email
|
||||
|
||||
def needs_update(self, fields):
|
||||
for fieldname in TestCenterUser.user_provided_fields():
|
||||
if fieldname in fields and getattr(self, fieldname) != fields[fieldname]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _generate_edx_id(prefix):
|
||||
NUM_DIGITS = 12
|
||||
return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1))
|
||||
|
||||
@staticmethod
|
||||
def _generate_candidate_id():
|
||||
return TestCenterUser._generate_edx_id("edX")
|
||||
|
||||
@classmethod
|
||||
def create(cls, user):
|
||||
testcenter_user = cls(user=user)
|
||||
# testcenter_user.candidate_id remains unset
|
||||
# assign an ID of our own:
|
||||
cand_id = cls._generate_candidate_id()
|
||||
while TestCenterUser.objects.filter(client_candidate_id=cand_id).exists():
|
||||
cand_id = cls._generate_candidate_id()
|
||||
testcenter_user.client_candidate_id = cand_id
|
||||
return testcenter_user
|
||||
|
||||
@property
|
||||
def is_accepted(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ERROR
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
return not self.is_accepted and not self.is_rejected
|
||||
|
||||
|
||||
class TestCenterUserForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterUser
|
||||
fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation',
|
||||
'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country',
|
||||
'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name')
|
||||
|
||||
def update_and_save(self):
|
||||
new_user = self.save(commit=False)
|
||||
# create additional values here:
|
||||
new_user.user_updated_at = datetime.now(UTC)
|
||||
new_user.upload_status = ''
|
||||
new_user.save()
|
||||
log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username))
|
||||
|
||||
# add validation:
|
||||
|
||||
def clean_country(self):
|
||||
code = self.cleaned_data['country']
|
||||
if code and (len(code) != 3 or not code.isalpha()):
|
||||
raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG')
|
||||
return code.upper()
|
||||
|
||||
def clean(self):
|
||||
def _can_encode_as_latin(fieldvalue):
|
||||
try:
|
||||
fieldvalue.encode('iso-8859-1')
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
cleaned_data = super(TestCenterUserForm, self).clean()
|
||||
|
||||
# check for interactions between fields:
|
||||
if 'country' in cleaned_data:
|
||||
country = cleaned_data.get('country')
|
||||
if country == 'USA' or country == 'CAN':
|
||||
if 'state' in cleaned_data and len(cleaned_data['state']) == 0:
|
||||
self._errors['state'] = self.error_class([u'Required if country is USA or CAN.'])
|
||||
del cleaned_data['state']
|
||||
|
||||
if 'postal_code' in cleaned_data and len(cleaned_data['postal_code']) == 0:
|
||||
self._errors['postal_code'] = self.error_class([u'Required if country is USA or CAN.'])
|
||||
del cleaned_data['postal_code']
|
||||
|
||||
if 'fax' in cleaned_data and len(cleaned_data['fax']) > 0 and 'fax_country_code' in cleaned_data and len(cleaned_data['fax_country_code']) == 0:
|
||||
self._errors['fax_country_code'] = self.error_class([u'Required if fax is specified.'])
|
||||
del cleaned_data['fax_country_code']
|
||||
|
||||
# check encoding for all fields:
|
||||
cleaned_data_fields = [fieldname for fieldname in cleaned_data]
|
||||
for fieldname in cleaned_data_fields:
|
||||
if not _can_encode_as_latin(cleaned_data[fieldname]):
|
||||
self._errors[fieldname] = self.error_class([u'Must only use characters in Latin-1 (iso-8859-1) encoding'])
|
||||
del cleaned_data[fieldname]
|
||||
|
||||
# Always return the full collection of cleaned data.
|
||||
return cleaned_data
|
||||
|
||||
# our own code to indicate that a request has been rejected.
|
||||
ACCOMMODATION_REJECTED_CODE = 'NONE'
|
||||
|
||||
ACCOMMODATION_CODES = (
|
||||
(ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'),
|
||||
('EQPMNT', 'Equipment'),
|
||||
('ET12ET', 'Extra Time - 1/2 Exam Time'),
|
||||
('ET30MN', 'Extra Time - 30 Minutes'),
|
||||
('ETDBTM', 'Extra Time - Double Time'),
|
||||
('SEPRMM', 'Separate Room'),
|
||||
('SRREAD', 'Separate Room and Reader'),
|
||||
('SRRERC', 'Separate Room and Reader/Recorder'),
|
||||
('SRRECR', 'Separate Room and Recorder'),
|
||||
('SRSEAN', 'Separate Room and Service Animal'),
|
||||
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
|
||||
)
|
||||
|
||||
ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES}
|
||||
|
||||
|
||||
class TestCenterRegistration(models.Model):
|
||||
"""
|
||||
This is our representation of a user's registration for in-person testing,
|
||||
and specifically for Pearson at this point. A few things to note:
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding. This is less of an issue
|
||||
than for the TestCenterUser.
|
||||
* Registrations are only created here when a user registers to take an exam in person.
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system.
|
||||
"""
|
||||
# to find an exam registration, we key off of the user and course_id.
|
||||
# If multiple exams per course are possible, we would also need to add the
|
||||
# exam_series_code.
|
||||
testcenter_user = models.ForeignKey(TestCenterUser, default=None)
|
||||
course_id = models.CharField(max_length=128, db_index=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
# user_updated_at happens only when the user makes a change to their data,
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
# The appointment dates, the exam count, and the accommodation codes can be updated,
|
||||
# but hopefully this won't happen often.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
# "client_authorization_id" is our unique identifier for the authorization.
|
||||
# This must be present for an update or delete to be sent to Pearson.
|
||||
client_authorization_id = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
|
||||
# information about the test, from the course policy:
|
||||
exam_series_code = models.CharField(max_length=15, db_index=True)
|
||||
eligibility_appointment_date_first = models.DateField(db_index=True)
|
||||
eligibility_appointment_date_last = models.DateField(db_index=True)
|
||||
|
||||
# this is really a list of codes, using an '*' as a delimiter.
|
||||
# So it's not a choice list. We use the special value of ACCOMMODATION_REJECTED_CODE
|
||||
# to indicate the rejection of an accommodation request.
|
||||
accommodation_code = models.CharField(max_length=64, blank=True)
|
||||
|
||||
# store the original text of the accommodation request.
|
||||
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False)
|
||||
|
||||
# time at which edX sent the registration to the test center
|
||||
uploaded_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
# confirmation back from the test center, as well as timestamps
|
||||
# on when they processed the request, and when we received
|
||||
# confirmation back.
|
||||
processed_at = models.DateTimeField(null=True, db_index=True)
|
||||
upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted'
|
||||
upload_error_message = models.CharField(max_length=512, blank=True)
|
||||
# Unique ID given to us for this registration by the Testing Center. It's null when
|
||||
# we first create the registration entry, and may be assigned by Pearson later.
|
||||
# (However, it may never be set if we are always initiating such candidate creation.)
|
||||
authorization_id = models.IntegerField(null=True, db_index=True)
|
||||
confirmed_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@property
|
||||
def candidate_id(self):
|
||||
return self.testcenter_user.candidate_id
|
||||
|
||||
@property
|
||||
def client_candidate_id(self):
|
||||
return self.testcenter_user.client_candidate_id
|
||||
|
||||
@property
|
||||
def authorization_transaction_type(self):
|
||||
if self.authorization_id is not None:
|
||||
return 'Update'
|
||||
elif self.uploaded_at is None:
|
||||
return 'Add'
|
||||
elif self.registration_is_rejected:
|
||||
# Assume that if the registration was rejected before,
|
||||
# it is more likely this is the (first) correction
|
||||
# than a second correction in flight before the first was
|
||||
# processed.
|
||||
return 'Add'
|
||||
else:
|
||||
# TODO: decide what to send when we have uploaded an initial version,
|
||||
# but have not received confirmation back from that upload. If the
|
||||
# registration here has been changed, then we don't know if this changed
|
||||
# registration should be submitted as an 'add' or an 'update'.
|
||||
#
|
||||
# If the first registration were lost or in error (e.g. bad code),
|
||||
# the second should be an "Add". If the first were processed successfully,
|
||||
# then the second should be an "Update". We just don't know....
|
||||
return 'Update'
|
||||
|
||||
@property
|
||||
def exam_authorization_count(self):
|
||||
# Someday this could go in the database (with a default value). But at present,
|
||||
# we do not expect anyone to be authorized to take an exam more than once.
|
||||
return 1
|
||||
|
||||
@property
|
||||
def needs_uploading(self):
|
||||
return self.uploaded_at is None or self.uploaded_at < self.user_updated_at
|
||||
|
||||
@classmethod
|
||||
def create(cls, testcenter_user, exam, accommodation_request):
|
||||
registration = cls(testcenter_user=testcenter_user)
|
||||
registration.course_id = exam.course_id
|
||||
registration.accommodation_request = accommodation_request.strip()
|
||||
registration.exam_series_code = exam.exam_series_code
|
||||
registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.client_authorization_id = cls._create_client_authorization_id()
|
||||
# accommodation_code remains blank for now, along with Pearson confirmation information
|
||||
return registration
|
||||
|
||||
@staticmethod
|
||||
def _generate_authorization_id():
|
||||
return TestCenterUser._generate_edx_id("edXexam")
|
||||
|
||||
@staticmethod
|
||||
def _create_client_authorization_id():
|
||||
"""
|
||||
Return a unique id for a registration, suitable for using as an authorization code
|
||||
for Pearson. It must fit within 20 characters.
|
||||
"""
|
||||
# generate a random value, and check to see if it already is in use here
|
||||
auth_id = TestCenterRegistration._generate_authorization_id()
|
||||
while TestCenterRegistration.objects.filter(client_authorization_id=auth_id).exists():
|
||||
auth_id = TestCenterRegistration._generate_authorization_id()
|
||||
return auth_id
|
||||
|
||||
# methods for providing registration status details on registration page:
|
||||
@property
|
||||
def demographics_is_accepted(self):
|
||||
return self.testcenter_user.is_accepted
|
||||
|
||||
@property
|
||||
def demographics_is_rejected(self):
|
||||
return self.testcenter_user.is_rejected
|
||||
|
||||
@property
|
||||
def demographics_is_pending(self):
|
||||
return self.testcenter_user.is_pending
|
||||
|
||||
@property
|
||||
def accommodation_is_accepted(self):
|
||||
return len(self.accommodation_request) > 0 and len(self.accommodation_code) > 0 and self.accommodation_code != ACCOMMODATION_REJECTED_CODE
|
||||
|
||||
@property
|
||||
def accommodation_is_rejected(self):
|
||||
return len(self.accommodation_request) > 0 and self.accommodation_code == ACCOMMODATION_REJECTED_CODE
|
||||
|
||||
@property
|
||||
def accommodation_is_pending(self):
|
||||
return len(self.accommodation_request) > 0 and len(self.accommodation_code) == 0
|
||||
|
||||
@property
|
||||
def accommodation_is_skipped(self):
|
||||
return len(self.accommodation_request) == 0
|
||||
|
||||
@property
|
||||
def registration_is_accepted(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ACCEPTED
|
||||
|
||||
@property
|
||||
def registration_is_rejected(self):
|
||||
return self.upload_status == TEST_CENTER_STATUS_ERROR
|
||||
|
||||
@property
|
||||
def registration_is_pending(self):
|
||||
return not self.registration_is_accepted and not self.registration_is_rejected
|
||||
|
||||
# methods for providing registration status summary on dashboard page:
|
||||
@property
|
||||
def is_accepted(self):
|
||||
return self.registration_is_accepted and self.demographics_is_accepted
|
||||
|
||||
@property
|
||||
def is_rejected(self):
|
||||
return self.registration_is_rejected or self.demographics_is_rejected
|
||||
|
||||
@property
|
||||
def is_pending(self):
|
||||
return not self.is_accepted and not self.is_rejected
|
||||
|
||||
def get_accommodation_codes(self):
|
||||
return self.accommodation_code.split('*')
|
||||
|
||||
def get_accommodation_names(self):
|
||||
return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()]
|
||||
|
||||
@property
|
||||
def registration_signup_url(self):
|
||||
return settings.PEARSONVUE_SIGNINPAGE_URL
|
||||
|
||||
def demographics_status(self):
|
||||
if self.demographics_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.demographics_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def accommodation_status(self):
|
||||
if self.accommodation_is_skipped:
|
||||
return "Skipped"
|
||||
elif self.accommodation_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.accommodation_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
def registration_status(self):
|
||||
if self.registration_is_accepted:
|
||||
return "Accepted"
|
||||
elif self.registration_is_rejected:
|
||||
return "Rejected"
|
||||
else:
|
||||
return "Pending"
|
||||
|
||||
|
||||
class TestCenterRegistrationForm(ModelForm):
|
||||
class Meta:
|
||||
model = TestCenterRegistration
|
||||
fields = ('accommodation_request', 'accommodation_code')
|
||||
|
||||
def clean_accommodation_request(self):
|
||||
code = self.cleaned_data['accommodation_request']
|
||||
if code and len(code) > 0:
|
||||
return code.strip()
|
||||
return code
|
||||
|
||||
def update_and_save(self):
|
||||
registration = self.save(commit=False)
|
||||
# create additional values here:
|
||||
registration.user_updated_at = datetime.now(UTC)
|
||||
registration.upload_status = ''
|
||||
registration.save()
|
||||
log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code))
|
||||
|
||||
def clean_accommodation_code(self):
|
||||
code = self.cleaned_data['accommodation_code']
|
||||
if code:
|
||||
code = code.upper()
|
||||
codes = code.split('*')
|
||||
for codeval in codes:
|
||||
if codeval not in ACCOMMODATION_CODE_DICT:
|
||||
raise forms.ValidationError(u'Invalid accommodation code specified: "{}"'.format(codeval))
|
||||
return code
|
||||
|
||||
|
||||
def get_testcenter_registration(user, course_id, exam_series_code):
|
||||
try:
|
||||
tcu = TestCenterUser.objects.get(user=user)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
return []
|
||||
return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code)
|
||||
|
||||
# nosetests thinks that anything with _test_ in the name is a test.
|
||||
# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html)
|
||||
get_testcenter_registration.__test__ = False
|
||||
|
||||
|
||||
def unique_id_for_user(user):
|
||||
"""
|
||||
@@ -880,7 +405,7 @@ class CourseEnrollment(models.Model):
|
||||
verified the user authentication and access.
|
||||
"""
|
||||
enrollment = cls.get_or_create_enrollment(user, course_id)
|
||||
enrollment.update_enrollment(is_active=True)
|
||||
enrollment.update_enrollment(is_active=True, mode=mode)
|
||||
return enrollment
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
|
||||
@@ -329,6 +342,14 @@ class EnrollInCourseTest(TestCase):
|
||||
)
|
||||
self.assertFalse(enrollment_record.is_active)
|
||||
|
||||
# Make sure mode is updated properly if user unenrolls & re-enrolls
|
||||
enrollment = CourseEnrollment.enroll(user, course_id, "verified")
|
||||
self.assertEquals(enrollment.mode, "verified")
|
||||
CourseEnrollment.unenroll(user, course_id)
|
||||
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
|
||||
self.assertTrue(CourseEnrollment.is_enrolled(user, course_id))
|
||||
self.assertEquals(enrollment.mode, "audit")
|
||||
|
||||
def assert_no_events_were_emitted(self):
|
||||
"""Ensures no events were emitted since the last event related assertion"""
|
||||
self.assertFalse(self.mock_server_track.called)
|
||||
|
||||
@@ -39,10 +39,9 @@ from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from student.models import (
|
||||
Registration, UserProfile, TestCenterUser, TestCenterUserForm,
|
||||
TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange,
|
||||
Registration, UserProfile, PendingNameChange,
|
||||
PendingEmailChange, CourseEnrollment, unique_id_for_user,
|
||||
get_testcenter_registration, CourseEnrollmentAllowed, UserStanding,
|
||||
CourseEnrollmentAllowed, UserStanding,
|
||||
)
|
||||
from student.forms import PasswordResetFormNoActive
|
||||
|
||||
@@ -185,7 +184,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 +203,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 +297,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 = []
|
||||
@@ -964,172 +965,6 @@ def create_account(request, post_override=None):
|
||||
return response
|
||||
|
||||
|
||||
def exam_registration_info(user, course):
|
||||
""" Returns a Registration object if the user is currently registered for a current
|
||||
exam of the course. Returns None if the user is not registered, or if there is no
|
||||
current exam for the course.
|
||||
"""
|
||||
exam_info = course.current_test_center_exam
|
||||
if exam_info is None:
|
||||
return None
|
||||
|
||||
exam_code = exam_info.exam_series_code
|
||||
registrations = get_testcenter_registration(user, course.id, exam_code)
|
||||
if registrations:
|
||||
registration = registrations[0]
|
||||
else:
|
||||
registration = None
|
||||
return registration
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def begin_exam_registration(request, course_id):
|
||||
""" Handles request to register the user for the current
|
||||
test center exam of the specified course. Called by form
|
||||
in dashboard.html.
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
log.error("User {0} enrolled in non-existent course {1}".format(user.username, course_id))
|
||||
raise Http404
|
||||
|
||||
# get the exam to be registered for:
|
||||
# (For now, we just assume there is one at most.)
|
||||
# if there is no exam now (because someone bookmarked this stupid page),
|
||||
# then return a 404:
|
||||
exam_info = course.current_test_center_exam
|
||||
if exam_info is None:
|
||||
raise Http404
|
||||
|
||||
# determine if the user is registered for this course:
|
||||
registration = exam_registration_info(user, course)
|
||||
|
||||
# we want to populate the registration page with the relevant information,
|
||||
# if it already exists. Create an empty object otherwise.
|
||||
try:
|
||||
testcenteruser = TestCenterUser.objects.get(user=user)
|
||||
except TestCenterUser.DoesNotExist:
|
||||
testcenteruser = TestCenterUser()
|
||||
testcenteruser.user = user
|
||||
|
||||
context = {'course': course,
|
||||
'user': user,
|
||||
'testcenteruser': testcenteruser,
|
||||
'registration': registration,
|
||||
'exam_info': exam_info,
|
||||
}
|
||||
|
||||
return render_to_response('test_center_register.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def create_exam_registration(request, post_override=None):
|
||||
"""
|
||||
JSON call to create a test center exam registration.
|
||||
Called by form in test_center_register.html
|
||||
"""
|
||||
post_vars = post_override if post_override else request.POST
|
||||
|
||||
# first determine if we need to create a new TestCenterUser, or if we are making any update
|
||||
# to an existing TestCenterUser.
|
||||
username = post_vars['username']
|
||||
user = User.objects.get(username=username)
|
||||
course_id = post_vars['course_id']
|
||||
course = course_from_id(course_id) # assume it will be found....
|
||||
|
||||
# make sure that any demographic data values received from the page have been stripped.
|
||||
# Whitespace is not an acceptable response for any of these values
|
||||
demographic_data = {}
|
||||
for fieldname in TestCenterUser.user_provided_fields():
|
||||
if fieldname in post_vars:
|
||||
demographic_data[fieldname] = (post_vars[fieldname]).strip()
|
||||
try:
|
||||
testcenter_user = TestCenterUser.objects.get(user=user)
|
||||
needs_updating = testcenter_user.needs_update(demographic_data)
|
||||
log.info("User {0} enrolled in course {1} {2}updating demographic info for exam registration".format(user.username, course_id, "" if needs_updating else "not "))
|
||||
except TestCenterUser.DoesNotExist:
|
||||
# do additional initialization here:
|
||||
testcenter_user = TestCenterUser.create(user)
|
||||
needs_updating = True
|
||||
log.info("User {0} enrolled in course {1} creating demographic info for exam registration".format(user.username, course_id))
|
||||
|
||||
# perform validation:
|
||||
if needs_updating:
|
||||
# first perform validation on the user information
|
||||
# using a Django Form.
|
||||
form = TestCenterUserForm(instance=testcenter_user, data=demographic_data)
|
||||
if form.is_valid():
|
||||
form.update_and_save()
|
||||
else:
|
||||
response_data = {'success': False}
|
||||
# return a list of errors...
|
||||
response_data['field_errors'] = form.errors
|
||||
response_data['non_field_errors'] = form.non_field_errors()
|
||||
return HttpResponse(json.dumps(response_data), mimetype="application/json")
|
||||
|
||||
# create and save the registration:
|
||||
needs_saving = False
|
||||
exam = course.current_test_center_exam
|
||||
exam_code = exam.exam_series_code
|
||||
registrations = get_testcenter_registration(user, course_id, exam_code)
|
||||
if registrations:
|
||||
registration = registrations[0]
|
||||
# NOTE: we do not bother to check here to see if the registration has changed,
|
||||
# because at the moment there is no way for a user to change anything about their
|
||||
# registration. They only provide an optional accommodation request once, and
|
||||
# cannot make changes to it thereafter.
|
||||
# It is possible that the exam_info content has been changed, such as the
|
||||
# scheduled exam dates, but those kinds of changes should not be handled through
|
||||
# this registration screen.
|
||||
|
||||
else:
|
||||
accommodation_request = post_vars.get('accommodation_request', '')
|
||||
registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request)
|
||||
needs_saving = True
|
||||
log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id))
|
||||
|
||||
if needs_saving:
|
||||
# do validation of registration. (Mainly whether an accommodation request is too long.)
|
||||
form = TestCenterRegistrationForm(instance=registration, data=post_vars)
|
||||
if form.is_valid():
|
||||
form.update_and_save()
|
||||
else:
|
||||
response_data = {'success': False}
|
||||
# return a list of errors...
|
||||
response_data['field_errors'] = form.errors
|
||||
response_data['non_field_errors'] = form.non_field_errors()
|
||||
return HttpResponse(json.dumps(response_data), mimetype="application/json")
|
||||
|
||||
# only do the following if there is accommodation text to send,
|
||||
# and a destination to which to send it.
|
||||
# TODO: still need to create the accommodation email templates
|
||||
# if 'accommodation_request' in post_vars and 'TESTCENTER_ACCOMMODATION_REQUEST_EMAIL' in settings:
|
||||
# d = {'accommodation_request': post_vars['accommodation_request'] }
|
||||
#
|
||||
# # composes accommodation email
|
||||
# subject = render_to_string('emails/accommodation_email_subject.txt', d)
|
||||
# # Email subject *must not* contain newlines
|
||||
# subject = ''.join(subject.splitlines())
|
||||
# message = render_to_string('emails/accommodation_email.txt', d)
|
||||
#
|
||||
# try:
|
||||
# dest_addr = settings['TESTCENTER_ACCOMMODATION_REQUEST_EMAIL']
|
||||
# from_addr = user.email
|
||||
# send_mail(subject, message, from_addr, [dest_addr], fail_silently=False)
|
||||
# except:
|
||||
# log.exception(sys.exc_info())
|
||||
# response_data = {'success': False}
|
||||
# response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ]
|
||||
# return HttpResponse(json.dumps(response_data), mimetype="application/json")
|
||||
|
||||
js = {'success': True}
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
|
||||
def auto_auth(request):
|
||||
"""
|
||||
Automatically logs the user in with a generated random credentials
|
||||
@@ -1229,11 +1064,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 +1347,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}))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from functools import wraps
|
||||
import copy
|
||||
import json
|
||||
from django.core.serializers import serialize
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,7 +20,6 @@ XMODULES = [
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
|
||||
"vertical = xmodule.vertical_module:VerticalDescriptor",
|
||||
"video = xmodule.video_module:VideoDescriptor",
|
||||
"videoalpha = xmodule.video_module:VideoDescriptor",
|
||||
|
||||
@@ -213,7 +213,6 @@ class CourseFields(object):
|
||||
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
|
||||
discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings)
|
||||
discussion_sort_alpha = Boolean(scope=Scope.settings, default=False, help="Sort forum categories and subcategories alphabetically.")
|
||||
testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings)
|
||||
announcement = Date(help="Date this course is announced", scope=Scope.settings)
|
||||
cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings)
|
||||
is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings)
|
||||
@@ -426,20 +425,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
if self.discussion_topics == {}:
|
||||
self.discussion_topics = {'General': {'id': self.location.html_id()}}
|
||||
|
||||
self.test_center_exams = []
|
||||
test_center_info = self.testcenter_info
|
||||
if test_center_info is not None:
|
||||
for exam_name in test_center_info:
|
||||
try:
|
||||
exam_info = test_center_info[exam_name]
|
||||
self.test_center_exams.append(self.TestCenterExam(self.id, exam_name, exam_info))
|
||||
except Exception as err:
|
||||
# If we can't parse the test center exam info, don't break
|
||||
# the rest of the courseware.
|
||||
msg = 'Error %s: Unable to load test-center exam info for exam "%s" of course "%s"' % (err, exam_name, self.id)
|
||||
log.error(msg)
|
||||
continue
|
||||
|
||||
# TODO check that this is still needed here and can't be by defaults.
|
||||
if not self.tabs:
|
||||
# When calling the various _tab methods, can omit the 'type':'blah' from the
|
||||
@@ -597,6 +582,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
|
||||
@@ -873,93 +861,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
return True
|
||||
|
||||
class TestCenterExam(object):
|
||||
def __init__(self, course_id, exam_name, exam_info):
|
||||
self.course_id = course_id
|
||||
self.exam_name = exam_name
|
||||
self.exam_info = exam_info
|
||||
self.exam_series_code = exam_info.get('Exam_Series_Code') or exam_name
|
||||
self.display_name = exam_info.get('Exam_Display_Name') or self.exam_series_code
|
||||
self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date')
|
||||
if self.first_eligible_appointment_date is None:
|
||||
raise ValueError("First appointment date must be specified")
|
||||
# TODO: If defaulting the last appointment date, it should be the
|
||||
# *end* of the same day, not the same time. It's going to be used as the
|
||||
# end of the exam overall, so we don't want the exam to disappear too soon.
|
||||
# It's also used optionally as the registration end date, so time matters there too.
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or
|
||||
datetime.fromtimestamp(0, UTC()))
|
||||
self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date
|
||||
# do validation within the exam info:
|
||||
if self.registration_start_date > self.registration_end_date:
|
||||
raise ValueError("Registration start date must be before registration end date")
|
||||
if self.first_eligible_appointment_date > self.last_eligible_appointment_date:
|
||||
raise ValueError("First appointment date must be before last appointment date")
|
||||
if self.registration_end_date > self.last_eligible_appointment_date:
|
||||
raise ValueError("Registration end date must be before last appointment date")
|
||||
self.exam_url = exam_info.get('Exam_URL')
|
||||
|
||||
def _try_parse_time(self, key):
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, complain
|
||||
if it doesn't parse.
|
||||
Return None if not present or invalid.
|
||||
"""
|
||||
if key in self.exam_info:
|
||||
try:
|
||||
return Date().from_json(self.exam_info[key])
|
||||
except ValueError as e:
|
||||
msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e)
|
||||
log.warning(msg)
|
||||
return None
|
||||
|
||||
def has_started(self):
|
||||
return datetime.now(UTC()) > self.first_eligible_appointment_date
|
||||
|
||||
def has_ended(self):
|
||||
return datetime.now(UTC()) > self.last_eligible_appointment_date
|
||||
|
||||
def has_started_registration(self):
|
||||
return datetime.now(UTC()) > self.registration_start_date
|
||||
|
||||
def has_ended_registration(self):
|
||||
return datetime.now(UTC()) > self.registration_end_date
|
||||
|
||||
def is_registering(self):
|
||||
now = datetime.now(UTC())
|
||||
return now >= self.registration_start_date and now <= self.registration_end_date
|
||||
|
||||
@property
|
||||
def first_eligible_appointment_date_text(self):
|
||||
return self.first_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def last_eligible_appointment_date_text(self):
|
||||
return self.last_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def registration_end_date_text(self):
|
||||
return date_utils.get_default_time_display(self.registration_end_date)
|
||||
|
||||
@property
|
||||
def current_test_center_exam(self):
|
||||
exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()]
|
||||
if len(exams) > 1:
|
||||
# TODO: output some kind of warning. This should already be
|
||||
# caught if we decide to do validation at load time.
|
||||
return exams[0]
|
||||
elif len(exams) == 1:
|
||||
return exams[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_test_center_exam(self, exam_series_code):
|
||||
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
|
||||
return exams[0] if len(exams) == 1 else None
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
return self.location.course
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Disabled 10/29/13 due to flakiness in master
|
||||
// Disabled 11/25/13 due to flakiness in master
|
||||
xdescribe('multiple YT on page', function () {
|
||||
var state1, state2, state3;
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
expect(videoCaption.currentIndex).toEqual(5);
|
||||
});
|
||||
|
||||
// Disabled 10/25/13 due to flakiness in master
|
||||
// Disabled 11/25/13 due to flakiness in master
|
||||
xit('scroll caption to new position', function () {
|
||||
expect($.fn.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
@@ -537,7 +537,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Disabled 10/23/13 due to flakiness in master
|
||||
// Disabled 11/25/13 due to flakiness in master
|
||||
xdescribe('scrollCaption', function () {
|
||||
beforeEach(function () {
|
||||
initialize();
|
||||
@@ -682,7 +682,7 @@
|
||||
.toHaveAttr('title', 'Turn off captions');
|
||||
});
|
||||
|
||||
// Test turned off due to flakiness (30.10.2013).
|
||||
// Test turned off due to flakiness (11/25/13)
|
||||
xit('scroll the caption', function () {
|
||||
// After transcripts are shown, and the video plays for a
|
||||
// bit.
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Turned off test due to flakiness (30.10.2013).
|
||||
// Turned off test due to flakiness (11/25/13)
|
||||
xit('trigger seek event', function() {
|
||||
runs(function () {
|
||||
videoProgressSlider.onSlide(
|
||||
@@ -219,7 +219,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Turned off test due to flakiness (30.10.2013).
|
||||
// Turned off test due to flakiness (11/25/13)
|
||||
xit('trigger seek event', function() {
|
||||
runs(function () {
|
||||
videoProgressSlider.onStop(
|
||||
@@ -285,6 +285,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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user