Commit 487afad5 by Will Daly

Merge branch 'master' into feature/will/move_correct_mark_for_multiple_choice

parents 9fe305e0 85432353
Feature: Advanced (manual) course policy
In order to specify course policy settings for which no custom user interface exists
I want to be able to manually enter JSON key/value pairs
Scenario: A course author sees only display_name on a newly created course
Given I have opened a new course in Studio
When I select the Advanced Settings
Then I see only the display name
Scenario: Test if there are no policy settings without existing UI controls
Given I am on the Advanced Course Settings page in Studio
When I delete the display name
Then there are no advanced policy settings
And I reload the page
Then there are no advanced policy settings
Scenario: Test cancel editing key name
Given I am on the Advanced Course Settings page in Studio
When I edit the name of a policy key
And I press the "Cancel" notification button
Then the policy key name is unchanged
Scenario: Test editing key name
Given I am on the Advanced Course Settings page in Studio
When I edit the name of a policy key
And I press the "Save" notification button
Then the policy key name is changed
Scenario: Test cancel editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Cancel" notification button
Then the policy key value is unchanged
Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
And I press the "Save" notification button
Then the policy key value is changed
Scenario: Add new entries, and they appear alphabetically after save
Given I am on the Advanced Course Settings page in Studio
When I create New Entries
Then they are alphabetized
And I reload the page
Then they are alphabetized
Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio
When I create a JSON object
Then it is displayed as formatted
from lettuce import world, step
from common import *
import time
from nose.tools import assert_equal
from nose.tools import assert_true
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
############### ACTIONS ####################
@step('I select the Advanced Settings$')
def i_select_advanced_settings(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand'
if world.browser.is_element_present_by_css(expand_icon_css):
css_click(expand_icon_css)
link_css = 'li.nav-course-settings-advanced a'
css_click(link_css)
@step('I am on the Advanced Course Settings page in Studio$')
def i_am_on_advanced_course_settings(step):
step.given('I have opened a new course in Studio')
step.given('I select the Advanced Settings')
# TODO: this is copied from terrain's step.py. Need to figure out how to share that code.
@step('I reload the page$')
def reload_the_page(step):
world.browser.reload()
@step(u'I edit the name of a policy key$')
def edit_the_name_of_a_policy_key(step):
policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first
e.fill('new')
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
world.browser.click_link_by_text(name)
@step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step):
"""
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first
e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X')
@step('I delete the display name$')
def delete_the_display_name(step):
delete_entry(0)
click_save()
@step('create New Entries$')
def create_new_entries(step):
create_entry("z", "apple")
create_entry("a", "zebra")
click_save()
@step('I create a JSON object$')
def create_JSON_object(step):
create_entry("json", '{"key": "value", "key_2": "value_2"}')
click_save()
############### RESULTS ####################
@step('I see only the display name$')
def i_see_only_display_name(step):
assert_policy_entries(["display_name"], ['"Robot Super Course"'])
@step('there are no advanced policy settings$')
def no_policy_settings(step):
assert_policy_entries([], [])
@step('they are alphabetized$')
def they_are_alphabetized(step):
assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"'])
@step('it is displayed as formatted$')
def it_is_formatted(step):
assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}'])
@step(u'the policy key name is unchanged$')
def the_policy_key_name_is_unchanged(step):
policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first
assert_equal(e.value, 'display_name')
@step(u'the policy key name is changed$')
def the_policy_key_name_is_changed(step):
policy_key_css = 'input.policy-key'
e = css_find(policy_key_css).first
assert_equal(e.value, 'new')
@step(u'the policy key value is unchanged$')
def the_policy_key_value_is_unchanged(step):
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
e = css_find(policy_value_css).first
assert_equal(e.value, '"Robot Super Course"')
@step(u'the policy key value is changed$')
def the_policy_key_value_is_unchanged(step):
policy_value_css = 'li.course-advanced-policy-list-item div.value textarea'
e = css_find(policy_value_css).first
assert_equal(e.value, '"Robot Super Course X"')
############# HELPERS ###############
def create_entry(key, value):
# Scroll down the page so the button is visible
world.scroll_to_bottom()
css_click_at('a.new-advanced-policy-item', 10, 10)
new_key_css = 'div#__new_advanced_key__ input'
new_key_element = css_find(new_key_css).first
new_key_element.fill(key)
# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM)
# Have to do all this because Selenium has a bug that fill does not remove existing text
new_value_css = 'div.CodeMirror textarea'
css_find(new_value_css).last.fill("")
css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE)
css_find(new_value_css).last.fill(value)
def delete_entry(index):
"""
Delete the nth entry where index is 0-based
"""
css = '.delete-button'
assert_true(world.browser.is_element_present_by_css(css, 5))
delete_buttons = css_find(css)
assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index))
delete_buttons[index].click()
def assert_policy_entries(expected_keys, expected_values):
assert_entries('.key input', expected_keys)
assert_entries('.json', expected_values)
def assert_entries(css, expected_values):
webElements = css_find(css)
assert_equal(len(expected_values), len(webElements))
# Sometimes get stale reference if I hold on to the array of elements
for counter in range(len(expected_values)):
assert_equal(expected_values[counter], css_find(css)[counter].value)
def click_save():
css = ".save-button"
def is_shown(driver):
visible = css_find(css).first.visible
if visible:
# Even when waiting for visible, this fails sporadically. Adding in a small wait.
time.sleep(float(1))
return visible
wait_for(is_shown)
css_click(css)
def fill_last_field(value):
newValue = css_find('#__new_advanced_key__ input').first
newValue.fill(value)
from lettuce import world, step from lettuce import world, step
from factories import *
from django.core.management import call_command
from lettuce.django import django_url from lettuce.django import django_url
from django.conf import settings
from django.core.management import call_command
from nose.tools import assert_true from nose.tools import assert_true
from nose.tools import assert_equal from nose.tools import assert_equal
from selenium.webdriver.support.ui import WebDriverWait
from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory
from terrain.factories import CourseFactory, GroupFactory
import xmodule.modulestore.django import xmodule.modulestore.django
from auth.authz import get_user_by_email
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
...@@ -44,6 +45,13 @@ def i_press_the_category_delete_icon(step, category): ...@@ -44,6 +45,13 @@ def i_press_the_category_delete_icon(step, category):
assert False, 'Invalid category: %s' % category assert False, 'Invalid category: %s' % category
css_click(css) css_click(css)
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
clear_courses()
log_into_studio()
create_a_course()
####### HELPER FUNCTIONS ############## ####### HELPER FUNCTIONS ##############
...@@ -86,13 +94,38 @@ def assert_css_with_text(css, text): ...@@ -86,13 +94,38 @@ def assert_css_with_text(css, text):
def css_click(css): def css_click(css):
assert_true(world.browser.is_element_present_by_css(css, 5))
world.browser.find_by_css(css).first.click() world.browser.find_by_css(css).first.click()
def css_click_at(css, x=10, y=10):
'''
A method to click at x,y coordinates of the element
rather than in the center of the element
'''
assert_true(world.browser.is_element_present_by_css(css, 5))
e = world.browser.find_by_css(css).first
e.action_chains.move_to_element_with_offset(e._element, x, y)
e.action_chains.click()
e.action_chains.perform()
def css_fill(css, value): def css_fill(css, value):
world.browser.find_by_css(css).first.fill(value) world.browser.find_by_css(css).first.fill(value)
def css_find(css):
return world.browser.find_by_css(css)
def wait_for(func):
WebDriverWait(world.browser.driver, 10).until(func)
def id_find(id):
return world.browser.find_by_id(id)
def clear_courses(): def clear_courses():
flush_xmodule_store() flush_xmodule_store()
...@@ -129,9 +162,18 @@ def log_into_studio( ...@@ -129,9 +162,18 @@ def log_into_studio(
def create_a_course(): def create_a_course():
css_click('a.new-course-button') c = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
fill_in_course_info()
css_click('input.new-course-save') # Add the user to the instructor group of the course
# so they will have the permissions to see it in studio
g = GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course')
u = get_user_by_email('robot+studio@edx.org')
u.groups.add(g)
u.save()
world.browser.reload()
course_link_css = 'span.class-name'
css_click(course_link_css)
course_title_css = 'span.course-title' course_title_css = 'span.course-title'
assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) assert_true(world.browser.is_element_present_by_css(course_title_css, 5))
......
...@@ -4,13 +4,6 @@ from common import * ...@@ -4,13 +4,6 @@ from common import *
############### ACTIONS #################### ############### ACTIONS ####################
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
clear_courses()
log_into_studio()
create_a_course()
@step('I click the new section link$') @step('I click the new section link$')
def i_click_new_section_link(step): def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button' link_css = 'a.new-courseware-section-button'
...@@ -46,6 +39,7 @@ def i_save_a_new_section_release_date(step): ...@@ -46,6 +39,7 @@ def i_save_a_new_section_release_date(step):
css_fill(time_css, '12:00am') css_fill(time_css, '12:00am')
css_click('a.save-button') css_click('a.save-button')
############ ASSERTIONS ################### ############ ASSERTIONS ###################
......
import datetime import datetime
import time
import json import json
import calendar
import copy import copy
from util import converters from util import converters
from util.converters import jsdate_to_time from util.converters import jsdate_to_time
...@@ -11,7 +9,6 @@ from django.test.client import Client ...@@ -11,7 +9,6 @@ from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.timezone import UTC from django.utils.timezone import UTC
import xmodule
from xmodule.modulestore import Location from xmodule.modulestore import Location
from cms.djangoapps.models.settings.course_details import (CourseDetails, from cms.djangoapps.models.settings.course_details import (CourseDetails,
CourseSettingsEncoder) CourseSettingsEncoder)
...@@ -22,6 +19,10 @@ from django.test import TestCase ...@@ -22,6 +19,10 @@ from django.test import TestCase
from utils import ModuleStoreTestCase from utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
# YYYY-MM-DDThh:mm:ss.s+/-HH:MM # YYYY-MM-DDThh:mm:ss.s+/-HH:MM
class ConvertersTestCase(TestCase): class ConvertersTestCase(TestCase):
...@@ -261,3 +262,64 @@ class CourseGradingTest(CourseTestCase): ...@@ -261,3 +262,64 @@ class CourseGradingTest(CourseTestCase):
test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1 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(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
class CourseMetadataEditingTest(CourseTestCase):
def setUp(self):
CourseTestCase.setUp(self)
# add in the full class too
import_from_xml(modulestore(), 'common/test/data/', ['full'])
self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None])
def test_fetch_initial_fields(self):
test_model = CourseMetadata.fetch(self.course_location)
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)
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'], 'Testing', "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_update_from_json(self):
test_model = CourseMetadata.update_from_json(self.course_location,
{ "a" : 1,
"b_a_c_h" : { "c" : "test" },
"test_text" : "a text string"})
self.update_check(test_model)
# try fresh fetch to ensure persistence
test_model = CourseMetadata.fetch(self.course_location)
self.update_check(test_model)
# now change some of the existing metadata
test_model = CourseMetadata.update_from_json(self.course_location,
{ "a" : 2,
"display_name" : "jolly roger"})
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
self.assertIn('a', test_model, 'Missing revised a metadata field')
self.assertEqual(test_model['a'], 2, "a not expected value")
def update_check(self, test_model):
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
self.assertIn('a', test_model, 'Missing new a metadata field')
self.assertEqual(test_model['a'], 1, "a not expected value")
self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field')
self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value")
self.assertIn('test_text', test_model, 'Missing test_text metadata field')
self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value")
def test_delete_key(self):
test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', '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')
self.assertEqual(test_model['display_name'], 'Testing', "not expected value")
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
# check for deletion effectiveness
self.assertNotIn('showanswer', test_model, 'showanswer field still in')
self.assertNotIn('xqa_key', test_model, 'xqa_key field still in')
\ No newline at end of file
...@@ -58,8 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\ ...@@ -58,8 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\
CourseSettingsEncoder CourseSettingsEncoder
from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.contentstore.utils import get_modulestore from cms.djangoapps.contentstore.utils import get_modulestore
from lxml import etree
from django.shortcuts import redirect from django.shortcuts import redirect
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
...@@ -365,7 +365,6 @@ def preview_component(request, location): ...@@ -365,7 +365,6 @@ def preview_component(request, location):
'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(),
}) })
@expect_json @expect_json
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -682,7 +681,6 @@ def create_draft(request): ...@@ -682,7 +681,6 @@ def create_draft(request):
return HttpResponse() return HttpResponse()
@login_required @login_required
@expect_json @expect_json
def publish_draft(request): def publish_draft(request):
...@@ -712,7 +710,6 @@ def unpublish_unit(request): ...@@ -712,7 +710,6 @@ def unpublish_unit(request):
return HttpResponse() return HttpResponse()
@login_required @login_required
@expect_json @expect_json
def clone_item(request): def clone_item(request):
...@@ -901,7 +898,6 @@ def remove_user(request, location): ...@@ -901,7 +898,6 @@ def remove_user(request, location):
def landing(request, org, course, coursename): def landing(request, org, course, coursename):
return render_to_response('temp-course-landing.html', {}) return render_to_response('temp-course-landing.html', {})
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def static_pages(request, org, course, coursename): def static_pages(request, org, course, coursename):
...@@ -1005,7 +1001,6 @@ def edit_tabs(request, org, course, coursename): ...@@ -1005,7 +1001,6 @@ def edit_tabs(request, org, course, coursename):
'components': components 'components': components
}) })
def not_found(request): def not_found(request):
return render_to_response('error.html', {'error': '404'}) return render_to_response('error.html', {'error': '404'})
...@@ -1041,7 +1036,6 @@ def course_info(request, org, course, name, provided_id=None): ...@@ -1041,7 +1036,6 @@ def course_info(request, org, course, name, provided_id=None):
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url()
}) })
@expect_json @expect_json
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -1112,7 +1106,6 @@ def module_info(request, module_location): ...@@ -1112,7 +1106,6 @@ def module_info(request, module_location):
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def get_course_settings(request, org, course, name): def get_course_settings(request, org, course, name):
...@@ -1159,6 +1152,28 @@ def course_config_graders_page(request, org, course, name): ...@@ -1159,6 +1152,28 @@ def course_config_graders_page(request, org, course, name):
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) '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 = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
course_module = modulestore().get_item(location)
return render_to_response('settings_advanced.html', {
'context_course': course_module,
'course_location' : location,
'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST),
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
})
@expect_json @expect_json
@login_required @login_required
...@@ -1191,7 +1206,6 @@ def course_settings_updates(request, org, course, name, section): ...@@ -1191,7 +1206,6 @@ def course_settings_updates(request, org, course, name, section):
return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder),
mimetype="application/json") mimetype="application/json")
@expect_json @expect_json
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -1226,6 +1240,37 @@ def course_grader_updates(request, org, course, name, grader_index=None): ...@@ -1226,6 +1240,37 @@ def course_grader_updates(request, org, course, name, grader_index=None):
return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)), return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)),
mimetype="application/json") mimetype="application/json")
## NB: expect_json failed on ["key", "key2"] and json payload
@login_required
@ensure_csrf_cookie
def course_advanced_updates(request, org, course, name):
"""
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
"""
location = ['i4x', org, course, 'course', name]
# check that logged in user has permissions to this item
if not has_access(request.user, location):
raise PermissionDenied()
# NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!!
if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META:
real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE']
else:
real_method = request.method
if real_method == 'GET':
return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json")
elif real_method == 'DELETE':
return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json")
elif real_method == 'POST' or real_method == 'PUT':
# NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key
return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json")
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -1286,7 +1331,6 @@ def asset_index(request, org, course, name): ...@@ -1286,7 +1331,6 @@ def asset_index(request, org, course, name):
def edge(request): def edge(request):
return render_to_response('university_profiles/edge.html', {}) return render_to_response('university_profiles/edge.html', {})
@login_required @login_required
@expect_json @expect_json
def create_new_course(request): def create_new_course(request):
...@@ -1342,7 +1386,6 @@ def create_new_course(request): ...@@ -1342,7 +1386,6 @@ def create_new_course(request):
return HttpResponse(json.dumps({'id': new_course.location.url()})) return HttpResponse(json.dumps({'id': new_course.location.url()}))
def initialize_course_tabs(course): def initialize_course_tabs(course):
# set up the default tabs # set up the default tabs
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
...@@ -1360,7 +1403,6 @@ def initialize_course_tabs(course): ...@@ -1360,7 +1403,6 @@ def initialize_course_tabs(course):
modulestore('direct').update_metadata(course.location.url(), course.own_metadata) modulestore('direct').update_metadata(course.location.url(), course.own_metadata)
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def import_course(request, org, course, name): def import_course(request, org, course, name):
...@@ -1438,7 +1480,6 @@ def import_course(request, org, course, name): ...@@ -1438,7 +1480,6 @@ def import_course(request, org, course, name):
course_module.location.name]) course_module.location.name])
}) })
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def generate_export_course(request, org, course, name): def generate_export_course(request, org, course, name):
...@@ -1490,7 +1531,6 @@ def export_course(request, org, course, name): ...@@ -1490,7 +1531,6 @@ def export_course(request, org, course, name):
'successful_import_redirect_url': '' 'successful_import_redirect_url': ''
}) })
def event(request): def event(request):
''' '''
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
......
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
class CourseMetadata(object):
'''
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
The objects have no predefined attrs but instead are obj encodings of the editable metadata.
'''
# __new_advanced_key__ is used by client not server; so, could argue against it being here
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__']
@classmethod
def fetch(cls, course_location):
"""
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)
for k, v in descriptor.metadata.iteritems():
if k not in cls.FILTERED_LIST:
course[k] = v
return course
@classmethod
def update_from_json(cls, course_location, jsondict):
"""
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
for k, v in jsondict.iteritems():
# should it be an error if one of the filtered list items is in the payload?
if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v):
dirty = True
descriptor.metadata[k] = v
if dirty:
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
# 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 key in descriptor.metadata:
del descriptor.metadata[key]
get_modulestore(course_location).update_metadata(course_location, descriptor.metadata)
return cls.fetch(course_location)
\ No newline at end of file
<li class="field-group course-advanced-policy-list-item">
<div class="field text key" id="<%= (_.isEmpty(key) ? '__new_advanced_key__' : key) %>">
<label for="<%= keyUniqueId %>">Policy Key:</label>
<input type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
<span class="tip tip-stacked">Keys are case sensitive and cannot contain spaces or start with a number</span>
</div>
<div class="field text value">
<label for="<%= valueUniqueId %>">Policy Value:</label>
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
</div>
<div class="actions">
<a href="#" class="button delete-button standard remove-item remove-grading-data"><span class="delete-icon"></span>Delete</a>
</div>
</li>
\ No newline at end of file
if (!CMS.Models['Settings']) CMS.Models.Settings = {};
CMS.Models.Settings.Advanced = Backbone.Model.extend({
// the key for a newly added policy-- before the user has entered a key value
new_key : "__new_advanced_key__",
defaults: {
// the properties are whatever the user types in (in addition to whatever comes originally from the server)
},
// which keys to send as the deleted keys on next save
deleteKeys : [],
blacklistKeys : [], // an array which the controller should populate directly for now [static not instance based]
validate: function (attrs) {
var errors = {};
for (var key in attrs) {
if (key === this.new_key || _.isEmpty(key)) {
errors[key] = "A key must be entered.";
}
else if (_.contains(this.blacklistKeys, key)) {
errors[key] = key + " is a reserved keyword or can be edited on another screen";
}
}
if (!_.isEmpty(errors)) return errors;
},
save : function (attrs, options) {
// wraps the save call w/ the deletion of the removed keys after we know the saved ones worked
options = options ? _.clone(options) : {};
// add saveSuccess to the success
var success = options.success;
options.success = function(model, resp, options) {
model.afterSave(model);
if (success) success(model, resp, options);
};
Backbone.Model.prototype.save.call(this, attrs, options);
},
afterSave : function(self) {
// remove deleted attrs
if (!_.isEmpty(self.deleteKeys)) {
// remove the to be deleted keys from the returned model
_.each(self.deleteKeys, function(key) { self.unset(key); });
// not able to do via backbone since we're not destroying the model
$.ajax({
url : self.url,
// json to and fro
contentType : "application/json",
dataType : "json",
// delete
type : 'DELETE',
// data
data : JSON.stringify({ deleteKeys : self.deleteKeys})
})
.fail(function(hdr, status, error) { CMS.ServerError(self, "Deleting keys:" + status); })
.done(function(data, status, error) {
// clear deleteKeys on success
self.deleteKeys = [];
});
}
}
});
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
if (typeof window.templateLoader == 'function') return; if (typeof window.templateLoader == 'function') return;
var templateLoader = { var templateLoader = {
templateVersion: "0.0.12", templateVersion: "0.0.15",
templates: {}, templates: {},
loadRemoteTemplate: function(templateName, filename, callback) { loadRemoteTemplate: function(templateName, filename, callback) {
if (!this.templates[templateName]) { if (!this.templates[templateName]) {
......
if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg exists if (!CMS.Views['Settings']) CMS.Views.Settings = {};
CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseDetails // Model class is CMS.Models.Settings.CourseDetails
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
'click .remove-course-syllabus' : "removeSyllabus", 'click .remove-course-syllabus' : "removeSyllabus",
'click .new-course-syllabus' : 'assetSyllabus', 'click .new-course-syllabus' : 'assetSyllabus',
'click .remove-course-introduction-video' : "removeVideo", 'click .remove-course-introduction-video' : "removeVideo",
......
...@@ -3,9 +3,9 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg ex ...@@ -3,9 +3,9 @@ if (!CMS.Views['Settings']) CMS.Views.Settings = {}; // ensure the pseudo pkg ex
CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGradingPolicy // Model class is CMS.Models.Settings.CourseGradingPolicy
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
"blur span[contenteditable=true]" : "updateDesignation", "change span[contenteditable=true]" : "updateDesignation",
"click .settings-extra header" : "showSettingsExtras", "click .settings-extra header" : "showSettingsExtras",
"click .new-grade-button" : "addNewGrade", "click .new-grade-button" : "addNewGrade",
"click .remove-button" : "removeGrade", "click .remove-button" : "removeGrade",
...@@ -310,8 +310,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ ...@@ -310,8 +310,8 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.CourseGrader // Model class is CMS.Models.Settings.CourseGrader
events : { events : {
"blur input" : "updateModel", "change input" : "updateModel",
"blur textarea" : "updateModel", "change textarea" : "updateModel",
"click .remove-grading-data" : "deleteModel", "click .remove-grading-data" : "deleteModel",
// would love to move to a general superclass, but event hashes don't inherit in backbone :-( // would love to move to a general superclass, but event hashes don't inherit in backbone :-(
'focus :input' : "inputFocus", 'focus :input' : "inputFocus",
......
...@@ -10,8 +10,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ ...@@ -10,8 +10,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({
errorTemplate : _.template('<span class="message-error"><%= message %></span>'), errorTemplate : _.template('<span class="message-error"><%= message %></span>'),
events : { events : {
"blur input" : "clearValidationErrors", "change input" : "clearValidationErrors",
"blur textarea" : "clearValidationErrors" "change textarea" : "clearValidationErrors"
}, },
fieldToSelectorMap : { fieldToSelectorMap : {
// Your subclass must populate this w/ all of the model keys and dom selectors // Your subclass must populate this w/ all of the model keys and dom selectors
......
// notifications
.wrapper-notification {
@include clearfix();
@include box-sizing(border-box);
@include transition (bottom 2.0s ease-in-out 5s);
@include box-shadow(0 -1px 2px rgba(0,0,0,0.1));
position: fixed;
bottom: -100px;
z-index: 1000;
width: 100%;
overflow: hidden;
opacity: 0;
border-top: 1px solid $darkGrey;
padding: 20px 40px;
&.is-shown {
bottom: 0;
opacity: 1.0;
}
&.wrapper-notification-warning {
border-color: shade($yellow, 25%);
background: tint($yellow, 25%);
}
&.wrapper-notification-error {
border-color: shade($red, 50%);
background: tint($red, 20%);
color: $white;
}
&.wrapper-notification-confirm {
border-color: shade($green, 30%);
background: tint($green, 40%);
color: shade($green, 30%);
}
}
.notification {
@include box-sizing(border-box);
margin: 0 auto;
width: flex-grid(12);
max-width: $fg-max-width;
min-width: $fg-min-width;
.copy {
float: left;
width: flex-grid(9, 12);
margin-right: flex-gutter();
margin-top: 5px;
font-size: 14px;
.icon {
display: inline-block;
vertical-align: top;
margin-right: 5px;
font-size: 20px;
}
p {
width: flex-grid(8, 9);
display: inline-block;
vertical-align: top;
}
}
.actions {
float: right;
width: flex-grid(3, 12);
margin-top: ($baseline/2);
text-align: right;
li {
display: inline-block;
vertical-align: middle;
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
.save-button {
@include blue-button;
}
.cancel-button {
@include white-button;
}
}
strong {
font-weight: 700;
}
}
// adopted alerts
.alert { .alert {
padding: 15px 20px; padding: 15px 20px;
margin-bottom: 30px; margin-bottom: 30px;
......
...@@ -14,6 +14,44 @@ body.course.settings { ...@@ -14,6 +14,44 @@ body.course.settings {
padding: $baseline ($baseline*1.5); padding: $baseline ($baseline*1.5);
} }
// messages - should be synced up with global messages in the future
.message {
display: block;
font-size: 14px;
}
.message-status {
display: none;
@include border-top-radius(2px);
@include box-sizing(border-box);
border-bottom: 2px solid $yellow;
margin: 0 0 20px 0;
padding: 10px 20px;
font-weight: 500;
background: $paleYellow;
.text {
display: inline-block;
}
&.error {
border-color: shade($red, 50%);
background: tint($red, 20%);
color: $white;
}
&.confirm {
border-color: shade($green, 50%);
background: tint($green, 20%);
color: $white;
}
&.is-shown {
display: block;
}
}
// in form - elements
.group-settings { .group-settings {
margin: 0 0 ($baseline*2) 0; margin: 0 0 ($baseline*2) 0;
...@@ -45,7 +83,12 @@ body.course.settings { ...@@ -45,7 +83,12 @@ body.course.settings {
} }
// UI hints/tips/messages // in form -UI hints/tips/messages
.instructions {
@include font-size(14);
margin: 0 0 $baseline 0;
}
.tip { .tip {
@include transition(color, 0.15s, ease-in-out); @include transition(color, 0.15s, ease-in-out);
@include font-size(13); @include font-size(13);
...@@ -576,6 +619,119 @@ body.course.settings { ...@@ -576,6 +619,119 @@ body.course.settings {
} }
} }
} }
// specific fields - advanced settings
&.advanced-policies {
.field-group {
margin-bottom: ($baseline*1.5);
&:last-child {
border: none;
padding-bottom: 0;
}
}
.course-advanced-policy-list-item {
@include clearfix();
position: relative;
.field {
input {
width: 100%;
}
.tip {
@include transition (opacity 0.5s ease-in-out 0s);
opacity: 0;
position: absolute;
bottom: ($baseline*1.25);
}
input:focus {
& + .tip {
opacity: 1.0;
}
}
input.error {
& + .tip {
opacity: 0;
}
}
}
.key, .value {
float: left;
margin: 0 0 ($baseline/2) 0;
}
.key {
width: flex-grid(3, 9);
margin-right: flex-gutter();
}
.value {
width: flex-grid(6, 9);
}
.actions {
float: left;
width: flex-grid(9, 9);
.delete-button {
margin: 0;
}
}
}
.message-error {
position: absolute;
bottom: ($baseline*0.75);
}
// specific to code mirror instance in JSON policy editing, need to sync up with other similar code mirror UIs
.CodeMirror {
@include font-size(16);
@include box-sizing(border-box);
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
@include linear-gradient($lightGrey, tint($lightGrey, 90%));
padding: 5px 8px;
border: 1px solid $mediumGrey;
border-radius: 2px;
background-color: $lightGrey;
font-family: 'Open Sans', sans-serif;
color: $baseFontColor;
outline: 0;
&.CodeMirror-focused {
@include linear-gradient($paleYellow, tint($paleYellow, 90%));
outline: 0;
}
.CodeMirror-scroll {
overflow: hidden;
height: auto;
min-height: ($baseline*1.5);
max-height: ($baseline*10);
}
// editor color changes just for JSON
.CodeMirror-lines {
.cm-string {
color: #cb9c40;
}
pre {
line-height: 2.0rem;
}
}
}
}
} }
.content-supplementary { .content-supplementary {
......
...@@ -23,7 +23,8 @@ from contentstore import utils ...@@ -23,7 +23,8 @@ from contentstore import utils
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function(){ $(document).ready(function(){
// hilighting labels when fields are focused in
$("form :input").focus(function() { $("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused"); $("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() { }).blur(function() {
...@@ -205,7 +206,7 @@ from contentstore import utils ...@@ -205,7 +206,7 @@ from contentstore import utils
<aside class="content-supplementary" role="complimentary"> <aside class="content-supplementary" role="complimentary">
<div class="bit"> <div class="bit">
<h3 class="title-3">How will these settings be used</h3> <h3 class="title-3">How will these settings be used?</h3>
<p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p> <p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p>
<p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p> <p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p>
...@@ -220,6 +221,7 @@ from contentstore import utils ...@@ -220,6 +221,7 @@ from contentstore import utils
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li> <li class="nav-item"><a href="${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="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li> <li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
</ul> </ul>
</nav> </nav>
% endif % endif
......
<%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %>
<%block name="title">Advanced Settings</%block>
<%block name="bodyclass">is-signedin course advanced settings</%block>
<%namespace name='static' file='static_content.html'/>
<%!
from contentstore import utils
%>
<%block name="jsextra">
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/server_error.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/advanced.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/advanced_view.js')}"></script>
<script type="text/javascript">
$(document).ready(function () {
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new CMS.Models.Settings.Advanced(${advanced_dict | n}, {parse: true});
advancedModel.blacklistKeys = ${advanced_blacklist | n};
advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}";
var editor = new CMS.Views.Settings.Advanced({
el: $('.settings-advanced'),
model: advancedModel
});
editor.render();
});
</script>
</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<header class="page">
<span class="title-sub">Settings</span>
<h1 class="title-1">Advanced Settings</h1>
</header>
<article class="content-primary" role="main">
<form id="settings_advanced" class="settings-advanced" method="post" action="">
<div class="message message-status confirm">
Your policy changes have been saved.
</div>
<div class="message message-status error">
There was an error saving your information. Please see below.
</div>
<section class="group-settings advanced-policies">
<header>
<h2 class="title-2">Manual Policy Definition</h2>
<span class="tip">Manually Edit Course Policy Values (JSON Key / Value pairs)</span>
</header>
<p class="instructions"><strong>Warning</strong>: Add only manual policy data that you are familiar
with.</p>
<ul class="list-input course-advanced-policy-list enum">
</ul>
<div class="actions">
<a href="#" class="button new-button new-advanced-policy-item add-policy-data">
<span class="plus-icon white"></span>New Manual Policy
</a>
</div>
</section>
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">How will these settings be used?</h3>
<p>Manual policies are JSON-based key and value pairs that allow you add additional settings which edX Studio will use when generating your course.</p>
<p>Any policies you define here will override any other information you've defined elsewhere in Studio. With this in mind, please be very careful and do not add policies that you are unfamiliar with (both their purpose and their syntax).</p>
</div>
<div class="bit">
% if context_course:
<% ctx_loc = context_course.location %>
<%! from django.core.urlresolvers import reverse %>
<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 &amp; Schedule</a></li>
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
</ul>
</nav>
% endif
</div>
</aside>
</section>
</div>
<!-- notification: change has been made and a save is needed -->
<div class="wrapper wrapper-notification wrapper-notification-warning">
<div class="notification warning">
<div class="copy">
<i class="ss-icon ss-symbolicons-block icon icon-warning">&#x26A0;</i>
<p><strong>Note: </strong>Your changes will not take effect until you <strong>save your
progress</strong>. Take care with key and value formatting, as validation is <strong>not implemented</strong>.</p>
</div>
<div class="actions">
<ul>
<li><a href="#" class="save-button">Save</a></li>
<li><a href="#" class="cancel-button">Cancel</a></li>
</ul>
</div>
</div>
</div>
</%block>
\ No newline at end of file
...@@ -126,7 +126,7 @@ from contentstore import utils ...@@ -126,7 +126,7 @@ from contentstore import utils
<aside class="content-supplementary" role="complimentary"> <aside class="content-supplementary" role="complimentary">
<div class="bit"> <div class="bit">
<h3 class="title-3">How will these settings be used</h3> <h3 class="title-3">How will these settings be used?</h3>
<p>Your grading settings will be used to calculate students grades and performance.</p> <p>Your grading settings will be used to calculate students grades and performance.</p>
<p>Overall grade range will be used in students' final grades, which are calculated by the weighting you determine for each custom assignment type.</p> <p>Overall grade range will be used in students' final grades, which are calculated by the weighting you determine for each custom assignment type.</p>
...@@ -141,6 +141,7 @@ from contentstore import utils ...@@ -141,6 +141,7 @@ from contentstore import utils
<ul> <ul>
<li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Details &amp; Schedule</a></li> <li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Details &amp; Schedule</a></li>
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li> <li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
</ul> </ul>
</nav> </nav>
% endif % endif
......
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
<li class="nav-item nav-course-settings-schedule"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Schedule &amp; Details</a></li> <li class="nav-item nav-course-settings-schedule"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">Schedule &amp; Details</a></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></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></li>
<li class="nav-item nav-course-settings-team"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li> <li class="nav-item nav-course-settings-team"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
<!-- <li class="nav-item nav-course-settings-advanced"><a href="${reverse('course_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</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></li>
</ul> </ul>
</div> </div>
</div> </div>
......
...@@ -47,6 +47,10 @@ urlpatterns = ('', ...@@ -47,6 +47,10 @@ urlpatterns = ('',
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'), url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'),
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-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'), 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'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'),
......
from student.models import User, UserProfile, Registration from student.models import User, UserProfile, Registration
from django.contrib.auth.models import Group
from datetime import datetime from datetime import datetime
from factory import Factory from factory import Factory
from xmodule.modulestore import Location from xmodule.modulestore import Location
...@@ -8,6 +9,12 @@ from uuid import uuid4 ...@@ -8,6 +9,12 @@ from uuid import uuid4
from xmodule.timeparse import stringify_time from xmodule.timeparse import stringify_time
class GroupFactory(Factory):
FACTORY_FOR = Group
name = 'staff_MITx/999/Robot_Super_Course'
class UserProfileFactory(Factory): class UserProfileFactory(Factory):
FACTORY_FOR = UserProfile FACTORY_FOR = UserProfile
......
from lettuce import world, step from lettuce import world, step
from factories import * from factories import *
from django.core.management import call_command
from lettuce.django import django_url from lettuce.django import django_url
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import CourseEnrollment from student.models import CourseEnrollment
from urllib import quote_plus from urllib import quote_plus
...@@ -21,6 +19,11 @@ def wait(step, seconds): ...@@ -21,6 +19,11 @@ def wait(step, seconds):
time.sleep(float(seconds)) time.sleep(float(seconds))
@step('I reload the page$')
def reload_the_page(step):
world.browser.reload()
@step('I (?:visit|access|open) the homepage$') @step('I (?:visit|access|open) the homepage$')
def i_visit_the_homepage(step): def i_visit_the_homepage(step):
world.browser.visit(django_url('/')) world.browser.visit(django_url('/'))
...@@ -105,6 +108,11 @@ def i_am_an_edx_user(step): ...@@ -105,6 +108,11 @@ def i_am_an_edx_user(step):
#### helper functions #### helper functions
@world.absorb
def scroll_to_bottom():
# Maximize the browser
world.browser.execute_script("window.scrollTo(0, screen.height);")
@world.absorb @world.absorb
def create_user(uname): def create_user(uname):
......
...@@ -111,7 +111,7 @@ class DragAndDrop(object): ...@@ -111,7 +111,7 @@ class DragAndDrop(object):
Returns: bool. Returns: bool.
''' '''
for draggable in self.excess_draggables: for draggable in self.excess_draggables:
if not self.excess_draggables[draggable]: if self.excess_draggables[draggable]:
return False # user answer has more draggables than correct answer return False # user answer has more draggables than correct answer
# Number of draggables in user_groups may be differ that in # Number of draggables in user_groups may be differ that in
...@@ -304,8 +304,13 @@ class DragAndDrop(object): ...@@ -304,8 +304,13 @@ class DragAndDrop(object):
user_answer = json.loads(user_answer) user_answer = json.loads(user_answer)
# check if we have draggables that are not in correct answer: # This dictionary will hold a key for each draggable the user placed on
self.excess_draggables = {} # the image. The value is True if that draggable is not mentioned in any
# correct_answer entries. If the draggable is mentioned in at least one
# correct_answer entry, the value is False.
# default to consider every user answer excess until proven otherwise.
self.excess_draggables = dict((users_draggable.keys()[0],True)
for users_draggable in user_answer['draggables'])
# create identical data structures from user answer and correct answer # create identical data structures from user answer and correct answer
for i in xrange(0, len(correct_answer)): for i in xrange(0, len(correct_answer)):
...@@ -322,11 +327,8 @@ class DragAndDrop(object): ...@@ -322,11 +327,8 @@ class DragAndDrop(object):
self.user_groups[groupname].append(draggable_name) self.user_groups[groupname].append(draggable_name)
self.user_positions[groupname]['user'].append( self.user_positions[groupname]['user'].append(
draggable_dict[draggable_name]) draggable_dict[draggable_name])
self.excess_draggables[draggable_name] = True # proved that this is not excess
else: self.excess_draggables[draggable_name] = False
self.excess_draggables[draggable_name] = \
self.excess_draggables.get(draggable_name, False)
def grade(user_input, correct_answer): def grade(user_input, correct_answer):
""" Creates DragAndDrop instance from user_input and correct_answer and """ Creates DragAndDrop instance from user_input and correct_answer and
......
...@@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase): ...@@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
correct_answer = {'1': 't1', 'name_with_icon': 't2'} correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer)) self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_wrong(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
correct_answer = []
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_right(self):
user_input = '{"draggables": []}'
correct_answer = []
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_false(self): def test_targets_false(self):
user_input = '{"draggables": [{"1": "t1"}, \ user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}' {"name_with_icon": "t2"}]}'
......
...@@ -4,7 +4,47 @@ class @Rubric ...@@ -4,7 +4,47 @@ class @Rubric
@initialize: (location) -> @initialize: (location) ->
$('.rubric').data("location", location) $('.rubric').data("location", location)
$('input[class="score-selection"]').change @tracking_callback $('input[class="score-selection"]').change @tracking_callback
# set up the hotkeys
$(window).unbind('keydown', @keypress_callback)
$(window).keydown @keypress_callback
# display the 'current' carat
@categories = $('.rubric-category')
@category = $(@categories.first())
@category.prepend('> ')
@category_index = 0
@keypress_callback: (event) =>
# don't try to do this when user is typing in a text input
if $(event.target).is('input, textarea')
return
# for when we select via top row
if event.which >= 48 and event.which <= 57
selected = event.which - 48
# for when we select via numpad
else if event.which >= 96 and event.which <= 105
selected = event.which - 96
# we don't want to do anything since we haven't pressed a number
else
return
# if we actually have a current category (not past the end)
if(@category_index <= @categories.length)
# find the valid selections for this category
inputs = $("input[name='score-selection-#{@category_index}']")
max_score = inputs.length - 1
if selected > max_score or selected < 0
return
inputs.filter("input[value=#{selected}]").click()
# move to the next category
old_category_text = @category.html().substring(5)
@category.html(old_category_text)
@category_index++
@category = $(@categories[@category_index])
@category.prepend('> ')
@tracking_callback: (event) -> @tracking_callback: (event) ->
target_selection = $(event.target).val() target_selection = $(event.target).val()
# chop off the beginning of the name so that we can get the number of the category # chop off the beginning of the name so that we can get the number of the category
...@@ -49,6 +89,7 @@ class @CombinedOpenEnded ...@@ -49,6 +89,7 @@ class @CombinedOpenEnded
constructor: (element) -> constructor: (element) ->
@element=element @element=element
@reinitialize(element) @reinitialize(element)
$(window).keydown @keydown_handler
reinitialize: (element) -> reinitialize: (element) ->
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
...@@ -306,6 +347,7 @@ class @CombinedOpenEnded ...@@ -306,6 +347,7 @@ class @CombinedOpenEnded
if response.success if response.success
@rubric_wrapper.html(response.rubric_html) @rubric_wrapper.html(response.rubric_html)
@rubric_wrapper.show() @rubric_wrapper.show()
Rubric.initialize(@location)
@answer_area.html(response.student_response) @answer_area.html(response.student_response)
@child_state = 'assessing' @child_state = 'assessing'
@find_assessment_elements() @find_assessment_elements()
...@@ -318,6 +360,11 @@ class @CombinedOpenEnded ...@@ -318,6 +360,11 @@ class @CombinedOpenEnded
else else
@errors_area.html(@out_of_sync_message) @errors_area.html(@out_of_sync_message)
keydown_handler: (e) =>
# only do anything when the key pressed is the 'enter' key
if e.which == 13 && @child_state == 'assessing' && Rubric.check_complete()
@save_assessment(e)
save_assessment: (event) => save_assessment: (event) =>
event.preventDefault() event.preventDefault()
if @child_state == 'assessing' && Rubric.check_complete() if @child_state == 'assessing' && Rubric.check_complete()
......
...@@ -210,6 +210,9 @@ class @PeerGradingProblem ...@@ -210,6 +210,9 @@ class @PeerGradingProblem
@calibration_interstitial_page_button = $('.calibration-interstitial-page-button') @calibration_interstitial_page_button = $('.calibration-interstitial-page-button')
@flag_student_checkbox = $('.flag-checkbox') @flag_student_checkbox = $('.flag-checkbox')
@answer_unknown_checkbox = $('.answer-unknown-checkbox') @answer_unknown_checkbox = $('.answer-unknown-checkbox')
$(window).keydown @keydown_handler
@collapse_question() @collapse_question()
Collapsible.setCollapsibles(@content_panel) Collapsible.setCollapsibles(@content_panel)
...@@ -251,9 +254,6 @@ class @PeerGradingProblem ...@@ -251,9 +254,6 @@ class @PeerGradingProblem
fetch_submission_essay: () => fetch_submission_essay: () =>
@backend.post('get_next_submission', {location: @location}, @render_submission) @backend.post('get_next_submission', {location: @location}, @render_submission)
gentle_alert: (msg) =>
@grading_message.fadeIn()
@grading_message.html("<p>" + msg + "</p>")
construct_data: () -> construct_data: () ->
data = data =
...@@ -337,6 +337,14 @@ class @PeerGradingProblem ...@@ -337,6 +337,14 @@ class @PeerGradingProblem
@show_submit_button() @show_submit_button()
@grade = Rubric.get_total_score() @grade = Rubric.get_total_score()
keydown_handler: (event) =>
if event.which == 13 && @submit_button.is(':visible')
if @calibration
@submit_calibration_essay()
else
@submit_grade()
########## ##########
...@@ -473,6 +481,10 @@ class @PeerGradingProblem ...@@ -473,6 +481,10 @@ class @PeerGradingProblem
# And now hook up an event handler again # And now hook up an event handler again
$("input[class='score-selection']").change @graded_callback $("input[class='score-selection']").change @graded_callback
gentle_alert: (msg) =>
@grading_message.fadeIn()
@grading_message.html("<p>" + msg + "</p>")
collapse_question: () => collapse_question: () =>
@prompt_container.slideToggle() @prompt_container.slideToggle()
@prompt_container.toggleClass('open') @prompt_container.toggleClass('open')
......
...@@ -85,13 +85,16 @@ select { ...@@ -85,13 +85,16 @@ select {
} }
#viewerContainer { #viewerContainer {
overflow: auto; /* overflow: auto; */
box-shadow: inset 1px 0 0 hsla(0,0%,100%,.05); box-shadow: inset 1px 0 0 hsla(0,0%,100%,.05);
/* position: absolute; /* position: absolute;
top: 32px; top: 32px;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; */ left: 0; */
/* switch to using these instead: */
position: relative;
overflow: hidden;
} }
.toolbar { .toolbar {
......
...@@ -27,12 +27,28 @@ PDFJS.disableWorker = true; ...@@ -27,12 +27,28 @@ PDFJS.disableWorker = true;
var pdfViewer = this; var pdfViewer = this;
var pdfDocument = null; var pdfDocument = null;
var url = options['url']; var urlToLoad = null;
var pageNum = 1; if (options.url) {
urlToLoad = options.url;
}
var chapterUrls = null;
if (options.chapters) {
chapterUrls = options.chapters;
}
var chapterToLoad = 1;
if (options.chapterNum) {
// TODO: this should only be specified if there are
// chapters, and it should be in-bounds.
chapterToLoad = options.chapterNum;
}
var pageToLoad = 1;
if (options.pageNum) { if (options.pageNum) {
pageNum = options.pageNum; pageToLoad = options.pageNum;
} }
var chapterNum = 1;
var pageNum = 1;
var viewerElement = document.getElementById('viewer'); var viewerElement = document.getElementById('viewer');
var ANNOT_MIN_SIZE = 10; var ANNOT_MIN_SIZE = 10;
var DEFAULT_SCALE_DELTA = 1.1; var DEFAULT_SCALE_DELTA = 1.1;
...@@ -44,31 +60,28 @@ PDFJS.disableWorker = true; ...@@ -44,31 +60,28 @@ PDFJS.disableWorker = true;
var currentScaleValue = "0"; var currentScaleValue = "0";
var DEFAULT_SCALE_VALUE = "1"; var DEFAULT_SCALE_VALUE = "1";
// TESTING:
var destinations = null;
var setupText = function setupText(textdiv, content, viewport) { var setupText = function setupText(textdiv, content, viewport) {
function getPageNumberFromDest(dest) { function getPageNumberFromDest(dest) {
var destPage = 1; var destPage = 1;
if (dest instanceof Array) { if (dest instanceof Array) {
var destRef = dest[0]; var destRef = dest[0];
if (destRef instanceof Object) { if (destRef instanceof Object) {
// we would need to look this up in the // we would need to look this up in the
// list of all pages that have been loaded, // list of all pages that have been loaded,
// but we're trying to not have to load all the pages // but we're trying to not have to load all the pages
// right now. // right now.
// destPage = this.pagesRefMap[destRef.num + ' ' + destRef.gen + ' R']; // destPage = this.pagesRefMap[destRef.num + ' ' + destRef.gen + ' R'];
} else { } else {
destPage = (destRef + 1); destPage = (destRef + 1);
} }
} }
return destPage; return destPage;
} }
function bindLink(link, dest) { function bindLink(link, dest) {
// get page number from dest: // get page number from dest:
destPage = getPageNumberFromDest(dest); destPage = getPageNumberFromDest(dest);
link.href = '#page=' + destPage; link.href = '#page=' + destPage;
link.onclick = function pageViewSetupLinksOnclick() { link.onclick = function pageViewSetupLinksOnclick() {
if (dest && dest instanceof Array ) if (dest && dest instanceof Array )
...@@ -138,10 +151,10 @@ PDFJS.disableWorker = true; ...@@ -138,10 +151,10 @@ PDFJS.disableWorker = true;
// Get page info from document, resize canvas accordingly, and render page // Get page info from document, resize canvas accordingly, and render page
// //
renderPage = function(num) { renderPage = function(num) {
// don't try to render a page that cannot be rendered // don't try to render a page that cannot be rendered
if (num < 1 || num > pdfDocument.numPages) { if (num < 1 || num > pdfDocument.numPages) {
return; return;
} }
// Update logging: // Update logging:
log_event("book", { "type" : "gotopage", "old" : pageNum, "new" : num }); log_event("book", { "type" : "gotopage", "old" : pageNum, "new" : num });
...@@ -270,18 +283,37 @@ PDFJS.disableWorker = true; ...@@ -270,18 +283,37 @@ PDFJS.disableWorker = true;
// //
// Asynchronously download PDF as an ArrayBuffer // Asynchronously download PDF as an ArrayBuffer
// //
PDFJS.getDocument(url).then( loadUrl = function pdfViewLoadUrl(url, page) {
function getDocument(_pdfDocument) { PDFJS.getDocument(url).then(
pdfDocument = _pdfDocument; function getDocument(_pdfDocument) {
// display the current page with a default scale value: pdfDocument = _pdfDocument;
parseScale(DEFAULT_SCALE_VALUE); pageNum = page;
}, // if the scale has not been set before, set it now.
function getDocumentError(message, exception) { // Otherwise, don't change the current scale,
// placeholder: don't expect errors :) // but make sure it gets refreshed.
}, if (currentScale == UNKNOWN_SCALE) {
function getDocumentProgress(progressData) { parseScale(DEFAULT_SCALE_VALUE);
// placeholder: not yet ready to display loading progress } else {
}); var preservedScale = currentScale;
currentScale = UNKNOWN_SCALE;
parseScale(preservedScale);
}
},
function getDocumentError(message, exception) {
// placeholder: don't expect errors :)
},
function getDocumentProgress(progressData) {
// placeholder: not yet ready to display loading progress
});
};
loadChapterUrl = function pdfViewLoadChapterUrl(chapterNum, pageVal) {
if (chapterNum < 1 || chapterNum > chapterUrls.length) {
return;
}
var chapterUrl = chapterUrls[chapterNum-1];
loadUrl(chapterUrl, pageVal);
}
$("#previous").click(function(event) { $("#previous").click(function(event) {
prevPage(); prevPage();
...@@ -302,11 +334,34 @@ PDFJS.disableWorker = true; ...@@ -302,11 +334,34 @@ PDFJS.disableWorker = true;
parseScale(this.value); parseScale(this.value);
}); });
$('#pageNumber').change(function(event) { $('#pageNumber').change(function(event) {
var newPageVal = parseInt(this.value); var newPageVal = parseInt(this.value);
if (newPageVal) { if (newPageVal) {
renderPage(newPageVal); renderPage(newPageVal);
} }
}); });
// define navigation links for chapters:
if (chapterUrls != null) {
var loadChapterUrlHelper = function(i) {
return function(event) {
// when opening a new chapter, always open the first page:
loadChapterUrl(i, 1);
};
};
for (var index = 1; index <= chapterUrls.length; index += 1) {
$("#pdfchapter-" + index).click(loadChapterUrlHelper(index));
}
}
// finally, load the appropriate url/page
if (urlToLoad != null) {
loadUrl(urlToLoad, pageToLoad);
} else {
loadChapterUrl(chapterToLoad, pageToLoad);
}
return pdfViewer;
} }
})(jQuery); })(jQuery);
// CodeMirror version 2.23 (with edits)
//
// All functions that need access to the editor's state live inside // All functions that need access to the editor's state live inside
// the CodeMirror function. Below that, at the bottom of the file, // the CodeMirror function. Below that, at the bottom of the file,
// some utilities are defined. // some utilities are defined.
......
# Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/edXDocs.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/edXDocs.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/edXDocs"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/edXDocs"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
# -*- coding: utf-8 -*-
#
# edX Docs documentation build configuration file, created by
# sphinx-quickstart on Mon Feb 25 16:55:22 2013.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.mathjax']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'edX Data'
copyright = u'2013, edX Team'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'sphinxdoc'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'edXDocsdoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'edXDocs.tex', u'edX Data Documentation',
u'edX Team', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'edxdocs', u'edX Data Documentation',
[u'edX Team'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'edXDocs', u'edX Data Documentation',
u'edX Team', 'edXDocs', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {'http://docs.python.org/': None}
# Course Grading ##############
Course Grading
##############
This document is written to help professors understand how a final grade for a This document is written to help professors understand how a final grade for a
course is computed. course is computed.
...@@ -8,42 +9,46 @@ in a course and generating a final score (and corresponding letter grade). This ...@@ -8,42 +9,46 @@ in a course and generating a final score (and corresponding letter grade). This
grading process can be split into two phases - totaling sections and section grading process can be split into two phases - totaling sections and section
weighting. weighting.
## Totaling sections *****************
Totaling sections
*****************
The process of totaling sections is to get a percentage score (between 0.0 and The process of totaling sections is to get a percentage score (between 0.0 and
1.0) for every section in the course. A section is any module that is a direct 1.0) for every section in the course. A section is any module that is a direct
child of a chapter. For example, psets, labs, and sequences are all common child of a chapter. For example, psets, labs, and sequences are all common
sections. Only the *percentage* on the section will be available to compute the sections. Only the *percentage* on the section will be available to compute the
final grade, *not* the final number of points earned / possible. final grade, *not* the final number of points earned / possible.
.. important::
**For a section to be included in the final grade, the policies file must set For a section to be included in the final grade, the policies file must set
graded = True for the section.** `graded = True` for the section.
For each section, the grading function retrieves all problems within the For each section, the grading function retrieves all problems within the
section. The section percentage is computed as (total points earned) / (total section. The section percentage is computed as (total points earned) / (total
points possible). points possible).
******************
### Weighting Problems Weighting Problems
******************
In some cases, one might want to give weights to problems within a section. For In some cases, one might want to give weights to problems within a section. For
example, a final exam might contain 4 questions each worth 1 point by default. example, a final exam might contain four questions each worth 1 point by default.
This means each question would by default have the same weight. If one wanted This means each question would by default have the same weight. If one wanted
the first problem to be worth 50% of the final exam, the policy file could specify the first problem to be worth 50% of the final exam, the policy file could specify
weights of 30, 10, 10, and 10 to the 4 problems, respectively. weights of 30, 10, 10, and 10 to the four problems, respectively.
Note that the default weight of a problem **is not 1.** The default weight of a Note that the default weight of a problem **is not 1**. The default weight of a
problem is the module's max_grade. problem is the module's `max_grade`.
If weighting is set, each problem is worth the number of points assigned, regardless of the number of responses it contains. If weighting is set, each problem is worth the number of points assigned, regardless of the number of responses it contains.
Consider a Homework section that contains two problems. Consider a Homework section that contains two problems.
.. code-block:: xml
<problem display_name=”Problem 1”> <problem display_name=”Problem 1”>
<numericalresponse> ... </numericalreponse> <numericalresponse> ... </numericalreponse>
</problem> </problem>
and .. code-block:: xml
<problem display_name=”Problem 2”> <problem display_name=”Problem 2”>
<numericalresponse> ... </numericalreponse> <numericalresponse> ... </numericalreponse>
...@@ -51,41 +56,40 @@ and ...@@ -51,41 +56,40 @@ and
<numericalresponse> ... </numericalreponse> <numericalresponse> ... </numericalreponse>
</problem> </problem>
Without weighting, Problem 1 is worth 25% of the assignment, and Problem 2 is worth 75% of the assignment. Without weighting, Problem 1 is worth 25% of the assignment, and Problem 2 is worth 75% of the assignment.
Weighting for the problems can be set in the policy.json file. Weighting for the problems can be set in the policy.json file.
.. code-block:: json
"problem/problem1": { "problem/problem1": {
"weight": 2 "weight": 2
}, },
"problem/problem2": { "problem/problem2": {
"weight": 2 "weight": 2
}, },
With the above weighting, Problems 1 and 2 are each worth 50% of the assignment. With the above weighting, Problems 1 and 2 are each worth 50% of the assignment.
Please note: When problems have weight, the point value is automatically included in the display name *except* when “weight”: 1.When “weight”: 1, no visual change occurs in the display name, leaving the point value open to interpretation to the student. Please note: When problems have weight, the point value is automatically included in the display name *except* when `"weight": 1`. When the weight is 1, no visual change occurs in the display name, leaving the point value open to interpretation to the student.
## Section Weighting
******************
Weighting Sections
******************
Once each section has a percentage score, we must total those sections into a Once each section has a percentage score, we must total those sections into a
final grade. Of course, not every section has equal weight in the final grade. final grade. Of course, not every section has equal weight in the final grade.
The policies for weighting sections into a final grade are specified in the The policies for weighting sections into a final grade are specified in the
grading_policy.json file. grading_policy.json file.
The grading_policy.json file specifies several sub-graders that are each given The `grading_policy.json` file specifies several sub-graders that are each given
a weight and factored into the final grade. There are currently two types of a weight and factored into the final grade. There are currently two types of
sub-graders, section format graders and single section graders. sub-graders, section format graders and single section graders.
We will use this simple example of a grader with one section format grader and We will use this simple example of a grader with one section format grader and
one single section grader. one single section grader.
.. code-block:: json
"GRADER" : [ "GRADER" : [
{ {
...@@ -103,13 +107,14 @@ one single section grader. ...@@ -103,13 +107,14 @@ one single section grader.
} }
] ]
### Section Format Graders Section Format Graders
======================
A section format grader grades a set of sections with the same format, as A section format grader grades a set of sections with the same format, as
defined in the course policy file. To make a vertical named Homework1 be graded defined in the course policy file. To make a vertical named Homework1 be graded
by the Homework section format grader, the following definition would be in the by the Homework section format grader, the following definition would be in the
course policy file. course policy file.
.. code-block:: json
"vertical/Homework1": { "vertical/Homework1": {
"display_name": "Homework 1", "display_name": "Homework 1",
...@@ -132,25 +137,26 @@ A section format grader will also show the average of that section in the grade ...@@ -132,25 +137,26 @@ A section format grader will also show the average of that section in the grade
breakdown (shown on the Progress page, gradebook, etc.). breakdown (shown on the Progress page, gradebook, etc.).
### Single Section Graders Single Section Graders
======================
A single section grader grades exactly that - a single section. If a section A single section grader grades exactly that - a single section. If a section
is found with a matching format and display name then the score of that section is found with a matching format and display name then the score of that section
is used. If not, a score of 0% is assumed. is used. If not, a score of 0% is assumed.
### Combining sub-graders Combining sub-graders
=====================
The final grade is computed by taking the score and weight of each sub grader. The final grade is computed by taking the score and weight of each sub grader.
In the above example, homework will be 40% of the final grade. The final exam In the above example, homework will be 40% of the final grade. The final exam
will be 60% of the final grade. will be 60% of the final grade.
## Displaying the final grade **************************
Displaying the final grade
**************************
The final grade is then rounded up to the nearest percentage point. This is so The final grade is then rounded up to the nearest percentage point. This is so
the system can consistently display a percentage without worrying whether the the system can consistently display a percentage without worrying whether the
displayed percentage has been rounded up or down (potentially misleading the displayed percentage has been rounded up or down (potentially misleading the
student). The formula for the rounding is student). The formula for the rounding is::
rounded_percent = round(computed_percent * 100 + 0.05) / 100 rounded_percent = round(computed_percent * 100 + 0.05) / 100
......
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Bar graph example.</h2>
<p>We can request the API to plot us a bar graph.</p>
<div style="clear:both">
<p style="width:60px;float:left;">a</p>
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="width:50px;float:left;margin-left:15px;"/>
<br /><br /><br />
<p style="width:60px;float:left;">b</p>
<slider var='b' style="width:400px;float:left;"/>
<textbox var='b' style="width:50px;float:left;margin-left:15px;"/>
</div>
<plot style="clear:left;"/>
</render>
<configuration>
<parameters>
<param var="a" min="-100" max="100" step="5" initial="25" />
<param var="b" min="-100" max="100" step="5" initial="50" />
</parameters>
<functions>
<function bar="true" color="blue" label="Men">
<![CDATA[if (((x>0.9) && (x<1.1)) || ((x>4.9) && (x<5.1))) { return Math.sin(a * 0.01 * Math.PI + 2.952 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="red" label="Women">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos(b * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="green" label="Other 1">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b - 10 * a) * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="yellow" label="Other 2">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b + 7 * a) * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
</functions>
<plot>
<xrange><min>1</min><max>5</max></xrange>
<num_points>5</num_points>
<xticks>0, 0.5, 6</xticks>
<yticks>-1.5, 0.1, 1.5</yticks>
<xticks_names>
<![CDATA[
{
"1.5": "Single", "4.5": "Married"
}
]]>
</xticks_names>
<yticks_names>
<![CDATA[
{
"-1.0": "-100%", "-0.5": "-50%", "0.0": "0%", "0.5": "50%", "1.0": "100%"
}
]]>
</yticks_names>
<bar_width>0.4</bar_width>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h1>Graphic slider tool: Dynamic labels.</h1>
<p>There are two kinds of dynamic lables.
1) Dynamic changing values in graph legends.
2) Dynamic labels, which coordinates depend on parameters </p>
<p>a: <slider var="a"/></p>
<br/>
<p>b: <slider var="b"/></p>
<br/><br/>
<plot style="width:400px; height:400px;"/>
</render>
<configuration>
<parameters>
<param var="a" min="-10" max="10" step="1" initial="0" />
<param var="b" min="0" max="10" step="0.5" initial="5" />
</parameters>
<functions>
<function label="Value of a is: dyn_val_1">a * x + b</function>
<!-- dynamic values in legend -->
<function output="plot_label" el_id="dyn_val_1">a</function>
</functions>
<plot>
<xrange><min>0</min><max>30</max></xrange>
<num_points>10</num_points>
<xticks>0, 6, 30</xticks>
<yticks>-9, 1, 9</yticks>
<!-- custom labels with coordinates as any function of parameter -->
<moving_label text="Dynam_lbl 1" weight="bold">
<![CDATA[ {'x': 10, 'y': a};]]>
</moving_label>
<moving_label text="Dynam lbl 2" weight="bold">
<![CDATA[ {'x': -6, 'y': b};]]>
</moving_label>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
\ No newline at end of file
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Dynamic range and implicit functions.</h2>
<p>You can make x range (not ticks of x axis) of functions to depend on
parameter value. This can be useful when function domain depends
on parameter.</p>
<p>Also implicit functons like circle can be plotted as 2 separate
functions of same color.</p>
<div style="height:50px;">
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="float:left;width:60px;margin-left:15px;"/>
</div>
<plot style="margin-top:15px;margin-bottom:15px;"/>
</render>
<configuration>
<parameters>
<param var="a" min="5" max="25" step="0.5" initial="12.5" />
</parameters>
<functions>
<function color="red">Math.sqrt(a * a - x * x)</function>
<function color="red">-Math.sqrt(a * a - x * x)</function>
</functions>
<plot>
<xrange>
<!-- dynamic range -->
<min>-a</min>
<max>a</max>
</xrange>
<num_points>1000</num_points>
<xticks>-30, 6, 30</xticks>
<yticks>-30, 6, 30</yticks>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Output to DOM element.</h2>
<p>a + b = <span id="answer_span_1"></span></p>
<div style="clear:both">
<p style="float:left;margin-right:10px;">a</p>
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="width:50px;float:left;margin-left:15px;"/>
</div>
<div style="clear:both">
<p style="float:left;margin-right:10px;">b</p>
<slider var='b' style="width:400px;float:left;"/>
<textbox var='b' style="width:50px;float:left;margin-left:15px;"/>
</div>
<br/><br/><br/>
<plot/>
</render>
<configuration>
<parameters>
<param var="a" min="-10.0" max="10.0" step="0.1" initial="0" />
<param var="b" min="-10.0" max="10.0" step="0.1" initial="0" />
</parameters>
<functions>
<function output="element" el_id="answer_span_1">
function add(a, b, precision) {
var x = Math.pow(10, precision || 2);
return (Math.round(a * x) + Math.round(b * x)) / x;
}
return add(a, b, 5);
</function>
</functions>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: full example.</h2>
<p>
A simple equation
\(
y_1 = 10 \times b \times \frac{sin(a \times x) \times sin(b \times x)}{cos(b \times x) + 10}
\)
can be plotted.
</p>
<!-- make text and input or slider at the same line -->
<div>
<p style="float:left;"> Currently \(a\) is</p>
<!-- readonly input for a -->
<span id="a_readonly" style="width:50px; float:left; margin-left:10px;"/>
</div>
<!-- clear:left will make next text to begin from new line -->
<p style="clear:left"> This one
\(
y_2 = sin(a \times x)
\)
will be overlayed on top.
</p>
<div>
<p style="float:left;">Currently \(b\) is </p>
<textbox var="b" style="width:50px; float:left; margin-left:10px;"/>
</div>
<div style="clear:left;">
<p style="float:left;">To change \(a\) use:</p>
<slider var="a" style="width:400px;float:left;margin-left:10px;"/>
</div>
<div style="clear:left;">
<p style="float:left;">To change \(b\) use:</p>
<slider var="b" style="width:400px;float:left;margin-left:10px;"/>
</div>
<plot style='clear:left;width:600px;padding-top:15px;padding-bottom:20px;'/>
<div style="clear:left;height:50px;">
<p style="float:left;">Second input for b:</p>
<!-- editable input for b -->
<textbox var="b" style="color:red;width:60px;float:left;margin-left:10px;"/>
</div >
</render>
<configuration>
<parameters>
<param var="a" min="90" max="120" step="10" initial="100" />
<param var="b" min="120" max="200" step="2.3" initial="120" />
</parameters>
<functions>
<function color="#0000FF" line="false" dot="true" label="\(y_1\)">
return 10.0 * b * Math.sin(a * x) * Math.sin(b * x) / (Math.cos(b * x) + 10);
</function>
<function color="red" line="true" dot="false" label="\(y_2\)">
<!-- works w/o return, if function is single line -->
Math.sin(a * x);
</function>
<function color="#FFFF00" line="false" dot="false" label="unknown">
function helperFunc(c1) {
return c1 * c1 - a;
}
return helperFunc(x + 10 * a * b) + Math.sin(a - x);
</function>
<function output="element" el_id="a_readonly">a</function>
</functions>
<plot>
<xrange>
<min>return 0;</min>
<!-- works w/o return -->
<max>30</max>
</xrange>
<num_points>120</num_points>
<xticks>0, 3, 30</xticks>
<yticks>-1.5, 1.5, 13.5</yticks>
<xunits>\(cm\)</xunits>
<yunits>\(m\)</yunits>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
..
Public facing docs for non-developers go here. Please do not add any Python
dependencies for code introspection here (we may temporarily host it some
place where those dependencies are cumbersome to build).
edX Data Documentation
======================
The following documents are targetted at those who are working with various data formats consumed and produced by the edX platform -- primarily course authors and those who are conducting research on data in our system. Developer oriented discussion of architecture and strictly internal APIs should be documented elsewhere.
Course Data Formats
-------------------
These are data formats written by people to specify course structure and content. Some of this is abstracted away if you are using the Studio authoring user interface.
.. toctree::
:maxdepth: 2
course_data_formats/course_xml.rst
course_data_formats/grading.rst
Specific Problem Types
^^^^^^^^^^^^^^^^^^^^^^
.. toctree::
:maxdepth: 1
course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
Internal Data Formats
---------------------
These document describe how we store course structure, student state/progress, and events internally. Useful for developers or researchers who interact with our raw data exports.
.. toctree::
:maxdepth: 2
internal_data_formats/sql_schema.rst
internal_data_formats/discussion_data.rst
internal_data_formats/tracking_logs.rst
Indices and tables
==================
* :ref:`search`
######################
Discussion Forums Data
######################
Discussions in edX are stored in a MongoDB database as collections of JSON documents.
The primary collection holding all posts and comments written by users is `contents`. There are two types of objects stored here, though they share much of the same structure. A `CommentThread` represents a comment that opens a new thread -- usually a student question of some sort. A `Comment` is a reply in the conversation started by a `CommentThread`.
*****************
Shared Attributes
*****************
The attributes that `Comment` and `CommentThread` objects share are listed below.
`_id`
-----
The 12-byte MongoDB unique ID for this collection. Like all MongoDB IDs, they are monotonically increasing and the first four bytes are a timestamp.
`_type`
-------
`CommentThread` or `Comment` depending on the type of object.
`anonymous`
-----------
If true, this `Comment` or `CommentThread` will show up as written by anonymous, even to those who have moderator privileges in the forums.
`anonymous_to_peers`
--------------------
The idea behind this field was that `anonymous_to_peers = true` would make the the comment appear anonymous to your fellow students, but would allow the course staff to see who you were. However, that was never implemented in the UI, and only `anonymous` is actually used. The `anonymous_to_peers` field is always false.
`at_position_list`
------------------
No longer used. Child comments (replies) are just sorted by their `created_at` timestamp instead.
`author_id`
-----------
The user who wrote this. Corresponds to the user IDs we store in our MySQL database as `auth_user.id`
`body`
------
Text of the comment in Markdown. UTF-8 encoded.
`course_id`
-----------
The full course_id of the course that this comment was made in, including org and run. This value can be seen in the URL when browsing the courseware section. Example: `BerkeleyX/Stat2.1x/2013_Spring`
`created_at`
------------
Timestamp in UTC. Example: `ISODate("2013-02-21T03:03:04.587Z")`
`updated_at`
------------
Timestamp in UTC. Example: `ISODate("2013-02-21T03:03:04.587Z")`
`votes`
-------
Both `CommentThread` and `Comment` objects support voting. `Comment` objects that are replies to other comments still have this attribute, even though there is no way to actually vote on them in the UI. This attribute is a dictionary that has the following inside:
* `up` = list of User IDs that up-voted this comment or thread.
* `down` = list of User IDs that down-voted this comment or thread (no longer used).
* `up_count` = total upvotes received.
* `down_count` = total downvotes received (no longer used).
* `count` = total votes cast.
* `point` = net vote, now always equal to `up_count`.
A user only has one vote per `Comment` or `CommentThread`. Though it's still written to the database, the UI no longer displays an option to downvote anything.
*************
CommentThread
*************
The following fields are specific to `CommentThread` objects. Each thread in the forums is represented by one `CommentThread`.
`closed`
--------
If true, this thread was closed by a forum moderator/admin.
`comment_count`
---------------
The number of comment replies in this thread. This includes all replies to replies, but does not include the original comment that started the thread. So if we had::
CommentThread: "What's a good breakfast?"
* Comment: "Just eat cereal!"
* Comment: "Try a Loco Moco, it's amazing!"
* Comment: "A Loco Moco? Only if you want a heart attack!"
* Comment: "But it's worth it! Just get a spam musubi on the side."
In that exchange, the `comment_count` for the `CommentThread` is `4`.
`commentable_id`
----------------
We can attach a discussion to any piece of content in the course, or to top level categories like "General" and "Troubleshooting". When the `commentable_id` is a high level category, it's specified in the course's policy file. When it's a specific content piece (e.g. `600x_l5_p8`, meaning 6.00x, Lecture Sequence 5, Problem 8), it's taken from a discussion module in the course.
`last_activity_at`
------------------
Timestamp in UTC indicating the last time there was activity in the thread (new posts, edits, etc). Closing the thread does not affect the value in this field.
`tags_array`
------------
Meant to be a list of tags that were user definable, but no longer used.
`title`
-------
Title of the thread, UTF-8 string.
*******
Comment
*******
The following fields are specific to `Comment` objects. A `Comment` is a reply to a `CommentThread` (so an answer to the question), or a reply to another `Comment` (a comment about somebody's answer). It used to be the case that `Comment` replies could nest much more deeply, but we later capped it at just these three levels (question, answer, comment) much in the way that StackOverflow does.
`endorsed`
----------
Boolean value, true if a forum moderator or instructor has marked that this `Comment` is a correct answer for whatever question the thread was asking. Exists for `Comments` that are replies to other `Comments`, but in that case `endorsed` is always false because there's no way to endorse such comments through the UI.
`comment_thread_id`
-------------------
What `CommentThread` are we a part of? All `Comment` objects have this.
`parent_id`
-----------
The `parent_id` is the `_id` of the `Comment` that this comment was made in reply to. Note that this only occurs in a `Comment` that is a reply to another `Comment`; it does not appear in a `Comment` that is a reply to a `CommentThread`.
`parent_ids`
------------
The `parent_ids` attribute appears in all `Comment` objects, and contains the `_id` of all ancestor comments. Since the UI now prevents comments from being nested more than one layer deep, it will only ever have at most one element in it. If a `Comment` has no parent, it's an empty list.
#############
Tracking Logs
#############
* Tracking logs are made available as separate tar files on S3 in the course-data bucket.
* They are represented as JSON files that catalog all user interactions with the site.
* To avoid filename collisions the tracking logs are organized by server name, where each directory corresponds to a server where they were stored.
*************
Common Fields
*************
.. list-table::
:widths: 10 40 10 25
:header-rows: 1
* - field
- details
- type
- values/format
* - `username`
- username of the user who triggered the event, empty string for anonymous events (not logged in)
- string
-
* - `session`
- key identifying the user's session, may be undefined
- string
- 32 digits key
* - `time`
- GMT time the event was triggered
- string
- `YYYY-MM-DDThh:mm:ss.xxxxxx`
* - `ip`
- user ip address
- string
-
* - `agent`
- users browser agent string
- string
-
* - `page`
- page the user was visiting when the event was generated
- string
- `$URL`
* - event_source
- event source
- string
- `browser`, `server`
* - `event_type`
- type of event triggered, values depends on `event_source`
- string
- *more details listed below*
* - `event`
- specifics of the event (dependenty of the event_type)
- string/json
- *the event string may encode a JSON record*
*************
Event Sources
*************
The `event_source` field identifies whether the event originated in the browser (via javascript) or on the server (during the processing of a request).
Server Events
=============
.. list-table::
:widths: 20 10 10 10 50
:header-rows: 1
* - event_type
- event fields
- type
- values/format
- details
* - `show_answer`
- `problem_id`
- string
-
- id of the problem being shown. Ex: `i4x://MITx/6.00x/problem/L15:L15_Problem_2`
* - `save_problem_check`
- `problem_id`
- string
-
- id of the problem being shown
* -
- `success`
- string
- correct, incorrect
- whether the problem was correct
* -
- `attempts`
- integer
- number of attempts
-
* -
- `correct_map`
- string/json
-
- see details below
* -
- `state`
- string/json
-
- current problem state
* -
- `answers`
- string/json
-
- students answers
* -
- `reset_problem`
- problem_id
- string
- id of the problem being shown
`correct_map` details
---------------------
.. list-table::
:widths: 15 10 15 10
:header-rows: 1
* - correct_map fields
- type
- values/format
- null allowed?
* - hint
- string
-
-
* - hintmode
- boolean
-
- yes
* - correctness
- string
- correct, incorrect
-
* - npoints
- integer
-
- yes
* - msg
- string
-
-
* - queuestate
- string/json
- keys: key, time
-
Browser Events
==============
.. list-table::
:widths: 10 10 8 12 20 10
:header-rows: 1
* - event_type
- fields
- type
- values/format
- details
- example
* - `book`
- `type`
- string
- `gotopage`
-
-
* -
- `old`
- integer
- `$PAGE`
- from page number
- `2`
* -
- `new`
- integer
- `$PAGE`
- to page number
- `25`
* - `book`
- `type`
- string
- `nextpage`
-
-
* -
- new
- integer
- `$PAGE`
- next page number
- `10`
* - `page_close`
- *empty*
- string
-
- 'page' field indicates which page was being closed
-
* - play_video
- `id`
- string
-
- edX id of the video being watched
- `i4x-HarvardX-PH207x-video-Simple_Random_Sample`
* -
- code
- string
-
- youtube id of the video being watched
- `FU3fCJNs94Y`
* -
- `currentTime`
- float
-
- time the video was paused at, in seconds
- `1.264`
* -
- `speed`
- string
- `0.75, 1.0, 1.25, 1.50`
- video speed being played
- `"1.0"`
* - `pause_video`
- `id`
- string
-
- edX id of the video being watched
-
* -
- `code`
- string
-
- youtube id of the video being watched
-
* -
- `currentTime`
- float
-
- time the video was paused at
-
* -
- `speed`
- string
- `0.75, 1.0, 1.25, 1.50`
- video speed being played
-
* - `problem_check`
- *none*
- string
-
- event field contains the values of all input fields from the problem being checked (in the style of GET parameters (`key=value&key=value`))
-
* - `problem_show`
- `problem`
- string
-
- id of the problem being checked
-
* - `seq_goto`
- `id`
- string
-
- edX id of the sequence
-
* -
- `old`
- integer
-
- sequence element being jumped from
- `3`
* -
- `new`
- integer
-
- sequence element being jumped to
- `5`
* - `seq_next`
- `id`
- string
-
- edX id of the sequence
-
* -
- `old`
- integer
-
- sequence element being jumped from
- `4`
* -
- `new`
- integer
-
- sequence element being jumped to
- `6`
* - `rubric_select`
- `location`
- string
-
- location of the rubric's problem
- `i4x://MITx/6.00x/problem/L15:L15_Problem_2`
* -
- `category`
- integer
-
- category number of the rubric selection
-
* -
- `value`
- integer
-
- value selected within the category
-
* - `(oe / peer_grading / staff_grading)`
`_show_problem`
- `location`
- string
-
- the location of the problem whose prompt we're showing
-
* - `(oe / peer_grading / staff_grading)`
`_hide_problem`
- `location`
- string
-
- the location of the problem whose prompt we're hiding
-
* - `oe_show_full_feedback`
- *empty*
-
-
- the page where they're showing full feedback is already recorded
-
* - `oe_show_respond_to_feedback`
- *empty*
-
-
- the page where they're showing the feedback response form is already recorded
-
* - `oe_feedback_response_selected`
- `value`
- integer
-
- the value selected in the feedback response form
-
...@@ -195,7 +195,7 @@ def instructor_dashboard(request, course_id): ...@@ -195,7 +195,7 @@ def instructor_dashboard(request, course_id):
track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard')
return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id)) return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id))
elif "Reset student's attempts" in action: elif "Reset student's attempts" in action or "Delete student state for problem" in action:
# get the form data # get the form data
unique_student_identifier = request.POST.get('unique_student_identifier', '') unique_student_identifier = request.POST.get('unique_student_identifier', '')
problem_to_reset = request.POST.get('problem_to_reset', '') problem_to_reset = request.POST.get('problem_to_reset', '')
...@@ -226,28 +226,36 @@ def instructor_dashboard(request, course_id): ...@@ -226,28 +226,36 @@ def instructor_dashboard(request, course_id):
except Exception as e: except Exception as e:
msg += "<font color='red'>Couldn't find module with that urlname. </font>" msg += "<font color='red'>Couldn't find module with that urlname. </font>"
# modify the problem's state if "Delete student state for problem" in action:
try: # delete the state
# load the state json try:
problem_state = json.loads(module_to_reset.state) module_to_reset.delete()
old_number_of_attempts = problem_state["attempts"] msg += "<font color='red'>Deleted student module state for %s!</font>" % module_state_key
problem_state["attempts"] = 0 except:
msg += "Failed to delete module state for %s/%s" % (unique_student_identifier, problem_to_reset)
# save else:
module_to_reset.state = json.dumps(problem_state) # modify the problem's state
module_to_reset.save() try:
track.views.server_track(request, # load the state json
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format( problem_state = json.loads(module_to_reset.state)
old_attempts=old_number_of_attempts, old_number_of_attempts = problem_state["attempts"]
student=student_to_reset, problem_state["attempts"] = 0
problem=module_to_reset.module_state_key,
instructor=request.user, # save
course=course_id), module_to_reset.state = json.dumps(problem_state)
{}, module_to_reset.save()
page='idashboard') track.views.server_track(request,
msg += "<font color='green'>Module state successfully reset!</font>" '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
except: old_attempts=old_number_of_attempts,
msg += "<font color='red'>Couldn't reset module state. </font>" student=student_to_reset,
problem=module_to_reset.module_state_key,
instructor=request.user,
course=course_id),
{},
page='idashboard')
msg += "<font color='green'>Module state successfully reset!</font>"
except:
msg += "<font color='red'>Couldn't reset module state. </font>"
elif "Get link to student's progress page" in action: elif "Get link to student's progress page" in action:
......
...@@ -52,13 +52,18 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): ...@@ -52,13 +52,18 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None):
# strip off the quotes again... # strip off the quotes again...
return output_url[1:-1] return output_url[1:-1]
textbook['url'] = remap_static_url(textbook['url'], course) if 'url' in textbook:
textbook['url'] = remap_static_url(textbook['url'], course)
# then remap all the chapter URLs as well, if they are provided. # then remap all the chapter URLs as well, if they are provided.
if 'chapters' in textbook:
for entry in textbook['chapters']:
entry['url'] = remap_static_url(entry['url'], course)
return render_to_response('static_pdfbook.html', return render_to_response('static_pdfbook.html',
{'book_index': book_index, {'book_index': book_index,
'course': course, 'course': course,
'textbook': textbook, 'textbook': textbook,
'page': page,
'chapter': chapter, 'chapter': chapter,
'page': page,
'staff_access': staff_access}) 'staff_access': staff_access})
...@@ -183,6 +183,8 @@ class @StaffGrading ...@@ -183,6 +183,8 @@ class @StaffGrading
@breadcrumbs = $('.breadcrumbs') @breadcrumbs = $('.breadcrumbs')
$(window).keydown @keydown_handler
@question_header = $('.question-header') @question_header = $('.question-header')
@question_header.click @collapse_question @question_header.click @collapse_question
@collapse_question() @collapse_question()
...@@ -219,7 +221,7 @@ class @StaffGrading ...@@ -219,7 +221,7 @@ class @StaffGrading
setup_score_selection: => setup_score_selection: =>
@score_selection_container.html(@rubric) @score_selection_container.html(@rubric)
$('.score-selection').click => @graded_callback() $('input[class="score-selection"]').change => @graded_callback()
Rubric.initialize(@location) Rubric.initialize(@location)
...@@ -229,6 +231,10 @@ class @StaffGrading ...@@ -229,6 +231,10 @@ class @StaffGrading
@state = state_graded @state = state_graded
@submit_button.show() @submit_button.show()
keydown_handler: (e) =>
if e.which == 13 && !@list_view && Rubric.check_complete()
@submit_and_get_next()
set_button_text: (text) => set_button_text: (text) =>
@action_button.attr('value', text) @action_button.attr('value', text)
......
...@@ -102,6 +102,7 @@ div.book-wrapper { ...@@ -102,6 +102,7 @@ div.book-wrapper {
position: absolute; position: absolute;
height: 100%; height: 100%;
width: flex-grid(2, 8); width: flex-grid(2, 8);
z-index: 1;
a { a {
background-color: rgba(#000, .7); background-color: rgba(#000, .7);
......
...@@ -149,6 +149,12 @@ function goto( mode) ...@@ -149,6 +149,12 @@ function goto( mode)
<p><input type="text" name="unique_student_identifier"> <input type="submit" name="action" value="Get link to student's progress page"></p> <p><input type="text" name="unique_student_identifier"> <input type="submit" name="action" value="Get link to student's progress page"></p>
<p>and, if you want to reset the number of attempts for a problem, the urlname of that problem</p> <p>and, if you want to reset the number of attempts for a problem, the urlname of that problem</p>
<p> <input type="text" name="problem_to_reset"> <input type="submit" name="action" value="Reset student's attempts"> </p> <p> <input type="text" name="problem_to_reset"> <input type="submit" name="action" value="Reset student's attempts"> </p>
%if instructor_access:
<p> You may also delete the entire state of a student for a problem:
<input type="submit" name="action" value="Delete student state for problem"> </p>
%endif
%endif %endif
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
......
...@@ -122,7 +122,7 @@ ...@@ -122,7 +122,7 @@
<a href="${reverse('university_profile', args=['TorontoX'])}"> <a href="${reverse('university_profile', args=['TorontoX'])}">
<img src="${static.url('images/university/toronto/toronto.png')}" /> <img src="${static.url('images/university/toronto/toronto.png')}" />
<div class="name"> <div class="name">
<span>TorontoX</span> <span>University of TorontoX</span>
</div> </div>
</a> </a>
</li> </li>
......
...@@ -17,37 +17,39 @@ ...@@ -17,37 +17,39 @@
<%block name="js_extra"> <%block name="js_extra">
<script type="text/javascript"> <script type="text/javascript">
var url = "${textbook['url']}";
$(document).ready(function() { $(document).ready(function() {
$('#outerContainer').PDFViewer( { var options = {};
% if page is not None: %if 'url' in textbook:
'pageNum' : ${page}, options.url = "${textbook['url']}";
% endif %endif
'url' : url %if 'chapters' in textbook:
}); var chptrs = [];
%for chap in textbook['chapters']:
chptrs.push("${chap['url']}");
%endfor
options.chapters = chptrs;
%endif
%if chapter is not None:
options.chapterNum = ${chapter};
%endif
%if page is not None:
options.pageNum = ${page};
%endif
$('#outerContainer').PDFViewer(options);
}); });
</script> </script>
</%block> </%block>
<%include file="/courseware/course_navigation.html" args="active_page='pdftextbook/{0}'.format(book_index)" /> <%include file="/courseware/course_navigation.html" args="active_page='pdftextbook/{0}'.format(book_index)" />
<div id="outerContainer"> <div id="outerContainer">
<div id="mainContainer"> <div id="mainContainer" class="book-wrapper">
<div class="toolbar"> <div class="toolbar">
<div id="toolbarContainer"> <div id="toolbarContainer">
<div id="toolbarViewer"> <div id="toolbarViewer">
<div id="toolbarViewerLeft"> <div id="toolbarViewerLeft">
<div class="splitToolbarButton">
<button class="toolbarButton pageUp" title="Previous Page" id="previous" tabindex="5">
<span>Previous</span>
</button>
<div class="splitToolbarButtonSeparator"></div>
<button class="toolbarButton pageDown" title="Next Page" id="next" tabindex="6">
<span>Next</span>
</button>
</div>
<label id="pageNumberLabel" class="toolbarLabel" for="pageNumber">Page: </label> <label id="pageNumberLabel" class="toolbarLabel" for="pageNumber">Page: </label>
<input type="number" id="pageNumber" class="toolbarField pageNumber" value="1" size="4" min="1" tabindex="7"> <input type="number" id="pageNumber" class="toolbarField pageNumber" value="1" size="4" min="1" tabindex="7">
</input> </input>
...@@ -88,7 +90,38 @@ ...@@ -88,7 +90,38 @@
</div> </div>
</div> </div>
<div id="viewerContainer"> %if 'chapters' in textbook:
<section aria-label="Textbook Navigation" class="book-sidebar">
<ul id="booknav" class="treeview-booknav">
<%def name="print_entry(entry, index_value)">
<li id="pdfchapter-${index_value}">
<a class="chapter">
${entry.get('title')}
</a>
</li>
</%def>
% for (index, entry) in enumerate(textbook['chapters']):
${print_entry(entry, index+1)}
% endfor
</ul>
</section>
%endif
<section id="viewerContainer" class="book">
<!-- use same page-turning as used in image-based textbooks -->
<nav>
<ul>
<li class="last">
<a id="previous">Previous page</a>
</li>
<li class="next">
<a id="next">Next page</a>
</li>
</ul>
</nav>
<div id="viewer" contextmenu="viewerContextMenu"></div> <div id="viewer" contextmenu="viewerContextMenu"></div>
</div> </div>
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%block name="title"><title>TorontoX</title></%block> <%block name="title"><title>University of TorontoX</title></%block>
<%block name="university_header"> <%block name="university_header">
<header class="search" style="background: url('/static/images/university/toronto/toronto-cover.jpg')"> <header class="search" style="background: url('/static/images/university/toronto/toronto-cover.jpg')">
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div class="logo"> <div class="logo">
<img src="${static.url('images/university/toronto/toronto.png')}" /> <img src="${static.url('images/university/toronto/toronto.png')}" />
</div> </div>
<h1>TorontoX</h1> <h1>University of TorontoX</h1>
</hgroup> </hgroup>
</div> </div>
</header> </header>
......
...@@ -280,11 +280,10 @@ if settings.COURSEWARE_ENABLED: ...@@ -280,11 +280,10 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/(?P<page>[^/]*)$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/(?P<page>[^/]*)$',
'staticbook.views.pdf_index'), 'staticbook.views.pdf_index'),
# Doesn't yet support loading individual chapters... url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/$',
# url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/$', 'staticbook.views.pdf_index'),
# 'staticbook.views.pdf_index'), url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/(?P<page>[^/]*)$',
# url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/pdfbook/(?P<book_index>[^/]*)/chapter/(?P<chapter>[^/]*)/(?P<page>[^/]*)$', 'staticbook.views.pdf_index'),
# 'staticbook.views.pdf_index'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/courseware/?$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/courseware/?$',
'courseware.views.index', name="courseware"), 'courseware.views.index', name="courseware"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment