Merge branch 'master' into feature/cas/manual-policy
This commit is contained in:
@@ -1 +1 @@
|
||||
1.8.7-p371
|
||||
1.8.7-p371
|
||||
@@ -8,6 +8,8 @@ from django.core.urlresolvers import reverse
|
||||
from path import path
|
||||
from tempfile import mkdtemp
|
||||
import json
|
||||
from fs.osfs import OSFS
|
||||
|
||||
|
||||
from student.models import Registration
|
||||
from django.contrib.auth.models import User
|
||||
@@ -443,6 +445,17 @@ class ContentStoreTest(TestCase):
|
||||
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
|
||||
self.assertEqual(len(items), 0)
|
||||
|
||||
def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''):
|
||||
fs = OSFS(root_dir / 'test_export')
|
||||
self.assertTrue(fs.exists(dirname))
|
||||
|
||||
query_loc = Location('i4x', location.org, location.course, category_name, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
for item in items:
|
||||
fs = OSFS(root_dir / ('test_export/' + dirname))
|
||||
self.assertTrue(fs.exists(item.location.name + filename_suffix))
|
||||
|
||||
def test_export_course(self):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
@@ -457,6 +470,16 @@ class ContentStoreTest(TestCase):
|
||||
# export out to a tempdir
|
||||
export_to_xml(ms, cs, location, root_dir, 'test_export')
|
||||
|
||||
# check for static tabs
|
||||
self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html')
|
||||
|
||||
# check for custom_tags
|
||||
self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html')
|
||||
|
||||
# check for custom_tags
|
||||
self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template')
|
||||
|
||||
|
||||
# remove old course
|
||||
delete_course(ms, cs, location)
|
||||
|
||||
@@ -472,6 +495,7 @@ class ContentStoreTest(TestCase):
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
@@ -261,7 +261,6 @@ def edit_unit(request, location):
|
||||
break
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location)
|
||||
preview_lms_link = get_lms_link_for_item(item.location, preview=True)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
|
||||
@@ -1379,7 +1378,6 @@ def import_course(request, org, course, name):
|
||||
@login_required
|
||||
def generate_export_course(request, org, course, name):
|
||||
location = ['i4x', org, course, 'course', name]
|
||||
course_module = modulestore().get_item(location)
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
raise PermissionDenied()
|
||||
@@ -1426,3 +1424,10 @@ def export_course(request, org, course, name):
|
||||
'active_tab': 'export',
|
||||
'successful_import_redirect_url' : ''
|
||||
})
|
||||
|
||||
def event(request):
|
||||
'''
|
||||
A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at
|
||||
console logs don't get distracted :-)
|
||||
'''
|
||||
return HttpResponse(True)
|
||||
@@ -5,8 +5,22 @@ import json
|
||||
|
||||
from .common import *
|
||||
from logsettings import get_logger_config
|
||||
import os
|
||||
|
||||
############################### ALWAYS THE SAME ################################
|
||||
# specified as an environment variable. Typically this is set
|
||||
# in the service's upstart script and corresponds exactly to the service name.
|
||||
# Service variants apply config differences via env and auth JSON files,
|
||||
# the names of which correspond to the variant.
|
||||
SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None)
|
||||
|
||||
# when not variant is specified we attempt to load an unvaried
|
||||
# config set.
|
||||
CONFIG_PREFIX = ""
|
||||
|
||||
if SERVICE_VARIANT:
|
||||
CONFIG_PREFIX = SERVICE_VARIANT + "."
|
||||
|
||||
############### ALWAYS THE SAME ################################
|
||||
DEBUG = False
|
||||
TEMPLATE_DEBUG = False
|
||||
|
||||
@@ -14,9 +28,9 @@ EMAIL_BACKEND = 'django_ses.SESBackend'
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
|
||||
|
||||
########################### NON-SECURE ENV CONFIG ##############################
|
||||
############# NON-SECURE ENV CONFIG ##############################
|
||||
# Things like server locations, ports, etc.
|
||||
with open(ENV_ROOT / "cms.env.json") as env_file:
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
LMS_BASE = ENV_TOKENS.get('LMS_BASE')
|
||||
@@ -35,15 +49,16 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
|
||||
LOGGING = get_logger_config(LOG_DIR,
|
||||
logging_env=ENV_TOKENS['LOGGING_ENV'],
|
||||
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
|
||||
debug=False)
|
||||
debug=False,
|
||||
service_variant=SERVICE_VARIANT)
|
||||
|
||||
with open(ENV_ROOT / "repos.json") as repos_file:
|
||||
REPOS = json.load(repos_file)
|
||||
|
||||
|
||||
############################## SECURE AUTH ITEMS ###############################
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
with open(ENV_ROOT / "cms.auth.json") as auth_file:
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
AUTH_TOKENS = json.load(auth_file)
|
||||
|
||||
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"js_files": [
|
||||
"/static/js/vendor/RequireJS.js",
|
||||
"/static/js/vendor/jquery.min.js",
|
||||
"/static/js/vendor/jquery-ui.min.js",
|
||||
"/static/js/vendor/jquery.ui.draggable.js",
|
||||
"/static/js/vendor/jquery.cookie.js",
|
||||
"/static/js/vendor/json2.js",
|
||||
"/static/js/vendor/underscore-min.js",
|
||||
"/static/js/vendor/backbone-min.js",
|
||||
"/static/js/vendor/RequireJS.js"
|
||||
"/static/js/vendor/backbone-min.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -80,64 +80,6 @@ $(document).ready(function() {
|
||||
$('.import .file-input').click();
|
||||
});
|
||||
|
||||
// making the unit list draggable. Note: sortable didn't work b/c it considered
|
||||
// drop points which the user hovered over as destinations and proactively changed
|
||||
// the dom; so, if the user subsequently dropped at an illegal spot, the reversion
|
||||
// point was the last dom change.
|
||||
$('.unit').draggable({
|
||||
axis: 'y',
|
||||
handle: '.drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Subsection reordering
|
||||
$('.id-holder').draggable({
|
||||
axis: 'y',
|
||||
handle: '.section-item .drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-section').draggable({
|
||||
axis: 'y',
|
||||
handle: 'header .drag-handle',
|
||||
stack: '.courseware-section',
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
|
||||
$('.sortable-unit-list').droppable({
|
||||
accept : '.unit',
|
||||
greedy: true,
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onUnitReordered
|
||||
});
|
||||
$('.subsection-list > ol').droppable({
|
||||
// why don't we have a more useful class for subsections than id-holder?
|
||||
accept : '.id-holder', // '.unit, .id-holder',
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onSubsectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-overview').droppable({
|
||||
accept : '.courseware-section',
|
||||
tolerance: "pointer",
|
||||
drop: onSectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
$('.new-course-button').bind('click', addNewCourse);
|
||||
|
||||
// section name editing
|
||||
@@ -279,136 +221,6 @@ function removePolicyMetadata(e) {
|
||||
saveSubsection()
|
||||
}
|
||||
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
function initiateHesitate(event, ui) {
|
||||
CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true);
|
||||
$('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
$('.collapsed').each(function() {
|
||||
this.proportions = {width : this.offsetWidth, height : this.offsetHeight };
|
||||
// reset b/c these were holding values from aborts
|
||||
this.isover = false;
|
||||
});
|
||||
}
|
||||
function checkHoverState(event, ui) {
|
||||
// copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect
|
||||
var draggable = $(this).data("ui-draggable"),
|
||||
x1 = (draggable.positionAbs || draggable.position.absolute).left + (draggable.helperProportions.width / 2),
|
||||
y1 = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2);
|
||||
$('.collapsed').each(function() {
|
||||
// don't expand the thing being carried
|
||||
if (ui.helper.is(this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.extend(this, {offset : $(this).offset()});
|
||||
|
||||
var droppable = this,
|
||||
l = droppable.offset.left,
|
||||
r = l + droppable.proportions.width,
|
||||
t = droppable.offset.top,
|
||||
b = t + droppable.proportions.height;
|
||||
|
||||
if (l === r) {
|
||||
// probably wrong values b/c invisible at the time of caching
|
||||
droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight };
|
||||
r = l + droppable.proportions.width;
|
||||
b = t + droppable.proportions.height;
|
||||
}
|
||||
// equivalent to the intersects test
|
||||
var intersects = (l < x1 && // Right Half
|
||||
x1 < r && // Left Half
|
||||
t < y1 && // Bottom Half
|
||||
y1 < b ), // Top Half
|
||||
|
||||
c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null);
|
||||
|
||||
if(!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[c] = true;
|
||||
this[c === "isout" ? "isover" : "isout"] = false;
|
||||
$(this).trigger(c === "isover" ? "dragEnter" : "dragLeave");
|
||||
});
|
||||
}
|
||||
function removeHesitate(event, ui) {
|
||||
$('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
}
|
||||
|
||||
function expandSection(event) {
|
||||
$(event.delegateTarget).removeClass('collapsed', 400);
|
||||
// don't descend to icon's on children (which aren't under first child) only to this element's icon
|
||||
$(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse');
|
||||
}
|
||||
|
||||
function onUnitReordered(event, ui) {
|
||||
// a unit's been dropped on this subsection,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'subsection-id', 'li:.leaf');
|
||||
}
|
||||
|
||||
function onSubsectionReordered(event, ui) {
|
||||
// a subsection has been dropped on this section,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'section-id', 'li:.branch');
|
||||
}
|
||||
|
||||
function onSectionReordered(event, ui) {
|
||||
// a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order)
|
||||
_handleReorder(event, ui, 'course-id', '.courseware-section');
|
||||
}
|
||||
|
||||
function _handleReorder(event, ui, parentIdField, childrenSelector) {
|
||||
// figure out where it came from and where it slots in.
|
||||
var subsection_id = $(event.target).data(parentIdField);
|
||||
var _els = $(event.target).children(childrenSelector);
|
||||
var children = _els.map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
// if new to this parent, figure out which parent to remove it from and do so
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
var old_parent = ui.draggable.parent();
|
||||
var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
old_children = _.without(old_children, ui.draggable.data('id'));
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children})
|
||||
});
|
||||
}
|
||||
else {
|
||||
// staying in same parent
|
||||
// remove so that the replacement in the right place doesn't double it
|
||||
children = _.without(children, ui.draggable.data('id'));
|
||||
}
|
||||
// add to this parent (figure out where)
|
||||
for (var i = 0; i < _els.length; i++) {
|
||||
if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) {
|
||||
// insert at i in children and _els
|
||||
ui.draggable.insertBefore($(_els[i]));
|
||||
// TODO figure out correct way to have it remove the style: top:n; setting (and similar line below)
|
||||
ui.draggable.attr("style", "position:relative;");
|
||||
children.splice(i, 0, ui.draggable.data('id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// see if it goes at end (the above loop didn't insert it)
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
$(event.target).append(ui.draggable);
|
||||
ui.draggable.attr("style", "position:relative;"); // STYLE hack too
|
||||
children.push(ui.draggable.data('id'));
|
||||
}
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children})
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
|
||||
var edxTimeStr = null;
|
||||
|
||||
|
||||
191
cms/static/js/views/overview.js
Normal file
191
cms/static/js/views/overview.js
Normal file
@@ -0,0 +1,191 @@
|
||||
$(document).ready(function() {
|
||||
// making the unit list draggable. Note: sortable didn't work b/c it considered
|
||||
// drop points which the user hovered over as destinations and proactively changed
|
||||
// the dom; so, if the user subsequently dropped at an illegal spot, the reversion
|
||||
// point was the last dom change.
|
||||
$('.unit').draggable({
|
||||
axis: 'y',
|
||||
handle: '.drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Subsection reordering
|
||||
$('.id-holder').draggable({
|
||||
axis: 'y',
|
||||
handle: '.section-item .drag-handle',
|
||||
zIndex: 999,
|
||||
start: initiateHesitate,
|
||||
drag: checkHoverState,
|
||||
stop: removeHesitate,
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-section').draggable({
|
||||
axis: 'y',
|
||||
handle: 'header .drag-handle',
|
||||
stack: '.courseware-section',
|
||||
revert: "invalid"
|
||||
});
|
||||
|
||||
|
||||
$('.sortable-unit-list').droppable({
|
||||
accept : '.unit',
|
||||
greedy: true,
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onUnitReordered
|
||||
});
|
||||
$('.subsection-list > ol').droppable({
|
||||
// why don't we have a more useful class for subsections than id-holder?
|
||||
accept : '.id-holder', // '.unit, .id-holder',
|
||||
tolerance: "pointer",
|
||||
hoverClass: "dropover",
|
||||
drop: onSubsectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
// Section reordering
|
||||
$('.courseware-overview').droppable({
|
||||
accept : '.courseware-section',
|
||||
tolerance: "pointer",
|
||||
drop: onSectionReordered,
|
||||
greedy: true
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
function initiateHesitate(event, ui) {
|
||||
CMS.HesitateEvent.toggleXpandHesitation = new CMS.HesitateEvent(expandSection, 'dragLeave', true);
|
||||
$('.collapsed').on('dragEnter', CMS.HesitateEvent.toggleXpandHesitation, CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
$('.collapsed').each(function() {
|
||||
this.proportions = {width : this.offsetWidth, height : this.offsetHeight };
|
||||
// reset b/c these were holding values from aborts
|
||||
this.isover = false;
|
||||
});
|
||||
}
|
||||
function checkHoverState(event, ui) {
|
||||
// copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect
|
||||
var draggable = $(this).data("ui-draggable"),
|
||||
x1 = (draggable.positionAbs || draggable.position.absolute).left + (draggable.helperProportions.width / 2),
|
||||
y1 = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2);
|
||||
$('.collapsed').each(function() {
|
||||
// don't expand the thing being carried
|
||||
if (ui.helper.is(this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.extend(this, {offset : $(this).offset()});
|
||||
|
||||
var droppable = this,
|
||||
l = droppable.offset.left,
|
||||
r = l + droppable.proportions.width,
|
||||
t = droppable.offset.top,
|
||||
b = t + droppable.proportions.height;
|
||||
|
||||
if (l === r) {
|
||||
// probably wrong values b/c invisible at the time of caching
|
||||
droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight };
|
||||
r = l + droppable.proportions.width;
|
||||
b = t + droppable.proportions.height;
|
||||
}
|
||||
// equivalent to the intersects test
|
||||
var intersects = (l < x1 && // Right Half
|
||||
x1 < r && // Left Half
|
||||
t < y1 && // Bottom Half
|
||||
y1 < b ), // Top Half
|
||||
|
||||
c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null);
|
||||
|
||||
if(!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[c] = true;
|
||||
this[c === "isout" ? "isover" : "isout"] = false;
|
||||
$(this).trigger(c === "isover" ? "dragEnter" : "dragLeave");
|
||||
});
|
||||
}
|
||||
function removeHesitate(event, ui) {
|
||||
$('.collapsed').off('dragEnter', CMS.HesitateEvent.toggleXpandHesitation.trigger);
|
||||
CMS.HesitateEvent.toggleXpandHesitation = null;
|
||||
}
|
||||
|
||||
function expandSection(event) {
|
||||
$(event.delegateTarget).removeClass('collapsed', 400);
|
||||
// don't descend to icon's on children (which aren't under first child) only to this element's icon
|
||||
$(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse');
|
||||
}
|
||||
|
||||
function onUnitReordered(event, ui) {
|
||||
// a unit's been dropped on this subsection,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'subsection-id', 'li:.leaf');
|
||||
}
|
||||
|
||||
function onSubsectionReordered(event, ui) {
|
||||
// a subsection has been dropped on this section,
|
||||
// figure out where it came from and where it slots in.
|
||||
_handleReorder(event, ui, 'section-id', 'li:.branch');
|
||||
}
|
||||
|
||||
function onSectionReordered(event, ui) {
|
||||
// a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order)
|
||||
_handleReorder(event, ui, 'course-id', '.courseware-section');
|
||||
}
|
||||
|
||||
function _handleReorder(event, ui, parentIdField, childrenSelector) {
|
||||
// figure out where it came from and where it slots in.
|
||||
var subsection_id = $(event.target).data(parentIdField);
|
||||
var _els = $(event.target).children(childrenSelector);
|
||||
var children = _els.map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
// if new to this parent, figure out which parent to remove it from and do so
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
var old_parent = ui.draggable.parent();
|
||||
var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get();
|
||||
old_children = _.without(old_children, ui.draggable.data('id'));
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children})
|
||||
});
|
||||
}
|
||||
else {
|
||||
// staying in same parent
|
||||
// remove so that the replacement in the right place doesn't double it
|
||||
children = _.without(children, ui.draggable.data('id'));
|
||||
}
|
||||
// add to this parent (figure out where)
|
||||
for (var i = 0; i < _els.length; i++) {
|
||||
if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) {
|
||||
// insert at i in children and _els
|
||||
ui.draggable.insertBefore($(_els[i]));
|
||||
// TODO figure out correct way to have it remove the style: top:n; setting (and similar line below)
|
||||
ui.draggable.attr("style", "position:relative;");
|
||||
children.splice(i, 0, ui.draggable.data('id'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// see if it goes at end (the above loop didn't insert it)
|
||||
if (!_.contains(children, ui.draggable.data('id'))) {
|
||||
$(event.target).append(ui.draggable);
|
||||
ui.draggable.attr("style", "position:relative;"); // STYLE hack too
|
||||
children.push(ui.draggable.data('id'));
|
||||
}
|
||||
$.ajax({
|
||||
url: "/save_item",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data:JSON.stringify({ 'id' : subsection_id, 'children' : children})
|
||||
});
|
||||
|
||||
}
|
||||
@@ -211,15 +211,15 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
'intro_video' : 'course-introduction-video',
|
||||
'effort' : "course-effort"
|
||||
},
|
||||
|
||||
setupDatePicker : function(fieldName) {
|
||||
var cacheModel = this.model;
|
||||
var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]);
|
||||
var datefield = $(div).find(".date");
|
||||
var timefield = $(div).find(".time");
|
||||
var cachethis = this;
|
||||
var savefield = function() {
|
||||
cachethis.clearValidationErrors();
|
||||
|
||||
setupDatePicker: function (fieldName) {
|
||||
var cacheModel = this.model;
|
||||
var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]);
|
||||
var datefield = $(div).find(".date");
|
||||
var timefield = $(div).find(".time");
|
||||
var cachethis = this;
|
||||
var savefield = function () {
|
||||
cachethis.clearValidationErrors();
|
||||
var date = datefield.datepicker('getDate');
|
||||
if (date) {
|
||||
var time = timefield.timepicker("getSecondsFromMidnight");
|
||||
@@ -227,21 +227,24 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
time = 0;
|
||||
}
|
||||
var newVal = new Date(date.getTime() + time * 1000);
|
||||
if (cacheModel.get(fieldName) != newVal) cacheModel.save(fieldName, newVal,
|
||||
{ error : CMS.ServerError});
|
||||
if (cacheModel.get(fieldName).getTime() !== newVal.getTime()) {
|
||||
cacheModel.save(fieldName, newVal, { error: CMS.ServerError});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// instrument as date and time pickers
|
||||
timefield.timepicker();
|
||||
|
||||
// FIXME being called 2x on each change. Was trapping datepicker onSelect b4 but change to datepair broke that
|
||||
datefield.datepicker({ onSelect : savefield });
|
||||
timefield.on('changeTime', savefield);
|
||||
|
||||
datefield.datepicker('setDate', this.model.get(fieldName));
|
||||
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
|
||||
},
|
||||
};
|
||||
|
||||
// instrument as date and time pickers
|
||||
timefield.timepicker();
|
||||
datefield.datepicker();
|
||||
|
||||
// Using the change event causes savefield to be triggered twice, but it is necessary
|
||||
// to pick up when the date is typed directly in the field.
|
||||
datefield.change(savefield);
|
||||
timefield.on('changeTime', savefield);
|
||||
|
||||
datefield.datepicker('setDate', this.model.get(fieldName));
|
||||
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
|
||||
},
|
||||
|
||||
updateModel: function(event) {
|
||||
switch (event.currentTarget.id) {
|
||||
@@ -294,29 +297,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
}
|
||||
},
|
||||
codeMirrors : {},
|
||||
codeMirrorize : function(e, forcedTarget) {
|
||||
if (forcedTarget) {
|
||||
thisTarget = forcedTarget;
|
||||
thisTarget.id = $(thisTarget).attr('id');
|
||||
} else {
|
||||
thisTarget = e.currentTarget;
|
||||
}
|
||||
codeMirrorize: function (e, forcedTarget) {
|
||||
var thisTarget;
|
||||
if (forcedTarget) {
|
||||
thisTarget = forcedTarget;
|
||||
thisTarget.id = $(thisTarget).attr('id');
|
||||
} else {
|
||||
thisTarget = e.currentTarget;
|
||||
}
|
||||
|
||||
if (!this.codeMirrors[thisTarget.id]) {
|
||||
var cachethis = this;
|
||||
var field = this.selectorToField[thisTarget.id];
|
||||
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
|
||||
mode: "text/html", lineNumbers: true, lineWrapping: true,
|
||||
onBlur : function(mirror) {
|
||||
mirror.save();
|
||||
cachethis.clearValidationErrors();
|
||||
var newVal = mirror.getValue();
|
||||
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal,
|
||||
{ error : CMS.ServerError});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.codeMirrors[thisTarget.id]) {
|
||||
var cachethis = this;
|
||||
var field = this.selectorToField[thisTarget.id];
|
||||
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
|
||||
mode: "text/html", lineNumbers: true, lineWrapping: true,
|
||||
onBlur: function (mirror) {
|
||||
mirror.save();
|
||||
cachethis.clearValidationErrors();
|
||||
var newVal = mirror.getValue();
|
||||
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal,
|
||||
{ error: CMS.ServerError});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -668,7 +672,7 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
|
||||
$(event.currentTarget).parent().append(
|
||||
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
|
||||
'" subsections to "' + this.model.get('type') + '".'}));
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.saveIfChanged(event);
|
||||
|
||||
@@ -88,6 +88,40 @@
|
||||
background: #f6f6f6;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 1em;
|
||||
color: $baseFontColor;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.708em;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal outside none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc outside none;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1em 0;
|
||||
color: $baseFontColor;
|
||||
font-family: monospace, serif;
|
||||
font-size: 1em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $baseFontColor;
|
||||
font-family: monospace, serif;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.new-update-button {
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
<label>Display Name:</label>
|
||||
<input type="text" value="${subsection.display_name}" class="subsection-display-name-input" data-metadata-name="display_name"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Format:</label>
|
||||
<input type="text" value="${subsection.metadata['format'] if 'format' in subsection.metadata else ''}" class="unit-subtitle" data-metadata-name="format"/>
|
||||
</div>
|
||||
<div class="sortable-unit-list">
|
||||
<label>Units:</label>
|
||||
${units.enum_units(subsection, subsection_units=subsection_units)}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="${static.url('js/vendor/date.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/grader-select-view.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/overview.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -68,6 +68,8 @@ urlpatterns = ('',
|
||||
|
||||
# temporary landing page for edge
|
||||
url(r'^edge$', 'contentstore.views.edge', name='edge'),
|
||||
# noop to squelch ajax errors
|
||||
url(r'^event$', 'contentstore.views.event', name='event'),
|
||||
|
||||
url(r'^heartbeat$', include('heartbeat.urls')),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from optparse import make_option
|
||||
from json import dump
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
@@ -32,10 +33,9 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 1:
|
||||
raise CommandError("Missing single argument: output JSON file")
|
||||
|
||||
# get output location:
|
||||
outputfile = args[0]
|
||||
outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
|
||||
else:
|
||||
outputfile = args[0]
|
||||
|
||||
# construct the query object to dump:
|
||||
registrations = TestCenterRegistration.objects.all()
|
||||
@@ -65,6 +65,8 @@ class Command(BaseCommand):
|
||||
}
|
||||
if len(registration.upload_error_message) > 0:
|
||||
record['registration_error'] = registration.upload_error_message
|
||||
if len(registration.testcenter_user.upload_error_message) > 0:
|
||||
record['demographics_error'] = registration.testcenter_user.upload_error_message
|
||||
if registration.needs_uploading:
|
||||
record['needs_uploading'] = True
|
||||
|
||||
@@ -72,5 +74,5 @@ class Command(BaseCommand):
|
||||
|
||||
# dump output:
|
||||
with open(outputfile, 'w') as outfile:
|
||||
dump(output, outfile)
|
||||
dump(output, outfile, indent=2)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler
|
||||
|
||||
LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
|
||||
|
||||
def get_logger_config(log_dir,
|
||||
logging_env="no_env",
|
||||
tracking_filename="tracking.log",
|
||||
@@ -13,7 +14,8 @@ def get_logger_config(log_dir,
|
||||
syslog_addr=None,
|
||||
debug=False,
|
||||
local_loglevel='INFO',
|
||||
console_loglevel=None):
|
||||
console_loglevel=None,
|
||||
service_variant=None):
|
||||
|
||||
"""
|
||||
|
||||
@@ -39,13 +41,15 @@ def get_logger_config(log_dir,
|
||||
console_loglevel = 'DEBUG' if debug else 'INFO'
|
||||
|
||||
hostname = platform.node().split(".")[0]
|
||||
syslog_format = ("[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
syslog_format = ("[service_variant={service_variant}]"
|
||||
"[%(name)s][env:{logging_env}] %(levelname)s "
|
||||
"[{hostname} %(process)d] [%(filename)s:%(lineno)d] "
|
||||
"- %(message)s").format(
|
||||
logging_env=logging_env, hostname=hostname)
|
||||
"- %(message)s").format(service_variant=service_variant,
|
||||
logging_env=logging_env,
|
||||
hostname=hostname)
|
||||
|
||||
handlers = ['console', 'local'] if debug else ['console',
|
||||
'syslogger-remote', 'local']
|
||||
'syslogger-remote', 'local']
|
||||
|
||||
logger_config = {
|
||||
'version': 1,
|
||||
@@ -78,11 +82,6 @@ def get_logger_config(log_dir,
|
||||
}
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': handlers,
|
||||
'propagate': True,
|
||||
'level': 'INFO'
|
||||
},
|
||||
'tracking': {
|
||||
'handlers': ['tracking'],
|
||||
'level': 'DEBUG',
|
||||
@@ -93,16 +92,6 @@ def get_logger_config(log_dir,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'mitx': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'keyedcache': {
|
||||
'handlers': handlers,
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +117,9 @@ def get_logger_config(log_dir,
|
||||
},
|
||||
})
|
||||
else:
|
||||
# for production environments we will only
|
||||
# log INFO and up
|
||||
logger_config['loggers']['']['level'] = 'INFO'
|
||||
logger_config['handlers'].update({
|
||||
'local': {
|
||||
'level': local_loglevel,
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.cookie.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/jquery.tinymce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/tiny_mce/tiny_mce.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script>
|
||||
|
||||
@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
#The highest score allowed for the overall xmodule and for each rubric point
|
||||
MAX_SCORE_ALLOWED = 3
|
||||
|
||||
#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress
|
||||
#Metadata overrides this.
|
||||
IS_SCORED = False
|
||||
|
||||
#If true, then default behavior is to require a file upload or pasted link from a student for this problem.
|
||||
#Metadata overrides this.
|
||||
ACCEPT_FILE_UPLOAD = False
|
||||
|
||||
#Contains all reasonable bool and case combinations of True
|
||||
TRUE_DICT = ["True", True, "TRUE", "true"]
|
||||
|
||||
HUMAN_TASK_TYPE = {
|
||||
'selfassessment' : "Self Assessment",
|
||||
'openended' : "External Grader",
|
||||
}
|
||||
|
||||
class IncorrectMaxScoreError(Exception):
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
class CombinedOpenEndedModule(XModule):
|
||||
"""
|
||||
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
|
||||
@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule):
|
||||
#Allow reset is true if student has failed the criteria to move to the next child task
|
||||
self.allow_reset = instance_state.get('ready_to_reset', False)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT
|
||||
self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
if self._max_score > MAX_SCORE_ALLOWED:
|
||||
error_message = "Max score {0} is higher than max score allowed {1} for location {2}".format(self._max_score,
|
||||
MAX_SCORE_ALLOWED, location)
|
||||
log.error(error_message)
|
||||
raise IncorrectMaxScoreError(error_message)
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
try:
|
||||
rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric']))
|
||||
except RubricParsingError:
|
||||
log.error("Failed to parse rubric in location: {1}".format(location))
|
||||
raise
|
||||
rubric_string = stringify_children(definition['rubric'])
|
||||
rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED)
|
||||
|
||||
#Static data is passed to the child modules to render
|
||||
self.static_data = {
|
||||
'max_score': self._max_score,
|
||||
'max_attempts': self.max_attempts,
|
||||
'prompt': definition['prompt'],
|
||||
'rubric': definition['rubric'],
|
||||
'display_name': self.display_name
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
self.task_xml = definition['task_xml']
|
||||
@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule):
|
||||
elif current_task_state is None and self.current_task_number > 0:
|
||||
last_response_data = self.get_last_response(self.current_task_number - 1)
|
||||
last_response = last_response_data['response']
|
||||
current_task_state=json.dumps({
|
||||
'state' : self.ASSESSING,
|
||||
'version' : self.STATE_VERSION,
|
||||
'max_score' : self._max_score,
|
||||
'attempts' : 0,
|
||||
'created' : True,
|
||||
'history' : [{'answer' : str(last_response)}],
|
||||
current_task_state = json.dumps({
|
||||
'state': self.ASSESSING,
|
||||
'version': self.STATE_VERSION,
|
||||
'max_score': self._max_score,
|
||||
'attempts': 0,
|
||||
'created': True,
|
||||
'history': [{'answer': last_response}],
|
||||
})
|
||||
self.current_task = child_task_module(self.system, self.location,
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule):
|
||||
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
|
||||
instance_state=current_task_state)
|
||||
|
||||
log.debug(current_task_state)
|
||||
return True
|
||||
|
||||
def check_allow_reset(self):
|
||||
@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule):
|
||||
'task_count': len(self.task_xml),
|
||||
'task_number': self.current_task_number + 1,
|
||||
'status': self.get_status(),
|
||||
'display_name': self.display_name
|
||||
'display_name': self.display_name,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
return context
|
||||
@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule):
|
||||
last_correctness = task.is_last_response_correct()
|
||||
max_score = task.max_score()
|
||||
state = task.state
|
||||
if task_type in HUMAN_TASK_TYPE:
|
||||
human_task_name = HUMAN_TASK_TYPE[task_type]
|
||||
else:
|
||||
human_task_name = task_type
|
||||
|
||||
if state in task.HUMAN_NAMES:
|
||||
human_state = task.HUMAN_NAMES[state]
|
||||
else:
|
||||
human_state = state
|
||||
last_response_dict = {
|
||||
'response': last_response,
|
||||
'score': last_score,
|
||||
@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule):
|
||||
'type': task_type,
|
||||
'max_score': max_score,
|
||||
'state': state,
|
||||
'human_state': task.HUMAN_NAMES[state],
|
||||
'human_state': human_state,
|
||||
'human_task': human_task_name,
|
||||
'correct': last_correctness,
|
||||
'min_score_to_attempt': min_score_to_attempt,
|
||||
'max_score_to_attempt': max_score_to_attempt,
|
||||
@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule):
|
||||
|
||||
return status_html
|
||||
|
||||
def check_if_done_and_scored(self):
|
||||
"""
|
||||
Checks if the object is currently in a finished state (either student didn't meet criteria to move
|
||||
to next step, in which case they are in the allow_reset state, or they are done with the question
|
||||
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
|
||||
@return: Boolean corresponding to the above.
|
||||
"""
|
||||
return (self.state == self.DONE or self.allow_reset) and self.is_scored
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Score the student received on the problem, or None if there is no
|
||||
score.
|
||||
|
||||
Returns:
|
||||
dictionary
|
||||
{'score': integer, from 0 to get_max_score(),
|
||||
'total': get_max_score()}
|
||||
"""
|
||||
max_score = None
|
||||
score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
score = last_response['score']
|
||||
|
||||
score_dict = {
|
||||
'score': score,
|
||||
'total': max_score,
|
||||
}
|
||||
|
||||
return score_dict
|
||||
|
||||
def max_score(self):
|
||||
''' Maximum score. Two notes:
|
||||
|
||||
* This is generic; in abstract, a problem could be 3/5 points on one
|
||||
randomization, and 5/7 on another
|
||||
'''
|
||||
max_score = None
|
||||
if self.check_if_done_and_scored():
|
||||
last_response = self.get_last_response(self.current_task_number)
|
||||
max_score = last_response['max_score']
|
||||
return max_score
|
||||
|
||||
def get_progress(self):
|
||||
''' Return a progress.Progress object that represents how far the
|
||||
student has gone in this module. Must be implemented to get correct
|
||||
progress tracking behavior in nesting modules like sequence and
|
||||
vertical.
|
||||
|
||||
If this module has no notion of progress, return None.
|
||||
'''
|
||||
progress_object = Progress(self.current_task_number, len(self.task_xml))
|
||||
|
||||
return progress_object
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
for child in ['task']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
return elt
|
||||
@@ -4,7 +4,8 @@ from lxml import etree
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
class RubricParsingError(Exception):
|
||||
pass
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object):
|
||||
Output:
|
||||
html: the html that corresponds to the xml given
|
||||
'''
|
||||
success = False
|
||||
try:
|
||||
rubric_categories = self.extract_categories(rubric_xml)
|
||||
html = self.system.render_template('open_ended_rubric.html',
|
||||
{'categories' : rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
'view_only': self.view_only})
|
||||
success = True
|
||||
except:
|
||||
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml))
|
||||
return html
|
||||
return success, html
|
||||
|
||||
def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed):
|
||||
success, rubric_feedback = self.render_rubric(rubric_string)
|
||||
if not success:
|
||||
error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url())
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
rubric_categories = self.extract_categories(rubric_string)
|
||||
for category in rubric_categories:
|
||||
if len(category['options']) > (max_score_allowed + 1):
|
||||
error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format(
|
||||
len(category['options']), max_score_allowed)
|
||||
log.error(error_message)
|
||||
raise RubricParsingError(error_message)
|
||||
|
||||
def extract_categories(self, element):
|
||||
'''
|
||||
|
||||
@@ -442,6 +442,14 @@ section.open-ended-child {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
span.short-form-response {
|
||||
padding: 9px;
|
||||
background: #F6F6F6;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
margin-bottom: 20px;
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
.grader-status {
|
||||
padding: 9px;
|
||||
|
||||
@@ -117,7 +117,7 @@ th {
|
||||
table td, th {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc !important;
|
||||
border: 1px solid #ccc;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
123
common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<section class="course-content">
|
||||
<section class="xmodule_display xmodule_CombinedOpenEndedModule" data-type="CombinedOpenEnded">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE" data-allow_reset="False" data-state="assessing" data-task-count="2" data-task-number="1">
|
||||
|
||||
<h2>Problem 1</h2>
|
||||
<div class="status-container">
|
||||
<h4>Status</h4>
|
||||
<div class="status-elements">
|
||||
<section id="combined-open-ended-status" class="combined-open-ended-status">
|
||||
|
||||
<div class="statusitem" data-status-number="0">
|
||||
|
||||
Step 1 (Problem complete) : 1 / 1
|
||||
<span class="correct" id="status"></span>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="statusitem statusitem-current" data-status-number="1">
|
||||
|
||||
Step 2 (Being scored) : None / 1
|
||||
<span class="grading" id="status"></span>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<h4>Problem</h4>
|
||||
<div class="problem-container">
|
||||
<div class="item"><section id="openended_open_ended" class="open-ended-child" data-state="assessing" data-child-type="openended"><div class="error"></div>
|
||||
<div class="prompt">
|
||||
|
||||
Some prompt.
|
||||
|
||||
</div>
|
||||
<textarea rows="30" cols="80" name="answer" class="answer short-form-response" id="input_open_ended" disabled="disabled">Test submission. Yaaaaaaay!</textarea><div class="message-wrapper"></div>
|
||||
<div class="grader-status">
|
||||
<span class="grading" id="status_open_ended">Submitted for grading.</span>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="button" value="Submit assessment" class="submit-button" name="show" style="display: none;"><input name="skip" class="skip-button" type="button" value="Skip Post-Assessment" style="display: none;"><div class="open-ended-action"></div>
|
||||
|
||||
<span id="answer_open_ended"></span>
|
||||
</section></div>
|
||||
</div>
|
||||
|
||||
|
||||
<input type="button" value="Reset" class="reset-button" name="reset" style="display: none;">
|
||||
<input type="button" value="Next Step" class="next-step-button" name="reset" style="display: none;">
|
||||
</div>
|
||||
|
||||
<a name="results">
|
||||
<div class="result-container">
|
||||
</div>
|
||||
</a></section><a name="results">
|
||||
|
||||
|
||||
</a></section><a name="results">
|
||||
|
||||
</a><div><a name="results">
|
||||
</a><a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">Edit</a> /
|
||||
<a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" onclick="javascript:getlog('i4x_MITx_6_002x_combinedopenended_CombinedOE', {
|
||||
'location': 'i4x://MITx/6.002x/combinedopenended/CombinedOE',
|
||||
'xqa_key': 'KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A',
|
||||
'category': 'CombinedOpenEndedModule',
|
||||
'user': 'blah'
|
||||
})" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA</a>
|
||||
</div>
|
||||
<div><a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_trig">Staff Debug Info</a></div>
|
||||
|
||||
<section id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>edX Content Quality Assessment</h2>
|
||||
</header>
|
||||
|
||||
<form id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_form" class="xqa_form">
|
||||
<label>Comment</label>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_entry" type="text" placeholder="comment">
|
||||
<label>Tag</label>
|
||||
<span style="color:black;vertical-align: -10pt">Optional tag (eg "done" or "broken"): </span>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_tag" type="text" placeholder="tag" style="width:80px;display:inline">
|
||||
<div class="submit">
|
||||
<button name="submit" type="submit">Add comment</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log_data"></div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="modal staff-modal" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" style="width:80%; left:20%; height:80%; overflow:auto;">
|
||||
<div class="inner-wrapper" style="color:black">
|
||||
<header>
|
||||
<h2>Staff Debug</h2>
|
||||
</header>
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = <font color="red">Yes!</font>
|
||||
location = i4x://MITx/6.002x/combinedopenended/CombinedOE
|
||||
github = <a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml</a>
|
||||
definition = <pre>None</pre>
|
||||
metadata = {
|
||||
"showanswer": "attempted",
|
||||
"display_name": "Problem 1",
|
||||
"graceperiod": "1 day 12 hours 59 minutes 59 seconds",
|
||||
"xqa_key": "KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A",
|
||||
"rerandomize": "never",
|
||||
"start": "2012-09-05T12:00",
|
||||
"attempts": "10000",
|
||||
"data_dir": "content-mit-6002x",
|
||||
"max_score": "1"
|
||||
}
|
||||
category = CombinedOpenEndedModule
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_setup"></div>
|
||||
</section>
|
||||
@@ -0,0 +1,111 @@
|
||||
describe 'CombinedOpenEnded', ->
|
||||
beforeEach ->
|
||||
spyOn Logger, 'log'
|
||||
# load up some fixtures
|
||||
loadFixtures 'combined-open-ended.html'
|
||||
jasmine.Clock.useMock()
|
||||
@element = $('.course-content')
|
||||
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
spyOn(Collapsible, 'setCollapsibles')
|
||||
@combined = new CombinedOpenEnded @element
|
||||
|
||||
it 'set the element', ->
|
||||
expect(@combined.element).toEqual @element
|
||||
|
||||
it 'get the correct values from data fields', ->
|
||||
expect(@combined.ajax_url).toEqual '/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE'
|
||||
expect(@combined.state).toEqual 'assessing'
|
||||
expect(@combined.task_count).toEqual 2
|
||||
expect(@combined.task_number).toEqual 1
|
||||
|
||||
it 'subelements are made collapsible', ->
|
||||
expect(Collapsible.setCollapsibles).toHaveBeenCalled()
|
||||
|
||||
|
||||
describe 'poll', ->
|
||||
beforeEach =>
|
||||
# setup the spies
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'reload').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'polls at the correct intervals', =>
|
||||
fakeResponseContinue = state: 'not done'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseContinue)
|
||||
@combined.poll()
|
||||
expect(window.setTimeout).toHaveBeenCalledWith(@combined.poll, 10000)
|
||||
expect(window.queuePollerID).toBe(5)
|
||||
|
||||
it 'polling stops properly', =>
|
||||
fakeResponseDone = state: "done"
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone)
|
||||
@combined.poll()
|
||||
expect(window.queuePollerID).toBeUndefined()
|
||||
expect(window.setTimeout).not.toHaveBeenCalled()
|
||||
|
||||
describe 'rebind', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
spyOn(@combined, 'queueing').andCallFake -> return 0
|
||||
spyOn(@combined, 'skip_post_assessment').andCallFake -> return 0
|
||||
window.setTimeout = jasmine.createSpy().andCallFake (callback, timeout) -> return 5
|
||||
|
||||
it 'when our child is in an assessing state', ->
|
||||
@combined.child_state = 'assessing'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit assessment")
|
||||
expect(@combined.queueing).toHaveBeenCalled()
|
||||
|
||||
it 'when our child state is initial', ->
|
||||
@combined.child_state = 'initial'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBeUndefined()
|
||||
expect(@combined.submit_button.val()).toBe("Submit")
|
||||
|
||||
it 'when our child state is post_assessment', ->
|
||||
@combined.child_state = 'post_assessment'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.submit_button.val()).toBe("Submit post-assessment")
|
||||
|
||||
it 'when our child state is done', ->
|
||||
spyOn(@combined, 'next_problem').andCallFake ->
|
||||
@combined.child_state = 'done'
|
||||
@combined.rebind()
|
||||
expect(@combined.answer_area.attr("disabled")).toBe("disabled")
|
||||
expect(@combined.next_problem).toHaveBeenCalled()
|
||||
|
||||
describe 'next_problem', ->
|
||||
beforeEach ->
|
||||
@combined = new CombinedOpenEnded @element
|
||||
@combined.child_state = 'done'
|
||||
|
||||
it 'handling a successful call', ->
|
||||
fakeResponse =
|
||||
success: true
|
||||
html: "dummy html"
|
||||
allow_reset: false
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
spyOn(@combined, 'reinitialize')
|
||||
spyOn(@combined, 'rebind')
|
||||
@combined.next_problem()
|
||||
expect($.postWithPrefix).toHaveBeenCalled()
|
||||
expect(@combined.reinitialize).toHaveBeenCalledWith(@combined.element)
|
||||
expect(@combined.rebind).toHaveBeenCalled()
|
||||
expect(@combined.answer_area.val()).toBe('')
|
||||
expect(@combined.child_state).toBe('initial')
|
||||
|
||||
it 'handling an unsuccessful call', ->
|
||||
fakeResponse =
|
||||
success: false
|
||||
error: 'This is an error'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, val, callback) -> callback(fakeResponse)
|
||||
@combined.next_problem()
|
||||
expect(@combined.errors_area.html()).toBe(fakeResponse.error)
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
if createPlayer
|
||||
return new VideoPlayer(video: context.video)
|
||||
|
||||
spyOn(window, 'onunload')
|
||||
|
||||
# Stub jQuery.cookie
|
||||
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
|
||||
|
||||
@@ -140,15 +140,15 @@ class @Problem
|
||||
allowed_files = $(element).data("allowed_files")
|
||||
for file in element.files
|
||||
if allowed_files.length != 0 and file.name not in allowed_files
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
unallowed_file_submitted = true
|
||||
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
|
||||
if file.name in required_files
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
required_files.splice(required_files.indexOf(file.name), 1)
|
||||
if file.size > max_filesize
|
||||
file_too_large = true
|
||||
errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
|
||||
fd.append(element.id, file)
|
||||
if element.files.length == 0
|
||||
if element.files.length == 0
|
||||
file_not_selected = true
|
||||
fd.append(element.id, '') # In case we want to allow submissions with no file
|
||||
if required_files.length != 0
|
||||
@@ -157,7 +157,7 @@ class @Problem
|
||||
else
|
||||
fd.append(element.id, element.value)
|
||||
|
||||
|
||||
|
||||
if file_not_selected
|
||||
errors.push 'You did not select any files to submit'
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class @CombinedOpenEnded
|
||||
@state = @el.data('state')
|
||||
@task_count = @el.data('task-count')
|
||||
@task_number = @el.data('task-number')
|
||||
@accept_file_upload = @el.data('accept-file-upload')
|
||||
|
||||
@allow_reset = @el.data('allow_reset')
|
||||
@reset_button = @$('.reset-button')
|
||||
@@ -44,6 +45,8 @@ class @CombinedOpenEnded
|
||||
@skip_button = @$('.skip-button')
|
||||
@skip_button.click @skip_post_assessment
|
||||
|
||||
@file_upload_area = @$('.file-upload')
|
||||
@can_upload_files = false
|
||||
@open_ended_child= @$('.open-ended-child')
|
||||
|
||||
@find_assessment_elements()
|
||||
@@ -55,6 +58,16 @@ class @CombinedOpenEnded
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
show_results_current: () =>
|
||||
data = {'task_number' : @task_number-1}
|
||||
$.postWithPrefix "#{@ajax_url}/get_results", data, (response) =>
|
||||
if response.success
|
||||
@results_container.after(response.html).remove()
|
||||
@results_container = $('div.result-container')
|
||||
@submit_evaluation_button = $('.submit-evaluation-button')
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
|
||||
show_results: (event) =>
|
||||
status_item = $(event.target).parent().parent()
|
||||
status_number = status_item.data('status-number')
|
||||
@@ -67,7 +80,7 @@ class @CombinedOpenEnded
|
||||
@submit_evaluation_button.click @message_post
|
||||
Collapsible.setCollapsibles(@results_container)
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@gentle_alert response.error
|
||||
|
||||
message_post: (event)=>
|
||||
Logger.log 'message_post', @answers
|
||||
@@ -108,22 +121,28 @@ class @CombinedOpenEnded
|
||||
@submit_button.show()
|
||||
@reset_button.hide()
|
||||
@next_problem_button.hide()
|
||||
@hide_file_upload()
|
||||
@hint_area.attr('disabled', false)
|
||||
if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
if @child_type=="openended"
|
||||
@skip_button.hide()
|
||||
if @allow_reset=="True"
|
||||
@show_results_current
|
||||
@reset_button.show()
|
||||
@submit_button.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
else if @child_state == 'initial'
|
||||
@answer_area.attr("disabled", false)
|
||||
@submit_button.prop('value', 'Submit')
|
||||
@submit_button.click @save_answer
|
||||
@setup_file_upload()
|
||||
else if @child_state == 'assessing'
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hide_file_upload()
|
||||
@submit_button.prop('value', 'Submit assessment')
|
||||
@submit_button.click @save_assessment
|
||||
if @child_type == "openended"
|
||||
@@ -134,6 +153,7 @@ class @CombinedOpenEnded
|
||||
@skip_button.show()
|
||||
@skip_post_assessment()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@submit_button.prop('value', 'Submit post-assessment')
|
||||
if @child_type=="selfassessment"
|
||||
@submit_button.click @save_hint
|
||||
@@ -142,6 +162,7 @@ class @CombinedOpenEnded
|
||||
else if @child_state == 'done'
|
||||
@rubric_wrapper.hide()
|
||||
@answer_area.attr("disabled", true)
|
||||
@replace_text_inputs()
|
||||
@hint_area.attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
if @child_type=="openended"
|
||||
@@ -149,6 +170,7 @@ class @CombinedOpenEnded
|
||||
if @task_number<@task_count
|
||||
@next_problem()
|
||||
else
|
||||
@show_results_current()
|
||||
@reset_button.show()
|
||||
|
||||
|
||||
@@ -160,17 +182,41 @@ class @CombinedOpenEnded
|
||||
|
||||
save_answer: (event) =>
|
||||
event.preventDefault()
|
||||
max_filesize = 2*1000*1000 #2MB
|
||||
if @child_state == 'initial'
|
||||
data = {'student_answer' : @answer_area.val()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
files = ""
|
||||
if @can_upload_files == true
|
||||
files = $('.file-upload-box')[0].files[0]
|
||||
if files != undefined
|
||||
if files.size > max_filesize
|
||||
@can_upload_files = false
|
||||
files = ""
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
@can_upload_files = false
|
||||
|
||||
fd = new FormData()
|
||||
fd.append('student_answer', @answer_area.val())
|
||||
fd.append('student_file', files)
|
||||
fd.append('can_upload_files', @can_upload_files)
|
||||
|
||||
settings =
|
||||
type: "POST"
|
||||
data: fd
|
||||
processData: false
|
||||
contentType: false
|
||||
success: (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@rubric_wrapper.show()
|
||||
@answer_area.html(response.student_response)
|
||||
@child_state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
else
|
||||
@gentle_alert response.error
|
||||
|
||||
$.ajaxWithPrefix("#{@ajax_url}/save_answer",settings)
|
||||
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
@@ -260,6 +306,7 @@ class @CombinedOpenEnded
|
||||
@gentle_alert "Moved to next step."
|
||||
else
|
||||
@gentle_alert "Your score did not meet the criteria to move to the next step."
|
||||
@show_results_current()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@@ -282,6 +329,31 @@ class @CombinedOpenEnded
|
||||
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
|
||||
if response.state == "done" or response.state=="post_assessment"
|
||||
delete window.queuePollerID
|
||||
location.reload()
|
||||
@reload
|
||||
else
|
||||
window.queuePollerID = window.setTimeout(@poll, 10000)
|
||||
|
||||
setup_file_upload: =>
|
||||
if window.File and window.FileReader and window.FileList and window.Blob
|
||||
if @accept_file_upload == "True"
|
||||
@can_upload_files = true
|
||||
@file_upload_area.html('<input type="file" class="file-upload-box">')
|
||||
@file_upload_area.show()
|
||||
else
|
||||
@gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.'
|
||||
|
||||
hide_file_upload: =>
|
||||
if @accept_file_upload == "True"
|
||||
@file_upload_area.hide()
|
||||
|
||||
replace_text_inputs: =>
|
||||
answer_class = @answer_area.attr('class')
|
||||
answer_id = @answer_area.attr('id')
|
||||
answer_val = @answer_area.val()
|
||||
new_text = ''
|
||||
new_text = "<span class='#{answer_class}' id='#{answer_id}'>#{answer_val}</span>"
|
||||
@answer_area.replaceWith(new_text)
|
||||
|
||||
# wrap this so that it can be mocked
|
||||
reload: ->
|
||||
location.reload()
|
||||
|
||||
@@ -17,4 +17,26 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
# export the static assets
|
||||
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
|
||||
|
||||
# export the static tabs
|
||||
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
|
||||
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix = ''):
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
if len(items) > 0:
|
||||
item_dir = export_fs.makeopendir(dirname)
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
|
||||
|
||||
|
||||
|
||||
261
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
261
common/lib/xmodule/xmodule/open_ended_image_submission.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
This contains functions and classes used to evaluate if images are acceptable (do not show improper content, etc), and
|
||||
to send them to S3.
|
||||
"""
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
ENABLE_PIL = True
|
||||
except:
|
||||
ENABLE_PIL = False
|
||||
|
||||
from urlparse import urlparse
|
||||
import requests
|
||||
from boto.s3.connection import S3Connection
|
||||
from boto.s3.key import Key
|
||||
from django.conf import settings
|
||||
import pickle
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#Domains where any image linked to can be trusted to have acceptable content.
|
||||
TRUSTED_IMAGE_DOMAINS = [
|
||||
'wikipedia',
|
||||
'edxuploads.s3.amazonaws.com',
|
||||
'wikimedia',
|
||||
]
|
||||
|
||||
#Suffixes that are allowed in image urls
|
||||
ALLOWABLE_IMAGE_SUFFIXES = [
|
||||
'jpg',
|
||||
'png',
|
||||
'gif',
|
||||
'jpeg'
|
||||
]
|
||||
|
||||
#Maximum allowed dimensions (x and y) for an uploaded image
|
||||
MAX_ALLOWED_IMAGE_DIM = 1000
|
||||
|
||||
#Dimensions to which image is resized before it is evaluated for color count, etc
|
||||
MAX_IMAGE_DIM = 150
|
||||
|
||||
#Maximum number of colors that should be counted in ImageProperties
|
||||
MAX_COLORS_TO_COUNT = 16
|
||||
|
||||
#Maximum number of colors allowed in an uploaded image
|
||||
MAX_COLORS = 400
|
||||
|
||||
class ImageProperties(object):
|
||||
"""
|
||||
Class to check properties of an image and to validate if they are allowed.
|
||||
"""
|
||||
def __init__(self, image_data):
|
||||
"""
|
||||
Initializes class variables
|
||||
@param image: Image object (from PIL)
|
||||
@return: None
|
||||
"""
|
||||
self.image = Image.open(image_data)
|
||||
image_size = self.image.size
|
||||
self.image_too_large = False
|
||||
if image_size[0] > MAX_ALLOWED_IMAGE_DIM or image_size[1] > MAX_ALLOWED_IMAGE_DIM:
|
||||
self.image_too_large = True
|
||||
if image_size[0] > MAX_IMAGE_DIM or image_size[1] > MAX_IMAGE_DIM:
|
||||
self.image = self.image.resize((MAX_IMAGE_DIM, MAX_IMAGE_DIM))
|
||||
self.image_size = self.image.size
|
||||
|
||||
def count_colors(self):
|
||||
"""
|
||||
Counts the number of colors in an image, and matches them to the max allowed
|
||||
@return: boolean true if color count is acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
if colors is None:
|
||||
color_count = MAX_COLORS_TO_COUNT
|
||||
else:
|
||||
color_count = len(colors)
|
||||
|
||||
too_many_colors = (color_count <= MAX_COLORS)
|
||||
return too_many_colors
|
||||
|
||||
def check_if_rgb_is_skin(self, rgb):
|
||||
"""
|
||||
Checks if a given input rgb tuple/list is a skin tone
|
||||
@param rgb: RGB tuple
|
||||
@return: Boolean true false
|
||||
"""
|
||||
colors_okay = False
|
||||
try:
|
||||
r = rgb[0]
|
||||
g = rgb[1]
|
||||
b = rgb[2]
|
||||
check_r = (r > 60)
|
||||
check_g = (r * 0.4) < g < (r * 0.85)
|
||||
check_b = (r * 0.2) < b < (r * 0.7)
|
||||
colors_okay = check_r and check_b and check_g
|
||||
except:
|
||||
pass
|
||||
|
||||
return colors_okay
|
||||
|
||||
def get_skin_ratio(self):
|
||||
"""
|
||||
Gets the ratio of skin tone colors in an image
|
||||
@return: True if the ratio is low enough to be acceptable, false otherwise
|
||||
"""
|
||||
colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
|
||||
is_okay = True
|
||||
if colors is not None:
|
||||
skin = sum([count for count, rgb in colors if self.check_if_rgb_is_skin(rgb)])
|
||||
total_colored_pixels = sum([count for count, rgb in colors])
|
||||
bad_color_val = float(skin) / total_colored_pixels
|
||||
if bad_color_val > .4:
|
||||
is_okay = False
|
||||
|
||||
return is_okay
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Does all available checks on an image to ensure that it is okay (size, skin ratio, colors)
|
||||
@return: Boolean indicating whether or not image passes all checks
|
||||
"""
|
||||
image_is_okay = False
|
||||
try:
|
||||
image_is_okay = self.count_colors() and self.get_skin_ratio() and not self.image_too_large
|
||||
except:
|
||||
log.exception("Could not run image tests.")
|
||||
|
||||
return image_is_okay
|
||||
|
||||
|
||||
class URLProperties(object):
|
||||
"""
|
||||
Checks to see if a URL points to acceptable content. Added to check if students are submitting reasonable
|
||||
links to the peer grading image functionality of the external grading service.
|
||||
"""
|
||||
def __init__(self, url_string):
|
||||
self.url_string = url_string
|
||||
|
||||
def check_if_parses(self):
|
||||
"""
|
||||
Check to see if a URL parses properly
|
||||
@return: success (True if parses, false if not)
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
self.parsed_url = urlparse(self.url_string)
|
||||
success = True
|
||||
except:
|
||||
pass
|
||||
|
||||
return success
|
||||
|
||||
def check_suffix(self):
|
||||
"""
|
||||
Checks the suffix of a url to make sure that it is allowed
|
||||
@return: True if suffix is okay, false if not
|
||||
"""
|
||||
good_suffix = False
|
||||
for suffix in ALLOWABLE_IMAGE_SUFFIXES:
|
||||
if self.url_string.endswith(suffix):
|
||||
good_suffix = True
|
||||
break
|
||||
return good_suffix
|
||||
|
||||
def run_tests(self):
|
||||
"""
|
||||
Runs all available url tests
|
||||
@return: True if URL passes tests, false if not.
|
||||
"""
|
||||
url_is_okay = self.check_suffix() and self.check_if_parses() and self.check_domain()
|
||||
return url_is_okay
|
||||
|
||||
def check_domain(self):
|
||||
"""
|
||||
Checks to see if url is from a trusted domain
|
||||
"""
|
||||
success = False
|
||||
for domain in TRUSTED_IMAGE_DOMAINS:
|
||||
if domain in self.url_string:
|
||||
success = True
|
||||
return success
|
||||
return success
|
||||
|
||||
def run_url_tests(url_string):
|
||||
"""
|
||||
Creates a URLProperties object and runs all tests
|
||||
@param url_string: A URL in string format
|
||||
@return: Boolean indicating whether or not URL has passed all tests
|
||||
"""
|
||||
url_properties = URLProperties(url_string)
|
||||
return url_properties.run_tests()
|
||||
|
||||
|
||||
def run_image_tests(image):
|
||||
"""
|
||||
Runs all available image tests
|
||||
@param image: PIL Image object
|
||||
@return: Boolean indicating whether or not all tests have been passed
|
||||
"""
|
||||
success = False
|
||||
try:
|
||||
image_properties = ImageProperties(image)
|
||||
success = image_properties.run_tests()
|
||||
except:
|
||||
log.exception("Cannot run image tests in combined open ended xmodule. May be an issue with a particular image,"
|
||||
"or an issue with the deployment configuration of PIL/Pillow")
|
||||
return success
|
||||
|
||||
|
||||
def upload_to_s3(file_to_upload, keyname):
|
||||
'''
|
||||
Upload file to S3 using provided keyname.
|
||||
|
||||
Returns:
|
||||
public_url: URL to access uploaded file
|
||||
'''
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#TODO: determine if commented code is needed and remove
|
||||
#im = Image.open(file_to_upload)
|
||||
#out_im = cStringIO.StringIO()
|
||||
#im.save(out_im, 'PNG')
|
||||
|
||||
try:
|
||||
conn = S3Connection(settings.AWS_ACCESS_KEY_ID, settings.AWS_SECRET_ACCESS_KEY)
|
||||
bucketname = str(settings.AWS_STORAGE_BUCKET_NAME)
|
||||
bucket = conn.create_bucket(bucketname.lower())
|
||||
|
||||
k = Key(bucket)
|
||||
k.key = keyname
|
||||
k.set_metadata('filename', file_to_upload.name)
|
||||
k.set_contents_from_file(file_to_upload)
|
||||
|
||||
#This commented out code is kept here in case we change the uploading method and require images to be
|
||||
#converted before they are sent to S3.
|
||||
#k.set_contents_from_string(out_im.getvalue())
|
||||
#k.set_metadata("Content-Type", 'images/png')
|
||||
|
||||
k.set_acl("public-read")
|
||||
public_url = k.generate_url(60 * 60 * 24 * 365) # URL timeout in seconds.
|
||||
|
||||
return True, public_url
|
||||
except:
|
||||
return False, "Could not connect to S3."
|
||||
|
||||
|
||||
def get_from_s3(s3_public_url):
|
||||
"""
|
||||
Gets an image from a given S3 url
|
||||
@param s3_public_url: The URL where an image is located
|
||||
@return: The image data
|
||||
"""
|
||||
r = requests.get(s3_public_url, timeout=2)
|
||||
data = r.text
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
"""
|
||||
new_score_msg = self._parse_score_msg(score_msg, system)
|
||||
if not new_score_msg['valid']:
|
||||
score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
new_score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
|
||||
|
||||
self.record_latest_score(new_score_msg['score'])
|
||||
self.record_latest_post_assessment(score_msg)
|
||||
@@ -378,12 +378,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
Return error message or feedback template
|
||||
"""
|
||||
|
||||
log.debug(response_items)
|
||||
rubric_feedback=""
|
||||
rubric_feedback = ""
|
||||
feedback = self._convert_longform_feedback_to_html(response_items)
|
||||
if response_items['rubric_scores_complete']==True:
|
||||
if response_items['rubric_scores_complete'] == True:
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
success, rubric_feedback = rubric_renderer.render_rubric(response_items['rubric_xml'])
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("open_ended_error.html",
|
||||
@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
|
||||
'feedback': feedback,
|
||||
'rubric_feedback' : rubric_feedback
|
||||
'rubric_feedback': rubric_feedback
|
||||
})
|
||||
|
||||
return feedback_template
|
||||
@@ -406,6 +405,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'score': Numeric value (floating point is okay) to assign to answer
|
||||
'msg': grader_msg
|
||||
'feedback' : feedback from grader
|
||||
'grader_type': what type of grader resulted in this score
|
||||
'grader_id': id of the grader
|
||||
'submission_id' : id of the submission
|
||||
'success': whether or not this submission was successful
|
||||
'rubric_scores': a list of rubric scores
|
||||
'rubric_scores_complete': boolean if rubric scores are complete
|
||||
'rubric_xml': the xml of the rubric in string format
|
||||
}
|
||||
|
||||
Returns (valid_score_msg, correct, score, msg):
|
||||
@@ -437,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
log.error(error_message)
|
||||
fail['feedback'] = error_message
|
||||
return fail
|
||||
#This is to support peer grading
|
||||
#This is to support peer grading
|
||||
if isinstance(score_result['score'], list):
|
||||
feedback_items = []
|
||||
for i in xrange(0, len(score_result['score'])):
|
||||
@@ -448,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'success': score_result['success'],
|
||||
'grader_id': score_result['grader_id'][i],
|
||||
'submission_id': score_result['submission_id'],
|
||||
'rubric_scores_complete' : score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml' : score_result['rubric_xml'][i],
|
||||
'rubric_scores_complete': score_result['rubric_scores_complete'][i],
|
||||
'rubric_xml': score_result['rubric_xml'][i],
|
||||
}
|
||||
feedback_items.append(self._format_feedback(new_score_result, system))
|
||||
if join_feedback:
|
||||
@@ -476,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
if not self.history:
|
||||
return ""
|
||||
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, join_feedback=join_feedback)
|
||||
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system,
|
||||
join_feedback=join_feedback)
|
||||
if not short_feedback:
|
||||
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
|
||||
if feedback_dict['valid']:
|
||||
@@ -554,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
error_message = ""
|
||||
if success:
|
||||
get['student_answer'] = OpenEndedModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.send_to_grader(get['student_answer'], system)
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {'success': True, }
|
||||
return {
|
||||
'success': True,
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer']
|
||||
}
|
||||
|
||||
def update_score(self, get, system):
|
||||
"""
|
||||
@@ -602,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'msg': post_assessment,
|
||||
'child_type': 'openended',
|
||||
'correct': correct,
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
log.debug(context)
|
||||
html = system.render_template('open_ended.html', context)
|
||||
return html
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from lxml.html.clean import Cleaner, autolink_html
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
import re
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
@@ -21,6 +23,7 @@ from .stringify import stringify_children
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from capa.util import *
|
||||
import open_ended_image_submission
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -94,6 +97,7 @@ class OpenEndedChild(object):
|
||||
self.prompt = static_data['prompt']
|
||||
self.rubric = static_data['rubric']
|
||||
self.display_name = static_data['display_name']
|
||||
self.accept_file_upload = static_data['accept_file_upload']
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
@@ -113,7 +117,7 @@ class OpenEndedChild(object):
|
||||
pass
|
||||
|
||||
def latest_answer(self):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('answer', "")
|
||||
@@ -125,17 +129,31 @@ class OpenEndedChild(object):
|
||||
return self.history[-1].get('score')
|
||||
|
||||
def latest_post_assessment(self, system):
|
||||
"""None if not available"""
|
||||
"""Empty string if not available"""
|
||||
if not self.history:
|
||||
return ""
|
||||
return self.history[-1].get('post_assessment', "")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_html(answer):
|
||||
try:
|
||||
answer = autolink_html(answer)
|
||||
cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True,
|
||||
host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS,
|
||||
whitelist_tags=set(['embed', 'iframe', 'a', 'img']))
|
||||
clean_html = cleaner.clean_html(answer)
|
||||
clean_html = re.sub(r'</p>$', '', re.sub(r'^<p>', '', clean_html))
|
||||
except:
|
||||
clean_html = answer
|
||||
return clean_html
|
||||
|
||||
def new_history_entry(self, answer):
|
||||
"""
|
||||
Adds a new entry to the history dictionary
|
||||
@param answer: The student supplied answer
|
||||
@return: None
|
||||
"""
|
||||
answer = OpenEndedChild.sanitize_html(answer)
|
||||
self.history.append({'answer': answer})
|
||||
|
||||
def record_latest_score(self, score):
|
||||
@@ -260,5 +278,115 @@ class OpenEndedChild(object):
|
||||
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
|
||||
return correctness
|
||||
|
||||
def upload_image_to_s3(self, image_data):
|
||||
"""
|
||||
Uploads an image to S3
|
||||
Image_data: InMemoryUploadedFileObject that responds to read() and seek()
|
||||
@return:Success and a URL corresponding to the uploaded object
|
||||
"""
|
||||
success = False
|
||||
s3_public_url = ""
|
||||
image_ok = False
|
||||
try:
|
||||
image_data.seek(0)
|
||||
image_ok = open_ended_image_submission.run_image_tests(image_data)
|
||||
except:
|
||||
log.exception("Could not create image and check it.")
|
||||
|
||||
if image_ok:
|
||||
image_key = image_data.name + datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
|
||||
try:
|
||||
image_data.seek(0)
|
||||
success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key)
|
||||
except:
|
||||
log.exception("Could not upload image to S3.")
|
||||
|
||||
return success, image_ok, s3_public_url
|
||||
|
||||
def check_for_image_and_upload(self, get_data):
|
||||
"""
|
||||
Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3
|
||||
@param get_data: AJAX get data
|
||||
@return: Success, whether or not a file was in the get dictionary,
|
||||
and the html corresponding to the uploaded image
|
||||
"""
|
||||
has_file_to_upload = False
|
||||
uploaded_to_s3 = False
|
||||
image_tag = ""
|
||||
image_ok = False
|
||||
if 'can_upload_files' in get_data:
|
||||
if get_data['can_upload_files'] == 'true':
|
||||
has_file_to_upload = True
|
||||
file = get_data['student_file'][0]
|
||||
uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file)
|
||||
if uploaded_to_s3:
|
||||
image_tag = self.generate_image_tag_from_url(s3_public_url, file.name)
|
||||
|
||||
return has_file_to_upload, uploaded_to_s3, image_ok, image_tag
|
||||
|
||||
def generate_image_tag_from_url(self, s3_public_url, image_name):
|
||||
"""
|
||||
Makes an image tag from a given URL
|
||||
@param s3_public_url: URL of the image
|
||||
@param image_name: Name of the image
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
image_template = """
|
||||
<a href="{0}" target="_blank">{1}</a>
|
||||
""".format(s3_public_url, image_name)
|
||||
return image_template
|
||||
|
||||
def append_image_to_student_answer(self, get_data):
|
||||
"""
|
||||
Adds an image to a student answer after uploading it to S3
|
||||
@param get_data: AJAx get data
|
||||
@return: Boolean success, updated AJAX get data
|
||||
"""
|
||||
overall_success = False
|
||||
if not self.accept_file_upload:
|
||||
#If the question does not accept file uploads, do not do anything
|
||||
return True, get_data
|
||||
|
||||
has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(get_data)
|
||||
if uploaded_to_s3 and has_file_to_upload and image_ok:
|
||||
get_data['student_answer'] += image_tag
|
||||
overall_success = True
|
||||
elif has_file_to_upload and not uploaded_to_s3 and image_ok:
|
||||
#In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely
|
||||
#a config issue (development vs deployment). For now, just treat this as a "success"
|
||||
log.warning("Student AJAX post to combined open ended xmodule indicated that it contained an image, "
|
||||
"but the image was not able to be uploaded to S3. This could indicate a config"
|
||||
"issue with this deployment, but it could also indicate a problem with S3 or with the"
|
||||
"student image itself.")
|
||||
overall_success = True
|
||||
elif not has_file_to_upload:
|
||||
#If there is no file to upload, probably the student has embedded the link in the answer text
|
||||
success, get_data['student_answer'] = self.check_for_url_in_text(get_data['student_answer'])
|
||||
overall_success = success
|
||||
|
||||
return overall_success, get_data
|
||||
|
||||
def check_for_url_in_text(self, string):
|
||||
"""
|
||||
Checks for urls in a string
|
||||
@param string: Arbitrary string
|
||||
@return: Boolean success, the edited string
|
||||
"""
|
||||
success = False
|
||||
links = re.findall(r'(https?://\S+)', string)
|
||||
if len(links)>0:
|
||||
for link in links:
|
||||
success = open_ended_image_submission.run_url_tests(link)
|
||||
if not success:
|
||||
string = re.sub(link, '', string)
|
||||
else:
|
||||
string = re.sub(link, self.generate_image_tag_from_url(link,link), string)
|
||||
success = True
|
||||
|
||||
return success, string
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
'state': self.state,
|
||||
'allow_reset': self._allow_reset(),
|
||||
'child_type': 'selfassessment',
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
html = system.render_template('self_assessment_prompt.html', context)
|
||||
@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
log.debug(get)
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get, system)
|
||||
after = self.get_progress()
|
||||
@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
return ''
|
||||
|
||||
rubric_renderer = CombinedOpenEndedRubric(system, True)
|
||||
rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
success, rubric_html = rubric_renderer.render_rubric(self.rubric)
|
||||
|
||||
# we'll render it
|
||||
context = {'rubric': rubric_html,
|
||||
@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
error_message = ""
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
success, get = self.append_image_to_student_answer(get)
|
||||
if success:
|
||||
get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer'])
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
else:
|
||||
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'rubric_html': self.get_rubric_html(system)
|
||||
'success': success,
|
||||
'rubric_html': self.get_rubric_html(system),
|
||||
'error': error_message,
|
||||
'student_response': get['student_answer'],
|
||||
}
|
||||
|
||||
def save_assessment(self, get, system):
|
||||
|
||||
339
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
339
common/lib/xmodule/xmodule/tests/test_combined_open_ended.py
Normal file
@@ -0,0 +1,339 @@
|
||||
import json
|
||||
from mock import Mock, MagicMock, ANY
|
||||
import unittest
|
||||
|
||||
from xmodule.openendedchild import OpenEndedChild
|
||||
from xmodule.open_ended_module import OpenEndedModule
|
||||
from xmodule.combined_open_ended_module import CombinedOpenEndedModule
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
from datetime import datetime
|
||||
|
||||
from . import test_system
|
||||
"""
|
||||
Tests for the various pieces of the CombinedOpenEndedGrading system
|
||||
|
||||
OpenEndedChild
|
||||
OpenEndedModule
|
||||
|
||||
"""
|
||||
|
||||
class OpenEndedChildTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 4
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
definition = Mock()
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.openendedchild = OpenEndedChild(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
|
||||
def test_latest_answer_empty(self):
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_latest_score_empty(self):
|
||||
answer = self.openendedchild.latest_score()
|
||||
self.assertEqual(answer, None)
|
||||
|
||||
|
||||
def test_latest_post_assessment_empty(self):
|
||||
answer = self.openendedchild.latest_post_assessment(test_system)
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
def test_new_history_entry(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(answer, new_answer)
|
||||
|
||||
new_answer = "Newer Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
answer = self.openendedchild.latest_answer()
|
||||
self.assertEqual(new_answer, answer)
|
||||
|
||||
def test_record_latest_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
new_score = 3
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 3)
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
|
||||
def test_record_latest_post_assessment(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
post_assessment = "Post assessment"
|
||||
self.openendedchild.record_latest_post_assessment(post_assessment)
|
||||
self.assertEqual(post_assessment,
|
||||
self.openendedchild.latest_post_assessment(test_system))
|
||||
|
||||
def test_get_score(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], 0)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
new_score = 4
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(new_score)
|
||||
score = self.openendedchild.get_score()
|
||||
self.assertEqual(score['score'], new_score)
|
||||
self.assertEqual(score['total'], self.static_data['max_score'])
|
||||
|
||||
|
||||
def test_reset(self):
|
||||
self.openendedchild.reset(test_system)
|
||||
state = json.loads(self.openendedchild.get_instance_state())
|
||||
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
|
||||
|
||||
|
||||
def test_is_last_response_correct(self):
|
||||
new_answer = "New Answer"
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(self.static_data['max_score'])
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'correct')
|
||||
|
||||
self.openendedchild.new_history_entry(new_answer)
|
||||
self.openendedchild.record_latest_score(0)
|
||||
self.assertEqual(self.openendedchild.is_last_response_correct(),
|
||||
'incorrect')
|
||||
|
||||
class OpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment",
|
||||
"SampleQuestion"])
|
||||
|
||||
metadata = json.dumps({'attempts': '10'})
|
||||
prompt = etree.XML("<prompt>This is a question prompt</prompt>")
|
||||
rubric = etree.XML('''<rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric>''')
|
||||
max_score = 4
|
||||
|
||||
static_data = {
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name',
|
||||
'accept_file_upload': False,
|
||||
}
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
definition = {'oeparam': oeparam}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
test_system.location = self.location
|
||||
self.mock_xqueue = MagicMock()
|
||||
self.mock_xqueue.send_to_queue.return_value=(None, "Message")
|
||||
test_system.xqueue = {'interface':self.mock_xqueue, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 1}
|
||||
self.openendedmodule = OpenEndedModule(test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
def test_message_post(self):
|
||||
get = {'feedback': 'feedback text',
|
||||
'submission_id': '1',
|
||||
'grader_id': '1',
|
||||
'score': 3}
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = {
|
||||
'feedback': get['feedback'],
|
||||
'submission_id': int(get['submission_id']),
|
||||
'grader_id': int(get['grader_id']),
|
||||
'score': get['score'],
|
||||
'student_info': json.dumps(student_info)
|
||||
}
|
||||
|
||||
result = self.openendedmodule.message_post(get, test_system)
|
||||
self.assertTrue(result['success'])
|
||||
# make sure it's actually sending something we want to the queue
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body = json.dumps(contents), header=ANY)
|
||||
|
||||
state = json.loads(self.openendedmodule.get_instance_state())
|
||||
self.assertIsNotNone(state['state'], OpenEndedModule.DONE)
|
||||
|
||||
def test_send_to_grader(self):
|
||||
submission = "This is a student submission"
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = self.openendedmodule.payload.copy()
|
||||
contents.update({
|
||||
'student_info': json.dumps(student_info),
|
||||
'student_response': submission,
|
||||
'max_score': self.max_score
|
||||
})
|
||||
result = self.openendedmodule.send_to_grader(submission, test_system)
|
||||
self.assertTrue(result)
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body = json.dumps(contents), header=ANY)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg' : 'Grader Message',
|
||||
'feedback': "Grader Feedback"
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': score_msg}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
feedback = {
|
||||
"success": True,
|
||||
"feedback": "Grader Feedback"
|
||||
}
|
||||
score_msg = {
|
||||
'correct': True,
|
||||
'score': 4,
|
||||
'msg' : 'Grader Message',
|
||||
'feedback': json.dumps(feedback),
|
||||
'grader_type': 'IN',
|
||||
'grader_id': '1',
|
||||
'submission_id': '1',
|
||||
'success': True,
|
||||
'rubric_scores': [0],
|
||||
'rubric_scores_complete': True,
|
||||
'rubric_xml': etree.tostring(self.rubric)
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': json.dumps(score_msg)}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
|
||||
def test_latest_post_assessment(self):
|
||||
self.update_score_single()
|
||||
assessment = self.openendedmodule.latest_post_assessment(test_system)
|
||||
self.assertFalse(assessment == '')
|
||||
# check for errors
|
||||
self.assertFalse('errors' in assessment)
|
||||
|
||||
def test_update_score(self):
|
||||
self.update_score_single()
|
||||
score = self.openendedmodule.latest_score()
|
||||
self.assertEqual(score, 4)
|
||||
|
||||
class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "open_ended", "combinedopenended",
|
||||
"SampleQuestion"])
|
||||
|
||||
prompt = "<prompt>This is a question prompt</prompt>"
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
max_score = 3
|
||||
|
||||
metadata = {'attempts': '10', 'max_score': max_score}
|
||||
|
||||
static_data = json.dumps({
|
||||
'max_attempts': 20,
|
||||
'prompt': prompt,
|
||||
'rubric': rubric,
|
||||
'max_score': max_score,
|
||||
'display_name': 'Name'
|
||||
})
|
||||
|
||||
oeparam = etree.XML('''
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
''')
|
||||
|
||||
task_xml1 = '''
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
'''
|
||||
task_xml2 = '''
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>'''
|
||||
definition = {'prompt': etree.XML(prompt), 'rubric': etree.XML(rubric), 'task_xml': [task_xml1, task_xml2]}
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.combinedoe = CombinedOpenEndedModule(test_system, self.location, self.definition, self.descriptor, self.static_data, metadata=self.metadata)
|
||||
|
||||
def test_get_tag_name(self):
|
||||
name = self.combinedoe.get_tag_name("<t>Tag</t>")
|
||||
self.assertEqual(name, "t")
|
||||
|
||||
def test_get_last_response(self):
|
||||
response_dict = self.combinedoe.get_last_response(0)
|
||||
self.assertEqual(response_dict['type'], "selfassessment")
|
||||
self.assertEqual(response_dict['max_score'], self.max_score)
|
||||
self.assertEqual(response_dict['state'], CombinedOpenEndedModule.INITIAL)
|
||||
|
||||
def test_update_task_states(self):
|
||||
changed = self.combinedoe.update_task_states()
|
||||
self.assertFalse(changed)
|
||||
|
||||
current_task = self.combinedoe.current_task
|
||||
current_task.change_state(CombinedOpenEndedModule.DONE)
|
||||
changed = self.combinedoe.update_task_states()
|
||||
|
||||
self.assertTrue(changed)
|
||||
|
||||
|
||||
@@ -10,8 +10,16 @@ from . import test_system
|
||||
|
||||
class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
definition = {'rubric': 'A rubric',
|
||||
'prompt': 'Who?',
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>This is sample prompt text.</prompt>")
|
||||
definition = {'rubric': rubric,
|
||||
'prompt': prompt,
|
||||
'submitmessage': 'Shall we submit now?',
|
||||
'hintprompt': 'Consider this...',
|
||||
}
|
||||
@@ -23,47 +31,47 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
descriptor = Mock()
|
||||
|
||||
def test_import(self):
|
||||
def setUp(self):
|
||||
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
|
||||
'scores': [0, 1],
|
||||
'hints': ['o hai'],
|
||||
'state': SelfAssessmentModule.INITIAL,
|
||||
'attempts': 2})
|
||||
|
||||
rubric = '''<rubric><rubric>
|
||||
<category>
|
||||
<description>Response Quality</description>
|
||||
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
|
||||
</category>
|
||||
</rubric></rubric>'''
|
||||
|
||||
prompt = etree.XML("<prompt>Text</prompt>")
|
||||
static_data = {
|
||||
'max_attempts': 10,
|
||||
'rubric': etree.XML(rubric),
|
||||
'prompt': prompt,
|
||||
'rubric': etree.XML(self.rubric),
|
||||
'prompt': self.prompt,
|
||||
'max_score': 1,
|
||||
'display_name': "Name"
|
||||
'display_name': "Name",
|
||||
'accept_file_upload' : False,
|
||||
}
|
||||
|
||||
module = SelfAssessmentModule(test_system, self.location,
|
||||
self.module = SelfAssessmentModule(test_system, self.location,
|
||||
self.definition, self.descriptor,
|
||||
static_data, state, metadata=self.metadata)
|
||||
|
||||
self.assertEqual(module.get_score()['score'], 0)
|
||||
def test_get_html(self):
|
||||
html = self.module.get_html(test_system)
|
||||
self.assertTrue("This is sample prompt text" in html)
|
||||
|
||||
def test_self_assessment_flow(self):
|
||||
|
||||
self.assertEqual(self.module.get_score()['score'], 0)
|
||||
|
||||
self.module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.ASSESSING)
|
||||
|
||||
self.module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.assertEqual(module.state, module.ASSESSING)
|
||||
|
||||
module.save_assessment({'assessment': '0'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
|
||||
d = module.reset({})
|
||||
d = self.module.reset({})
|
||||
self.assertTrue(d['success'])
|
||||
self.assertEqual(module.state, module.INITIAL)
|
||||
self.assertEqual(self.module.state, self.module.INITIAL)
|
||||
|
||||
# if we now assess as right, skip the REQUEST_HINT state
|
||||
module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(module.state, module.DONE)
|
||||
self.module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
self.module.save_assessment({'assessment': '1'}, test_system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ class ResourceTemplates(object):
|
||||
log.warning("Skipping unknown template file %s" % template_file)
|
||||
continue
|
||||
template_content = resource_string(__name__, os.path.join(dirname, template_file))
|
||||
template = yaml.load(template_content)
|
||||
template = yaml.safe_load(template_content)
|
||||
templates.append(Template(**template))
|
||||
|
||||
return templates
|
||||
|
||||
1
common/test/data/full/tabs/resources.html
Normal file
1
common/test/data/full/tabs/resources.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>This is another sample tab</p>
|
||||
@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse
|
||||
|
||||
from fs.errors import ResourceNotFoundError
|
||||
|
||||
from lxml.html import rewrite_links
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
|
||||
from lxml.html import rewrite_links
|
||||
from module_render import get_module
|
||||
from courseware.access import has_access
|
||||
from static_replace import replace_urls
|
||||
@@ -27,13 +29,10 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.x_module import XModule
|
||||
|
||||
|
||||
|
||||
from open_ended_grading.peer_grading_service import PeerGradingService
|
||||
from open_ended_grading.staff_grading_service import StaffGradingService
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
from open_ended_grading import open_ended_notifications
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class InvalidTabsException(Exception):
|
||||
@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page):
|
||||
def _staff_grading(tab, user, course, active_page):
|
||||
if has_access(user, course, 'staff'):
|
||||
link = reverse('staff_grading', args=[course.id])
|
||||
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
tab_name = "Staff grading"
|
||||
img_path= ""
|
||||
try:
|
||||
notifications = json.loads(staff_gs.get_notifications(course.id))
|
||||
if notifications['success']:
|
||||
if notifications['staff_needs_to_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
log.info("Problem with getting notifications from staff grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
tab_name = "Staff grading"
|
||||
|
||||
notifications = open_ended_notifications.staff_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
def _peer_grading(tab, user, course, active_page):
|
||||
|
||||
if user.is_authenticated():
|
||||
link = reverse('peer_grading', args=[course.id])
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
tab_name = "Peer grading"
|
||||
img_path= ""
|
||||
try:
|
||||
notifications = json.loads(peer_gs.get_notifications(course.id,unique_id_for_user(user)))
|
||||
if notifications['success']:
|
||||
if notifications['student_needs_to_peer_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
log.info("Problem with getting notifications from peer grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
notifications = open_ended_notifications.peer_grading_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
def _combined_open_ended_grading(tab, user, course, active_page):
|
||||
if user.is_authenticated():
|
||||
link = reverse('open_ended_notifications', args=[course.id])
|
||||
tab_name = "Open Ended Panel"
|
||||
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
pending_grading = notifications['pending_grading']
|
||||
img_path = notifications['img_path']
|
||||
|
||||
tab = [CourseTab(tab_name, link, active_page == "open_ended", pending_grading, img_path)]
|
||||
return tab
|
||||
return []
|
||||
|
||||
|
||||
#### Validators
|
||||
|
||||
|
||||
@@ -198,6 +193,7 @@ VALID_TAB_TYPES = {
|
||||
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
|
||||
'peer_grading': TabImpl(null_validator, _peer_grading),
|
||||
'staff_grading': TabImpl(null_validator, _staff_grading),
|
||||
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
|
||||
}
|
||||
|
||||
|
||||
@@ -326,4 +322,4 @@ def get_static_tab_contents(request, cache, course, tab):
|
||||
if tab_module is not None:
|
||||
html = tab_module.get_html()
|
||||
|
||||
return html
|
||||
return html
|
||||
44
lms/djangoapps/courseware/tests/factories.py
Normal file
44
lms/djangoapps/courseware/tests/factories.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import factory
|
||||
from student.models import (User, UserProfile, Registration,
|
||||
CourseEnrollmentAllowed)
|
||||
from django.contrib.auth.models import Group
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class UserProfileFactory(factory.Factory):
|
||||
FACTORY_FOR = UserProfile
|
||||
|
||||
user = None
|
||||
name = 'Robot Studio'
|
||||
courseware = 'course.xml'
|
||||
|
||||
class RegistrationFactory(factory.Factory):
|
||||
FACTORY_FOR = Registration
|
||||
|
||||
user = None
|
||||
activation_key = uuid.uuid4().hex
|
||||
|
||||
class UserFactory(factory.Factory):
|
||||
FACTORY_FOR = User
|
||||
|
||||
username = 'robot'
|
||||
email = 'robot@edx.org'
|
||||
password = 'test'
|
||||
first_name = 'Robot'
|
||||
last_name = 'Tester'
|
||||
is_staff = False
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
last_login = datetime.now()
|
||||
date_joined = datetime.now()
|
||||
|
||||
class GroupFactory(factory.Factory):
|
||||
FACTORY_FOR = Group
|
||||
|
||||
name = 'test_group'
|
||||
|
||||
class CourseEnrollmentAllowedFactory(factory.Factory):
|
||||
FACTORY_FOR = CourseEnrollmentAllowed
|
||||
|
||||
email = 'test@edx.org'
|
||||
course_id = 'edX/test/2012_Fall'
|
||||
109
lms/djangoapps/courseware/tests/test_access.py
Normal file
109
lms/djangoapps/courseware/tests/test_access.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import unittest
|
||||
import time
|
||||
from mock import Mock
|
||||
from django.test import TestCase
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from factories import CourseEnrollmentAllowedFactory
|
||||
import courseware.access as access
|
||||
|
||||
class AccessTestCase(TestCase):
|
||||
def test__has_global_staff_access(self):
|
||||
u = Mock(is_staff=False)
|
||||
self.assertFalse(access._has_global_staff_access(u))
|
||||
|
||||
u = Mock(is_staff=True)
|
||||
self.assertTrue(access._has_global_staff_access(u))
|
||||
|
||||
def test__has_access_to_location(self):
|
||||
location = Location('i4x://edX/toy/course/2012_Fall')
|
||||
|
||||
self.assertFalse(access._has_access_to_location(None, location,
|
||||
'staff', None))
|
||||
u = Mock()
|
||||
u.is_authenticated.return_value = False
|
||||
self.assertFalse(access._has_access_to_location(u, location,
|
||||
'staff', None))
|
||||
u = Mock(is_staff=True)
|
||||
self.assertTrue(access._has_access_to_location(u, location,
|
||||
'instructor', None))
|
||||
# A user has staff access if they are in the staff group
|
||||
u = Mock(is_staff=False)
|
||||
g = Mock()
|
||||
g.name = 'staff_edX/toy/2012_Fall'
|
||||
u.groups.all.return_value = [g]
|
||||
self.assertTrue(access._has_access_to_location(u, location,
|
||||
'staff', None))
|
||||
# A user has staff access if they are in the instructor group
|
||||
g.name = 'instructor_edX/toy/2012_Fall'
|
||||
self.assertTrue(access._has_access_to_location(u, location,
|
||||
'staff', None))
|
||||
|
||||
# A user has instructor access if they are in the instructor group
|
||||
g.name = 'instructor_edX/toy/2012_Fall'
|
||||
self.assertTrue(access._has_access_to_location(u, location,
|
||||
'instructor', None))
|
||||
|
||||
# A user does not have staff access if they are
|
||||
# not in either the staff or the the instructor group
|
||||
g.name = 'student_only'
|
||||
self.assertFalse(access._has_access_to_location(u, location,
|
||||
'staff', None))
|
||||
|
||||
# A user does not have instructor access if they are
|
||||
# not in the instructor group
|
||||
g.name = 'student_only'
|
||||
self.assertFalse(access._has_access_to_location(u, location,
|
||||
'instructor', None))
|
||||
|
||||
def test__has_access_string(self):
|
||||
u = Mock(is_staff=True)
|
||||
self.assertFalse(access._has_access_string(u, 'not_global', 'staff', None))
|
||||
|
||||
u._has_global_staff_access.return_value = True
|
||||
self.assertTrue(access._has_access_string(u, 'global', 'staff', None))
|
||||
|
||||
self.assertRaises(ValueError, access._has_access_string, u, 'global', 'not_staff', None)
|
||||
|
||||
def test__has_access_descriptor(self):
|
||||
# TODO: override DISABLE_START_DATES and test the start date branch of the method
|
||||
u = Mock()
|
||||
d = Mock()
|
||||
d.start = time.gmtime(time.time() - 86400) # make sure the start time is in the past
|
||||
|
||||
# Always returns true because DISABLE_START_DATES is set in test.py
|
||||
self.assertTrue(access._has_access_descriptor(u, d, 'load'))
|
||||
self.assertRaises(ValueError, access._has_access_descriptor, u, d, 'not_load_or_staff')
|
||||
|
||||
def test__has_access_course_desc_can_enroll(self):
|
||||
u = Mock()
|
||||
yesterday = time.gmtime(time.time() - 86400)
|
||||
tomorrow = time.gmtime(time.time() + 86400)
|
||||
c = Mock(enrollment_start=yesterday, enrollment_end=tomorrow)
|
||||
c.metadata.get = 'is_public'
|
||||
|
||||
# User can enroll if it is between the start and end dates
|
||||
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
|
||||
|
||||
# User can enroll if authenticated and specifically allowed for that course
|
||||
# even outside the open enrollment period
|
||||
u = Mock(email='test@edx.org', is_staff=False)
|
||||
u.is_authenticated.return_value = True
|
||||
|
||||
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/2012_Fall')
|
||||
c.metadata.get = 'is_public'
|
||||
|
||||
allowed = CourseEnrollmentAllowedFactory(email=u.email, course_id=c.id)
|
||||
|
||||
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
|
||||
|
||||
# Staff can always enroll even outside the open enrollment period
|
||||
u = Mock(email='test@edx.org', is_staff=True)
|
||||
u.is_authenticated.return_value = True
|
||||
|
||||
c = Mock(enrollment_start=tomorrow, enrollment_end=tomorrow, id='edX/test/Whenever')
|
||||
c.metadata.get = 'is_public'
|
||||
self.assertTrue(access._has_access_course_desc(u, c, 'enroll'))
|
||||
|
||||
# TODO:
|
||||
# Non-staff cannot enroll outside the open enrollment period if not specifically allowed
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, ConnectionError, HTTPError
|
||||
import sys
|
||||
from grading_service import GradingService
|
||||
from grading_service import GradingServiceError
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, Http404
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class ControllerQueryService(GradingService):
|
||||
"""
|
||||
Interface to staff grading backend.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
super(ControllerQueryService, self).__init__(config)
|
||||
self.check_eta_url = self.url + '/get_submission_eta/'
|
||||
self.is_unique_url = self.url + '/is_name_unique/'
|
||||
self.combined_notifications_url = self.url + '/combined_notifications/'
|
||||
self.grading_status_list_url = self.url + '/get_grading_status_list/'
|
||||
|
||||
def check_if_name_is_unique(self, location, problem_id, course_id):
|
||||
params = {
|
||||
'course_id': course_id,
|
||||
'location' : location,
|
||||
'problem_id' : problem_id
|
||||
}
|
||||
response = self.get(self.is_unique_url, params)
|
||||
return response
|
||||
|
||||
def check_for_eta(self, location):
|
||||
params = {
|
||||
'location' : location,
|
||||
}
|
||||
response = self.get(self.check_eta_url, params)
|
||||
return response
|
||||
|
||||
def check_combined_notifications(self, course_id, student_id, user_is_staff, last_time_viewed):
|
||||
params= {
|
||||
'student_id' : student_id,
|
||||
'course_id' : course_id,
|
||||
'user_is_staff' : user_is_staff,
|
||||
'last_time_viewed' : last_time_viewed,
|
||||
}
|
||||
log.debug(self.combined_notifications_url)
|
||||
response = self.get(self.combined_notifications_url,params)
|
||||
return response
|
||||
|
||||
def get_grading_status_list(self, course_id, student_id):
|
||||
params = {
|
||||
'student_id' : student_id,
|
||||
'course_id' : course_id,
|
||||
}
|
||||
|
||||
response = self.get(self.grading_status_list_url, params)
|
||||
return response
|
||||
@@ -116,7 +116,7 @@ class GradingService(object):
|
||||
if 'rubric' in response_json:
|
||||
rubric = response_json['rubric']
|
||||
rubric_renderer = CombinedOpenEndedRubric(self.system, False)
|
||||
rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
success, rubric_html = rubric_renderer.render_rubric(rubric)
|
||||
response_json['rubric'] = rubric_html
|
||||
return response_json
|
||||
# if we can't parse the rubric into HTML,
|
||||
|
||||
158
lms/djangoapps/open_ended_grading/open_ended_notifications.py
Normal file
158
lms/djangoapps/open_ended_grading/open_ended_notifications.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from django.conf import settings
|
||||
from staff_grading_service import StaffGradingService
|
||||
from peer_grading_service import PeerGradingService
|
||||
from open_ended_grading.controller_query_service import ControllerQueryService
|
||||
import json
|
||||
from student.models import unique_id_for_user
|
||||
import open_ended_util
|
||||
from courseware.models import StudentModule
|
||||
import logging
|
||||
from courseware.access import has_access
|
||||
from util.cache import cache
|
||||
import datetime
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
NOTIFICATION_CACHE_TIME = 300
|
||||
KEY_PREFIX = "open_ended_"
|
||||
|
||||
NOTIFICATION_TYPES = (
|
||||
('student_needs_to_peer_grade', 'peer_grading', 'Peer Grading'),
|
||||
('staff_needs_to_grade', 'staff_grading', 'Staff Grading'),
|
||||
('new_student_grading_to_view', 'open_ended_problems', 'Problems you have submitted')
|
||||
)
|
||||
|
||||
def staff_grading_notifications(course, user):
|
||||
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
img_path= ""
|
||||
course_id = course.id
|
||||
student_id = unique_id_for_user(user)
|
||||
notification_type = "staff"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
try:
|
||||
notifications = json.loads(staff_gs.get_notifications(course_id))
|
||||
if notifications['success']:
|
||||
if notifications['staff_needs_to_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.info("Problem with getting notifications from staff grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def peer_grading_notifications(course, user):
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
pending_grading=False
|
||||
img_path= ""
|
||||
course_id = course.id
|
||||
student_id = unique_id_for_user(user)
|
||||
notification_type = "peer"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
try:
|
||||
notifications = json.loads(peer_gs.get_notifications(course_id,student_id))
|
||||
if notifications['success']:
|
||||
if notifications['student_needs_to_peer_grade']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.info("Problem with getting notifications from peer grading service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def combined_notifications(course, user):
|
||||
controller_url = open_ended_util.get_controller_url()
|
||||
controller_qs = ControllerQueryService(controller_url)
|
||||
student_id = unique_id_for_user(user)
|
||||
user_is_staff = has_access(user, course, 'staff')
|
||||
course_id = course.id
|
||||
notification_type = "combined"
|
||||
|
||||
success, notification_dict = get_value_from_cache(student_id, course_id, notification_type)
|
||||
if success:
|
||||
return notification_dict
|
||||
|
||||
min_time_to_query = user.last_login
|
||||
last_module_seen = StudentModule.objects.filter(student=user, course_id = course_id, modified__gt=min_time_to_query).values('modified').order_by('-modified')
|
||||
last_module_seen_count = last_module_seen.count()
|
||||
|
||||
if last_module_seen_count>0:
|
||||
last_time_viewed = last_module_seen[0]['modified'] - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60))
|
||||
else:
|
||||
last_time_viewed = user.last_login
|
||||
|
||||
pending_grading= False
|
||||
|
||||
img_path= ""
|
||||
try:
|
||||
controller_response = controller_qs.check_combined_notifications(course.id,student_id, user_is_staff, last_time_viewed)
|
||||
log.debug(controller_response)
|
||||
notifications = json.loads(controller_response)
|
||||
if notifications['success']:
|
||||
if notifications['overall_need_to_check']:
|
||||
pending_grading=True
|
||||
except:
|
||||
#Non catastrophic error, so no real action
|
||||
notifications = {}
|
||||
log.exception("Problem with getting notifications from controller query service.")
|
||||
|
||||
if pending_grading:
|
||||
img_path = "/static/images/slider-handle.png"
|
||||
|
||||
notification_dict = {'pending_grading' : pending_grading, 'img_path' : img_path, 'response' : notifications}
|
||||
|
||||
set_value_in_cache(student_id, course_id, notification_type, notification_dict)
|
||||
|
||||
return notification_dict
|
||||
|
||||
def get_value_from_cache(student_id, course_id, notification_type):
|
||||
key_name = create_key_name(student_id, course_id, notification_type)
|
||||
success, value = _get_value_from_cache(key_name)
|
||||
return success, value
|
||||
|
||||
def set_value_in_cache(student_id, course_id, notification_type, value):
|
||||
key_name = create_key_name(student_id, course_id, notification_type)
|
||||
_set_value_in_cache(key_name, value)
|
||||
|
||||
def create_key_name(student_id, course_id, notification_type):
|
||||
key_name = "{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, student=student_id)
|
||||
return key_name
|
||||
|
||||
def _get_value_from_cache(key_name):
|
||||
value = cache.get(key_name)
|
||||
success = False
|
||||
if value is None:
|
||||
return success , value
|
||||
try:
|
||||
value = json.loads(value)
|
||||
success = True
|
||||
except:
|
||||
pass
|
||||
return success , value
|
||||
|
||||
def _set_value_in_cache(key_name, value):
|
||||
cache.set(key_name, json.dumps(value), NOTIFICATION_CACHE_TIME)
|
||||
12
lms/djangoapps/open_ended_grading/open_ended_util.py
Normal file
12
lms/djangoapps/open_ended_grading/open_ended_util.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
def get_controller_url():
|
||||
peer_grading_url = settings.PEER_GRADING_INTERFACE['url']
|
||||
split_url = peer_grading_url.split("/")
|
||||
controller_url = "http://" + split_url[2] + "/grading_controller"
|
||||
controller_settings=settings.PEER_GRADING_INTERFACE.copy()
|
||||
controller_settings['url'] = controller_url
|
||||
return controller_settings
|
||||
@@ -31,6 +31,15 @@ This is a mock peer grading service that can be used for unit tests
|
||||
without making actual service calls to the grading controller
|
||||
"""
|
||||
class MockPeerGradingService(object):
|
||||
# TODO: get this rubric parsed and working
|
||||
rubric = """<rubric>
|
||||
<category>
|
||||
<description>Description</description>
|
||||
<option>First option</option>
|
||||
<option>Second option</option>
|
||||
</category>
|
||||
</rubric>"""
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'submission_id':1,
|
||||
@@ -41,7 +50,7 @@ class MockPeerGradingService(object):
|
||||
'max_score': 4})
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key):
|
||||
score, feedback, submission_key, rubric_scores):
|
||||
return json.dumps({'success': True})
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
@@ -57,16 +66,16 @@ class MockPeerGradingService(object):
|
||||
'max_score': 4})
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score, feedback):
|
||||
return {'success': True, 'actual_score': 2}
|
||||
calibration_essay_id, submission_key, score, feedback, rubric_scores):
|
||||
return json.dumps({'success': True, 'actual_score': 2})
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
return json.dumps({'success': True,
|
||||
'problem_list': [
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1',
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5}),
|
||||
'problem_name': "Problem 1", 'num_graded': 3, 'num_pending': 5, 'num_required': 7}),
|
||||
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo2',
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5})
|
||||
'problem_name': "Problem 2", 'num_graded': 1, 'num_pending': 5, 'num_required': 8})
|
||||
]})
|
||||
|
||||
class PeerGradingService(GradingService):
|
||||
|
||||
@@ -6,6 +6,7 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open
|
||||
|
||||
from django.test import TestCase
|
||||
from open_ended_grading import staff_grading_service
|
||||
from open_ended_grading import peer_grading_service
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
@@ -17,9 +18,10 @@ from nose import SkipTest
|
||||
from mock import patch, Mock
|
||||
import json
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
from override_settings import override_settings
|
||||
|
||||
_mock_service = staff_grading_service.MockStaffGradingService()
|
||||
|
||||
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
|
||||
class TestStaffGradingService(ct.PageLoader):
|
||||
@@ -111,3 +113,144 @@ class TestStaffGradingService(ct.PageLoader):
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'], str(d))
|
||||
self.assertIsNotNone(d['problem_list'])
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
|
||||
class TestPeerGradingService(ct.PageLoader):
|
||||
'''
|
||||
Check that staff grading service proxy works. Basically just checking the
|
||||
access control and error handling logic -- all the actual work is on the
|
||||
backend.
|
||||
'''
|
||||
def setUp(self):
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
|
||||
self.student = 'view@test.com'
|
||||
self.instructor = 'view2@test.com'
|
||||
self.password = 'foo'
|
||||
self.location = 'TestLocation'
|
||||
self.create_account('u1', self.student, self.password)
|
||||
self.create_account('u2', self.instructor, self.password)
|
||||
self.activate_user(self.student)
|
||||
self.activate_user(self.instructor)
|
||||
|
||||
self.course_id = "edX/toy/2012_Fall"
|
||||
self.toy = modulestore().get_course(self.course_id)
|
||||
|
||||
self.mock_service = peer_grading_service.peer_grading_service()
|
||||
|
||||
self.logout()
|
||||
|
||||
def test_get_next_submission_success(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_get_next_submission', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
self.assertIsNotNone(d['submission_key'])
|
||||
self.assertIsNotNone(d['max_score'])
|
||||
|
||||
def test_get_next_submission_missing_location(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_get_next_submission', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertFalse(d['success'])
|
||||
self.assertEqual(d['error'], "Missing required keys: location")
|
||||
|
||||
def test_save_grade_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_grade', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location,
|
||||
'submission_id': '1',
|
||||
'submission_key': 'fake key',
|
||||
'score': '2',
|
||||
'feedback': 'This is feedback',
|
||||
'rubric_scores[]': [1, 2]}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'])
|
||||
|
||||
def test_save_grade_missing_keys(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_grade', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertFalse(d['success'])
|
||||
self.assertTrue(d['error'].find('Missing required keys:') > -1)
|
||||
|
||||
def test_is_calibrated_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_is_student_calibrated', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('calibrated' in d)
|
||||
|
||||
def test_is_calibrated_failure(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_is_student_calibrated', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertFalse(d['success'])
|
||||
self.assertFalse('calibrated' in d)
|
||||
|
||||
def test_show_calibration_essay_success(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_show_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertIsNotNone(d['submission_id'])
|
||||
self.assertIsNotNone(d['prompt'])
|
||||
self.assertIsNotNone(d['submission_key'])
|
||||
self.assertIsNotNone(d['max_score'])
|
||||
|
||||
def test_show_calibration_essay_missing_key(self):
|
||||
self.login(self.student, self.password)
|
||||
|
||||
url = reverse('peer_grading_show_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
|
||||
self.assertFalse(d['success'])
|
||||
self.assertEqual(d['error'], "Missing required keys: location")
|
||||
|
||||
def test_save_calibration_essay_success(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {'location': self.location,
|
||||
'submission_id': '1',
|
||||
'submission_key': 'fake key',
|
||||
'score': '2',
|
||||
'feedback': 'This is feedback',
|
||||
'rubric_scores[]': [1, 2]}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertTrue(d['success'])
|
||||
self.assertTrue('actual_score' in d)
|
||||
|
||||
def test_save_calibration_essay_missing_keys(self):
|
||||
self.login(self.student, self.password)
|
||||
url = reverse('peer_grading_save_calibration_essay', kwargs={'course_id': self.course_id})
|
||||
data = {}
|
||||
r = self.check_for_post_code(200, url, data)
|
||||
d = json.loads(r.content)
|
||||
self.assertFalse(d['success'])
|
||||
self.assertTrue(d['error'].find('Missing required keys:') > -1)
|
||||
self.assertFalse('actual_score' in d)
|
||||
|
||||
|
||||
@@ -13,10 +13,17 @@ from courseware.courses import get_course_with_access
|
||||
|
||||
from peer_grading_service import PeerGradingService
|
||||
from peer_grading_service import MockPeerGradingService
|
||||
from controller_query_service import ControllerQueryService
|
||||
from grading_service import GradingServiceError
|
||||
import json
|
||||
from .staff_grading import StaffGrading
|
||||
from student.models import unique_id_for_user
|
||||
|
||||
import open_ended_util
|
||||
import open_ended_notifications
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import search
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING:
|
||||
else:
|
||||
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
|
||||
|
||||
controller_url = open_ended_util.get_controller_url()
|
||||
controller_qs = ControllerQueryService(controller_url)
|
||||
|
||||
"""
|
||||
Reverses the URL from the name and the course id, and then adds a trailing slash if
|
||||
it does not exist yet
|
||||
|
||||
"""
|
||||
def _reverse_with_slash(url_name, course_id):
|
||||
ajax_url = reverse(url_name, kwargs={'course_id': course_id})
|
||||
ajax_url = _reverse_without_slash(url_name, course_id)
|
||||
if not ajax_url.endswith('/'):
|
||||
ajax_url += '/'
|
||||
return ajax_url
|
||||
|
||||
def _reverse_without_slash(url_name, course_id):
|
||||
ajax_url = reverse(url_name, kwargs={'course_id': course_id})
|
||||
return ajax_url
|
||||
|
||||
DESCRIPTION_DICT = {
|
||||
'Peer Grading': "View all problems that require peer assessment in this particular course.",
|
||||
'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.",
|
||||
'Problems you have submitted': "View open ended problems that you have previously submitted for grading."
|
||||
}
|
||||
ALERT_DICT = {
|
||||
'Peer Grading': "New submissions to grade",
|
||||
'Staff Grading': "New submissions to grade",
|
||||
'Problems you have submitted': "New grades have been returned"
|
||||
}
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def staff_grading(request, course_id):
|
||||
"""
|
||||
@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id):
|
||||
'ajax_url': ajax_url,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def student_problem_list(request, course_id):
|
||||
'''
|
||||
Show a student problem list
|
||||
'''
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
student_id = unique_id_for_user(request.user)
|
||||
|
||||
# call problem list service
|
||||
success = False
|
||||
error_text = ""
|
||||
problem_list = []
|
||||
base_course_url = reverse('courses')
|
||||
|
||||
try:
|
||||
problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
|
||||
problem_list_dict = json.loads(problem_list_json)
|
||||
success = problem_list_dict['success']
|
||||
if 'error' in problem_list_dict:
|
||||
error_text = problem_list_dict['error']
|
||||
|
||||
problem_list = problem_list_dict['problem_list']
|
||||
|
||||
for i in xrange(0,len(problem_list)):
|
||||
problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
|
||||
problem_url = base_course_url + "/"
|
||||
for z in xrange(0,len(problem_url_parts)):
|
||||
part = problem_url_parts[z]
|
||||
if part is not None:
|
||||
if z==1:
|
||||
problem_url += "courseware/"
|
||||
problem_url += part + "/"
|
||||
|
||||
problem_list[i].update({'actual_url' : problem_url})
|
||||
|
||||
except GradingServiceError:
|
||||
error_text = "Error occured while contacting the grading service"
|
||||
success = False
|
||||
# catch error if if the json loads fails
|
||||
except ValueError:
|
||||
error_text = "Could not get problem list"
|
||||
success = False
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_problems', course_id)
|
||||
|
||||
return render_to_response('open_ended_problems/open_ended_problems.html', {
|
||||
'course': course,
|
||||
'course_id': course_id,
|
||||
'ajax_url': ajax_url,
|
||||
'success': success,
|
||||
'problem_list': problem_list,
|
||||
'error_text': error_text,
|
||||
# Checked above
|
||||
'staff_access': False, })
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def combined_notifications(request, course_id):
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
user = request.user
|
||||
notifications = open_ended_notifications.combined_notifications(course, user)
|
||||
log.debug(notifications)
|
||||
response = notifications['response']
|
||||
notification_tuples=open_ended_notifications.NOTIFICATION_TYPES
|
||||
|
||||
notification_list = []
|
||||
for response_num in xrange(0,len(notification_tuples)):
|
||||
tag=notification_tuples[response_num][0]
|
||||
if tag in response:
|
||||
url_name = notification_tuples[response_num][1]
|
||||
human_name = notification_tuples[response_num][2]
|
||||
url = _reverse_without_slash(url_name, course_id)
|
||||
has_img = response[tag]
|
||||
|
||||
# check to make sure we have descriptions and alert messages
|
||||
if human_name in DESCRIPTION_DICT:
|
||||
description = DESCRIPTION_DICT[human_name]
|
||||
else:
|
||||
description = ""
|
||||
|
||||
if human_name in ALERT_DICT:
|
||||
alert_message = ALERT_DICT[human_name]
|
||||
else:
|
||||
alert_message = ""
|
||||
|
||||
notification_item = {
|
||||
'url' : url,
|
||||
'name' : human_name,
|
||||
'alert' : has_img,
|
||||
'description': description,
|
||||
'alert_message': alert_message
|
||||
}
|
||||
notification_list.append(notification_item)
|
||||
|
||||
ajax_url = _reverse_with_slash('open_ended_notifications', course_id)
|
||||
combined_dict = {
|
||||
'error_text' : "",
|
||||
'notification_list' : notification_list,
|
||||
'course' : course,
|
||||
'success' : True,
|
||||
'ajax_url' : ajax_url,
|
||||
}
|
||||
|
||||
return render_to_response('open_ended_problems/combined_notifications.html',
|
||||
combined_dict
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,8 +10,23 @@ import json
|
||||
|
||||
from .common import *
|
||||
from logsettings import get_logger_config
|
||||
import os
|
||||
|
||||
############################### ALWAYS THE SAME ################################
|
||||
# specified as an environment variable. Typically this is set
|
||||
# in the service's upstart script and corresponds exactly to the service name.
|
||||
# Service variants apply config differences via env and auth JSON files,
|
||||
# the names of which correspond to the variant.
|
||||
SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None)
|
||||
|
||||
# when not variant is specified we attempt to load an unvaried
|
||||
# config set.
|
||||
CONFIG_PREFIX = ""
|
||||
|
||||
if SERVICE_VARIANT:
|
||||
CONFIG_PREFIX = SERVICE_VARIANT + "."
|
||||
|
||||
|
||||
################### ALWAYS THE SAME ################################
|
||||
DEBUG = False
|
||||
TEMPLATE_DEBUG = False
|
||||
|
||||
@@ -25,14 +40,15 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
|
||||
# IMPORTANT: With this enabled, the server must always be behind a proxy that
|
||||
# strips the header HTTP_X_FORWARDED_PROTO from client requests. Otherwise,
|
||||
# a user can fool our server into thinking it was an https connection.
|
||||
# See https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
|
||||
# See
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
|
||||
# for other warnings.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
########################### NON-SECURE ENV CONFIG ##############################
|
||||
################# NON-SECURE ENV CONFIG ##############################
|
||||
# Things like server locations, ports, etc.
|
||||
|
||||
with open(ENV_ROOT / "env.json") as env_file:
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
SITE_NAME = ENV_TOKENS['SITE_NAME']
|
||||
@@ -55,18 +71,19 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
logging_env=ENV_TOKENS['LOGGING_ENV'],
|
||||
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
|
||||
local_loglevel=local_loglevel,
|
||||
debug=False)
|
||||
debug=False,
|
||||
service_variant=SERVICE_VARIANT)
|
||||
|
||||
COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {})
|
||||
SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {})
|
||||
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
|
||||
COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL",'')
|
||||
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY",'')
|
||||
COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '')
|
||||
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
|
||||
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
|
||||
|
||||
############################## SECURE AUTH ITEMS ###############################
|
||||
############################## SECURE AUTH ITEMS ###############
|
||||
# Secret things: passwords, access keys, etc.
|
||||
with open(ENV_ROOT / "auth.json") as auth_file:
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
AUTH_TOKENS = json.load(auth_file)
|
||||
|
||||
SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
|
||||
@@ -84,8 +101,10 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
|
||||
MODULESTORE = AUTH_TOKENS.get('MODULESTORE', MODULESTORE)
|
||||
CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
|
||||
|
||||
STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE', STAFF_GRADING_INTERFACE)
|
||||
PEER_GRADING_INTERFACE = AUTH_TOKENS.get('PEER_GRADING_INTERFACE', PEER_GRADING_INTERFACE)
|
||||
STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE',
|
||||
STAFF_GRADING_INTERFACE)
|
||||
PEER_GRADING_INTERFACE = AUTH_TOKENS.get('PEER_GRADING_INTERFACE',
|
||||
PEER_GRADING_INTERFACE)
|
||||
|
||||
PEARSON_TEST_USER = "pearsontest"
|
||||
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
|
||||
|
||||
76
lms/static/coffee/fixtures/staff_grading.html
Normal file
76
lms/static/coffee/fixtures/staff_grading.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<section class="container">
|
||||
|
||||
<div class="staff-grading" data-ajax_url="${ajax_url}">
|
||||
<h1>Staff grading</h1>
|
||||
<div class="breadcrumbs">
|
||||
</div>
|
||||
<div class="error-container">
|
||||
</div>
|
||||
<div class="message-container">
|
||||
</div>
|
||||
|
||||
<! -- Problem List View -->
|
||||
<section class="problem-list-container">
|
||||
<h2>Instructions</h2>
|
||||
<div class="instructions">
|
||||
<p>This is the list of problems that current need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.</p>
|
||||
</div>
|
||||
|
||||
<h2>Problem List</h2>
|
||||
<table class="problem-list">
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Grading View -->
|
||||
|
||||
<section class="prompt-wrapper">
|
||||
<h2 class="prompt-name"></h2>
|
||||
<div class="meta-info-wrapper">
|
||||
<h3>Problem Information</h3>
|
||||
<div class="problem-meta-info-container">
|
||||
</div>
|
||||
<h3>Maching Learning Information</h3>
|
||||
<div class="ml-error-info-container">
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompt-information-container">
|
||||
<h3>Question</h3>
|
||||
<div class="prompt-container">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div class="action-button">
|
||||
<input type=button value="Submit" class="action-button" name="show" />
|
||||
</div>
|
||||
|
||||
<section class="grading-wrapper">
|
||||
<h2>Grading</h2>
|
||||
|
||||
<div class="grading-container">
|
||||
<div class="submission-wrapper">
|
||||
<h3>Student Submission</h3>
|
||||
<div class="submission-container">
|
||||
</div>
|
||||
</div>
|
||||
<div class="evaluation">
|
||||
<p class="score-selection-container">
|
||||
</p>
|
||||
<p class="grade-selection-container">
|
||||
</p>
|
||||
<textarea name="feedback" placeholder="Feedback for student (optional)"
|
||||
class="feedback-area" cols="70" ></textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="submission">
|
||||
<input type="button" value="Submit" class="submit-button" name="show"/>
|
||||
<input type="button" value="Skip" class="skip-button" name="skip"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -10,19 +10,6 @@ describe 'Courseware', ->
|
||||
Courseware.start()
|
||||
expect(Logger.bind).toHaveBeenCalled()
|
||||
|
||||
describe 'bind', ->
|
||||
beforeEach ->
|
||||
@courseware = new Courseware
|
||||
setFixtures """
|
||||
<div class="course-content">
|
||||
<div class="sequence"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
it 'binds the content change event', ->
|
||||
@courseware.bind()
|
||||
expect($('.course-content .sequence')).toHandleWith 'contentChanged', @courseware.render
|
||||
|
||||
describe 'render', ->
|
||||
beforeEach ->
|
||||
jasmine.stubRequests()
|
||||
@@ -30,6 +17,7 @@ describe 'Courseware', ->
|
||||
spyOn(window, 'Histogram')
|
||||
spyOn(window, 'Problem')
|
||||
spyOn(window, 'Video')
|
||||
spyOn(XModule, 'loadModules')
|
||||
setFixtures """
|
||||
<div class="course-content">
|
||||
<div id="video_1" class="video" data-streams="1.0:abc1234"></div>
|
||||
@@ -41,12 +29,8 @@ describe 'Courseware', ->
|
||||
"""
|
||||
@courseware.render()
|
||||
|
||||
it 'detect the video elements and convert them', ->
|
||||
expect(window.Video).toHaveBeenCalledWith('1', '1.0:abc1234')
|
||||
expect(window.Video).toHaveBeenCalledWith('2', '1.0:def5678')
|
||||
|
||||
it 'detect the problem element and convert it', ->
|
||||
expect(window.Problem).toHaveBeenCalledWith(3, 'problem_3', '/example/url/')
|
||||
it 'ensure that the XModules have been loaded', ->
|
||||
expect(XModule.loadModules).toHaveBeenCalled()
|
||||
|
||||
it 'detect the histrogram element and convert it', ->
|
||||
expect(window.Histogram).toHaveBeenCalledWith('3', [[0, 1]])
|
||||
|
||||
@@ -16,6 +16,7 @@ describe 'Navigation', ->
|
||||
active: 1
|
||||
header: 'h3'
|
||||
autoHeight: false
|
||||
heightStyle: 'content'
|
||||
|
||||
describe 'when there is no active section', ->
|
||||
beforeEach ->
|
||||
@@ -23,11 +24,12 @@ describe 'Navigation', ->
|
||||
$('#accordion').append('<ul><li></li></ul><ul><li></li></ul>')
|
||||
new Navigation
|
||||
|
||||
it 'activate the accordian with section 1 as active', ->
|
||||
it 'activate the accordian with no section as active', ->
|
||||
expect($('#accordion').accordion).toHaveBeenCalledWith
|
||||
active: 1
|
||||
active: 0
|
||||
header: 'h3'
|
||||
autoHeight: false
|
||||
heightStyle: 'content'
|
||||
|
||||
it 'binds the accordionchange event', ->
|
||||
Navigation.bind()
|
||||
|
||||
11
lms/static/coffee/spec/staff_grading_spec.coffee
Normal file
11
lms/static/coffee/spec/staff_grading_spec.coffee
Normal file
@@ -0,0 +1,11 @@
|
||||
describe 'StaffGrading', ->
|
||||
beforeEach ->
|
||||
spyOn Logger, 'log'
|
||||
@mockBackend = new StaffGradingBackend('url', true)
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
@staff_grading = new StaffGrading(@mockBackend)
|
||||
|
||||
it 'we are originally in the list view', ->
|
||||
expect(@staff_grading.list_view).toBe(true)
|
||||
@@ -9,9 +9,13 @@ state_graded = "graded"
|
||||
state_no_data = "no_data"
|
||||
state_error = "error"
|
||||
|
||||
class StaffGradingBackend
|
||||
class @StaffGradingBackend
|
||||
constructor: (ajax_url, mock_backend) ->
|
||||
@ajax_url = ajax_url
|
||||
# prevent this from trying to make requests when we don't have
|
||||
# a proper url
|
||||
if !ajax_url
|
||||
mock_backend = true
|
||||
@mock_backend = mock_backend
|
||||
if @mock_backend
|
||||
@mock_cnt = 0
|
||||
@@ -142,7 +146,7 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t
|
||||
.error => callback({success: false, error: "Error occured while performing this operation"})
|
||||
|
||||
|
||||
class StaffGrading
|
||||
class @StaffGrading
|
||||
constructor: (backend) ->
|
||||
@backend = backend
|
||||
|
||||
|
||||
BIN
lms/static/images/press/releases/eric-lander-lab.jpg
Normal file
BIN
lms/static/images/press/releases/eric-lander-lab.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 552 KiB |
BIN
lms/static/images/press/releases/eric-lander-lab_x300.jpg
Normal file
BIN
lms/static/images/press/releases/eric-lander-lab_x300.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
lms/static/images/press/releases/eric-lander_240x180.jpg
Normal file
BIN
lms/static/images/press/releases/eric-lander_240x180.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -44,6 +44,8 @@
|
||||
@import "course/gradebook";
|
||||
@import "course/tabs";
|
||||
@import "course/staff_grading";
|
||||
@import "course/open_ended_grading";
|
||||
|
||||
|
||||
// instructor
|
||||
@import "course/instructor/instructor";
|
||||
|
||||
@@ -65,6 +65,11 @@ div.info-wrapper {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
> ol {
|
||||
list-style: decimal outside none;
|
||||
padding: 0 0 0 1em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: lh(.5);
|
||||
}
|
||||
|
||||
65
lms/static/sass/course/_open_ended_grading.scss
Normal file
65
lms/static/sass/course/_open_ended_grading.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
.open-ended-problems,
|
||||
.combined-notifications
|
||||
{
|
||||
padding: 40px;
|
||||
.problem-list
|
||||
{
|
||||
table-layout: auto;
|
||||
margin-top: 10px;
|
||||
width:70%;
|
||||
td, th
|
||||
{
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
.notification-container
|
||||
{
|
||||
margin: 30px 0px;
|
||||
}
|
||||
|
||||
.notification
|
||||
{
|
||||
margin: 10px;
|
||||
width: 30%;
|
||||
@include inline-block;
|
||||
vertical-align: top;
|
||||
.notification-link
|
||||
{
|
||||
display:block;
|
||||
height: 9em;
|
||||
padding: 10px;
|
||||
border: 1px solid black;
|
||||
text-align: center;
|
||||
p
|
||||
{
|
||||
font-size: .9em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.notification-title
|
||||
{
|
||||
text-transform: uppercase;
|
||||
background: $blue;
|
||||
color: white;
|
||||
padding: 5px 0px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.notification-link:hover
|
||||
{
|
||||
background-color: #eee;
|
||||
}
|
||||
.notification-description
|
||||
{
|
||||
padding-top:5%;
|
||||
}
|
||||
.alert-message
|
||||
{
|
||||
img
|
||||
{
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,7 +13,7 @@ div.syllabus {
|
||||
}
|
||||
|
||||
table {
|
||||
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
|
||||
margin: 10px 0;
|
||||
@@ -25,18 +25,19 @@ div.syllabus {
|
||||
|
||||
tr.first {
|
||||
td {
|
||||
padding-top: 15px;
|
||||
padding-top: 15px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
|
||||
border: none !important;
|
||||
padding: 5px 10px !important;
|
||||
vertical-align: middle;
|
||||
|
||||
padding: 5px 10px;
|
||||
font-size: 1em !important;
|
||||
line-height: auto;
|
||||
|
||||
&.day, &.due, &.slides, &.assignment {
|
||||
white-space: nowrap;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
&.no_class {
|
||||
@@ -48,16 +49,12 @@ div.syllabus {
|
||||
}
|
||||
|
||||
&.week_separator {
|
||||
padding: 0px;
|
||||
padding: 0px !important;
|
||||
|
||||
hr {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="${ajax_url}" data-allow_reset="${allow_reset}" data-state="${state}" data-task-count="${task_count}" data-task-number="${task_number}">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="${ajax_url}" data-allow_reset="${allow_reset}" data-state="${state}" data-task-count="${task_count}" data-task-number="${task_number}" data-accept-file-upload = "${accept_file_upload}">
|
||||
|
||||
<h2>${display_name}</h2>
|
||||
<div class="status-container">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="statusitem" data-status-number="${i}">
|
||||
%endif
|
||||
|
||||
Step ${status['task_number']} (${status['human_state']}) : ${status['score']} / ${status['max_score']}
|
||||
${status['task_number']}. ${status['human_task']} (${status['human_state']}) : ${status['score']} / ${status['max_score']}
|
||||
% if status['state'] == 'initial':
|
||||
<span class="unanswered" id="status"></span>
|
||||
% elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct':
|
||||
|
||||
@@ -6,7 +6,16 @@
|
||||
<link type="text/html" rel="alternate" href="http://blog.edx.org/"/>
|
||||
##<link type="application/atom+xml" rel="self" href="https://github.com/blog.atom"/>
|
||||
<title>EdX Blog</title>
|
||||
<updated>2013-01-21T14:00:12-07:00</updated>
|
||||
<updated>2013-01-30T14:00:12-07:00</updated>
|
||||
<entry>
|
||||
<id>tag:www.edx.org,2012:Post/13</id>
|
||||
<published>2013-01-30T10:00:00-07:00</published>
|
||||
<updated>2013-01-30T10:00:00-07:00</updated>
|
||||
<link type="text/html" rel="alternate" href="${reverse('press/eric-lander-secret-of-life')}"/>
|
||||
<title>New biology course from human genome pioneer Eric Lander</title>
|
||||
<content type="html"><img src="${static.url('images/press/releases/eric-lander_240x180.jpg')}" />
|
||||
<p></p></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>tag:www.edx.org,2012:Post/12</id>
|
||||
<published>2013-01-29T10:00:00-07:00</published>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="prompt">
|
||||
${prompt|n}
|
||||
</div>
|
||||
<h4>Answer</h4>
|
||||
<textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea>
|
||||
|
||||
<div class="message-wrapper"></div>
|
||||
@@ -22,6 +23,8 @@
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<div class="file-upload"></div>
|
||||
|
||||
<input type="button" value="Submit" class="submit-button" name="show"/>
|
||||
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Combined Notifications</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='open_ended'" />
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="combined-notifications" data-ajax_url="${ajax_url}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Open Ended Console</h1>
|
||||
<h2>Instructions</h2>
|
||||
<p>Here are items that could potentially need your attention.</p>
|
||||
% if success:
|
||||
% if len(notification_list) == 0:
|
||||
<div class="message-container">
|
||||
No items require attention at the moment.
|
||||
</div>
|
||||
%else:
|
||||
<div class="notification-container">
|
||||
%for notification in notification_list:
|
||||
% if notification['alert']:
|
||||
<div class="notification alert">
|
||||
% else:
|
||||
<div class="notification">
|
||||
% endif
|
||||
<a href="${notification['url']}" class="notification-link">
|
||||
<div class="notification-title">${notification['name']}</div>
|
||||
%if notification['alert']:
|
||||
<p class="alert-message"><img src="/static/images/white-error-icon.png" /> ${notification['alert_message']}</p>
|
||||
%endif
|
||||
<div class="notification-description">
|
||||
<p>${notification['description']}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
%endfor
|
||||
</div>
|
||||
%endif
|
||||
%endif
|
||||
</div>
|
||||
</section>
|
||||
49
lms/templates/open_ended_problems/open_ended_problems.html
Normal file
49
lms/templates/open_ended_problems/open_ended_problems.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<%inherit file="/main.html" />
|
||||
<%block name="bodyclass">${course.css_class}</%block>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='course'/>
|
||||
</%block>
|
||||
|
||||
<%block name="title"><title>${course.number} Open Ended Problems</title></%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='open_ended_problems'" />
|
||||
|
||||
|
||||
<section class="container">
|
||||
<div class="open-ended-problems" data-ajax_url="${ajax_url}">
|
||||
<div class="error-container">${error_text}</div>
|
||||
<h1>Open Ended Problems</h1>
|
||||
<h2>Instructions</h2>
|
||||
<p>Here are a list of open ended problems for this course.</p>
|
||||
% if success:
|
||||
% if len(problem_list) == 0:
|
||||
<div class="message-container">
|
||||
You have not attempted any open ended problems yet.
|
||||
</div>
|
||||
%else:
|
||||
<table class="problem-list">
|
||||
<tr>
|
||||
<th>Problem Name</th>
|
||||
<th>Status</th>
|
||||
<th>Type of Grading</th>
|
||||
</tr>
|
||||
%for problem in problem_list:
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${problem['actual_url']}">${problem['problem_name']}</a>
|
||||
</td>
|
||||
<td>
|
||||
${problem['state']}
|
||||
</td>
|
||||
<td>
|
||||
${problem['grader_type']}
|
||||
</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</table>
|
||||
%endif
|
||||
%endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -5,8 +5,9 @@
|
||||
${prompt}
|
||||
</div>
|
||||
|
||||
<h4>Answer</h4>
|
||||
<div>
|
||||
<textarea name="answer" class="answer short-form-response" cols="70" rows="20">${previous_answer|h}</textarea>
|
||||
<textarea name="answer" class="answer short-form-response" cols="70" rows="20">${previous_answer|n}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="open-ended-action"></div>
|
||||
@@ -16,6 +17,7 @@
|
||||
<div class="hint-wrapper">${initial_hint}</div>
|
||||
|
||||
<div class="message-wrapper">${initial_message}</div>
|
||||
|
||||
|
||||
<div class="file-upload"></div>
|
||||
<input type="button" value="Submit" class="submit-button" name="show"/>
|
||||
</section>
|
||||
|
||||
@@ -39,7 +39,14 @@
|
||||
</article>
|
||||
<article class="response">
|
||||
<h3>Will certificates be awarded?</h3>
|
||||
<p>Yes. Online learners who demonstrate mastery of subjects can earn a certificate of mastery. Certificates will be issued by edX under the name of the underlying "X University" from where the course originated, i.e. HarvardX, <em>MITx</em> or BerkeleyX. For the courses in Fall 2012, those certificates will be free. There is a plan to charge a modest fee for certificates in the future.</p>
|
||||
<p>Yes. Online learners who demonstrate mastery of subjects can earn a certificate
|
||||
of mastery. Certificates will be issued at the discretion of edX and the underlying
|
||||
X University that offered the course under the name of the underlying "X
|
||||
University" from where the course originated, i.e. HarvardX, MITx or BerkeleyX.
|
||||
For the courses in Fall 2012, those certificates will be free. There is a plan to
|
||||
charge a modest fee for certificates in the future. Note: At this time, edX is
|
||||
holding certificates for learners connected with Cuba, Iran, Syria and Sudan
|
||||
pending confirmation that the issuance is in compliance with U.S. embargoes.</p>
|
||||
</article>
|
||||
<article class="response">
|
||||
<h3>What will the scope of the online courses be? How many? Which faculty?</h3>
|
||||
|
||||
@@ -180,8 +180,17 @@
|
||||
|
||||
<article class="response">
|
||||
<h3 class="question">Will I get a certificate for taking an edX course?</h3>
|
||||
<div class="answer" id="certificates_and_credits_faq_answer_0">
|
||||
<p>Online learners who receive a passing grade for a course will receive a certificate of mastery from edX and the underlying X University that offered the course. For example, a certificate of mastery for MITx’s 6.002x Circuits & Electronics will come from edX and MITx.</p>
|
||||
<div class ="answer" id="certificates_and_credits_faq_answer_0">
|
||||
<p>Online learners who receive a passing grade for a course will receive a certificate
|
||||
of mastery at the discretion of edX and the underlying X University that offered
|
||||
the course. For example, a certificate of mastery for MITx’s 6.002x Circuits &
|
||||
Electronics will come from edX and MITx.</p>
|
||||
<p>If you passed the course, your certificate of mastery will be delivered online
|
||||
through edx.org. So be sure to check your email in the weeks following the final
|
||||
grading – you will be able to download and print your certificate. Note: At this
|
||||
time, edX is holding certificates for learners connected with Cuba, Iran, Syria
|
||||
and Sudan pending confirmation that the issuance is in compliance with U.S.
|
||||
embargoes.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="response">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="../../main.html" />
|
||||
|
||||
<%namespace name='static' file='../../static_content.html'/>
|
||||
|
||||
<%block name="title"><title>Human Genome Pioneer Eric Lander to reveal “the secret of life”</title></%block>
|
||||
<div id="fb-root"></div>
|
||||
<script>(function(d, s, id) {
|
||||
var js, fjs = d.getElementsByTagName(s)[0];
|
||||
if (d.getElementById(id)) return;
|
||||
js = d.createElement(s); js.id = id;
|
||||
js.src = "//connect.facebook.net/en_US/all.js#xfbml=1";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}(document, 'script', 'facebook-jssdk'));</script>
|
||||
|
||||
<section class="pressrelease">
|
||||
<section class="container">
|
||||
<h1>Human Genome Pioneer Eric Lander to reveal “the secret of life”</h1>
|
||||
<hr class="horizontal-divider">
|
||||
<article>
|
||||
<h2>Broad Institute Director shares his MIT introductory biology course, covering topics in biochemistry, genetics and genomics, through edX.</h2>
|
||||
|
||||
<figure>
|
||||
<a href="${static.url('images/press/releases/eric-lander-lab.jpg')}"><img src="${static.url('images/press/releases/eric-lander-lab_x300.jpg')}" /></a>
|
||||
<figcaption>
|
||||
<p>Eric Lander, the founding director of the Broad Institute and a professor at MIT and Harvard Medical School.</p>
|
||||
<a href="${static.url('images/press/releases/eric-lander-lab.jpg')}">High Resolution Image</a></p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
|
||||
|
||||
<p><strong>CAMBRIDGE, MA – January 30, 2013 –</strong>
|
||||
In the past 10 years, the ability to decode or “sequence” DNA has grown by a million-fold, a stunning rate of progress that is producing a flood of information about human biology and disease. Because of these advances, the scientific community — and the world as a whole — stands on the verge of a revolution in biology. In the coming decades scientists will be able to understand how cells are “wired” and how that wiring is disrupted in human diseases ranging from diabetes to cancer to schizophrenia. Now, with his free online course, <a href="https://www.edx.org/courses/MITx/7.00x/2013_Spring/about">7.00x Introductory Biology: “The Secret of Life”</a>, genome pioneer Eric Lander, the founding director of the Broad Institute and a professor at MIT and Harvard Medical School, will explain to students around the world the basics of biology – the secret of life, so to speak – so that they can understand today’s revolution in biology.</p>
|
||||
|
||||
<p><a href="https://www.edx.org/">EdX</a>, the not-for-profit online learning initiative founded by Harvard University and the Massachusetts Institute of Technology (MIT), brings the best courses from the best faculty at the best institutions to anyone with an Internet connection. For the past 20 years, legendary teacher Lander has taught Introductory Biology to more than half of all MIT students. He has now adapted his course for online education, creating the newest course on the edX platform. The course, 7.00X, is now open for enrollment, with the first class slated for March 5th. This course will include innovative technology including a 3D molecule viewer and gene explorer tool to transform the learning experience. It is open to all levels and types of learners.</p>
|
||||
|
||||
<p>“Introducing the freshman class of MIT to the basics of biology is exhilarating,” said Lander. “Now, with this edX course, I look forward to teaching people around the world. There are no prerequisites for this course – other than curiosity and an interest in understanding some of the greatest scientific challenges of our time.”</p>
|
||||
|
||||
<p>Those taking the course will learn the fundamental ideas that underlie modern biology and medicine, including genetics, biochemistry, molecular biology, recombinant DNA, genomics and genomic medicine. They will become familiar with the structure and function of macromolecules such as DNA, RNA and proteins and understand how information flows within cells. Students will explore how mutations affect biological function and cause human disease. They will learn about modern molecular biological techniques and their wide-ranging impact.</p>
|
||||
|
||||
<p>“Eric Lander has created this remarkable digitally enhanced introduction to genetics and biology,” said Anant Agarwal, President of edX. “With this unique online version, he has brought the introductory biology course to a new level. It has been completely rethought and retooled, incorporating cutting-edge online interactive tools as well as community-building contests and milestone-based prizes.”</p>
|
||||
|
||||
<p>With online courses through edX like 7.00x, what matters isn’t what people have achieved or their transcripts, but their desire to learn. Students only need to come with a real interest in science and the desire to understand what's going on at the forefront of biology, and to learn the fundamental principles on which an amazing biomedical revolution is based – from one of the top scientist in the world. <a href="https://www.edx.org/courses/MITx/7.00x/2013_Spring/about">7.00x Introductory Biology: The Secret of Life</a> is now available for enrollment. Classes will start on March 5, 2013.</p>
|
||||
|
||||
<p>Dr. Eric Lander is President and Founding Director of the Broad Institute of Harvard and MIT, a new kind of collaborative biomedical research institution focused on genomic medicine. Dr. Lander is also Professor of Biology at MIT and Professor of Systems Biology at the Harvard Medical School. In addition, Dr. Lander serves as Co-Chair of the President’s Council of Advisors on Science and Technology, which advises the White House on science and technology. A geneticist, molecular biologist and mathematician, Dr. Lander has played a pioneering role in all aspects of the reading, understanding and medical application of the human genome. He was a principal leader of the international Human Genome Project (HGP) from 1990-2003, with his group being the largest contributor to the mapping and sequencing of the human genetic blueprint. Dr. Lander was an early pioneer in the free availability of genomic tools and information. Finally, he has mentored an extraordinary cadre of young scientists who have become the next generation of leaders in medical genomics. The recipient of numerous awards and honorary degrees, Dr. Lander was elected a member of the U.S. National Academy of Sciences in 1997 and of the U.S. Institute of Medicine in 1999.</p>
|
||||
|
||||
|
||||
<p>Previously announced new 2013 courses include:
|
||||
<a href="https://www.edx.org/courses/MITx/8.02x/2013_Spring/about">8.02x Electricity and Magnetism from Walter Lewin</a>
|
||||
<a href="http://www.edx.org/courses/HarvardX/ER22x/2013_Spring/about">Justice from Michael Sandel</a>; <a href="http://www.edx.org/courses/BerkeleyX/Stat2.1x/2013_Spring/about">Introduction to Statistics from Ani Adhikari</a>; <a href="http://www.edx.org/courses/MITx/14.73x/2013_Spring/about">The Challenges of Global Poverty from Esther Duflo</a>; <a href="http://www.edx.org/courses/HarvardX/CB22x/2013_Spring/about">The Ancient Greek Hero from Gregory Nagy</a>; <a href="https://www.edx.org/courses/BerkeleyX/CS191x/2013_Spring/about">Quantum Mechanics and Quantum Computation from Umesh Vazirani</a>; <a href="https://www.edx.org/courses/HarvardX/PH278x/2013_Spring/about">Human Health and Global Environmental Change, from Aaron Bernstein and Jack Spengler</a>.</p>
|
||||
|
||||
<p>In addition to these new courses, edX is bringing back several courses from the popular fall 2012 semester: <a href="http://www.edx.org/courses/MITx/6.00x/2013_Spring/about">Introduction to Computer Science and Programming</a>; <a href="http://www.edx.org/courses/MITx/3.091x/2013_Spring/about">Introduction to Solid State Chemistry</a>; <a href="http://www.edx.org/courses/BerkeleyX/CS188.1x/2013_Spring/about">Introduction to Artificial Intelligence</a>; <a href="https://www.edx.org/courses/BerkeleyX/CS169.1x/2013_Spring/about">Software as a Service I</a>; <a href="https://www.edx.org/courses/BerkeleyX/CS169.2x/2013_Spring/about">Software as a Service II</a>; <a href="http://www.edx.org/courses/BerkeleyX/CS184.1x/2013_Spring/about">Foundations of Computer Graphics</a>.</p>
|
||||
|
||||
<h2>About the Broad Institute of MIT and Harvard</h2>
|
||||
|
||||
<p>The Eli and Edythe L. Broad Institute of MIT and Harvard was founded in 2003 to empower this generation of creative scientists to transform medicine with new genome-based knowledge. The Broad Institute seeks to describe all the molecular components of life and their connections; discover the molecular basis of major human diseases; develop effective new approaches to diagnostics and therapeutics; and disseminate discoveries, tools, methods and data openly to the entire scientific community.</p>
|
||||
|
||||
<p>Founded by MIT, Harvard and its affiliated hospitals, and the visionary Los Angeles philanthropists Eli and Edythe L. Broad, the Broad Institute includes faculty, professional staff and students from throughout the MIT and Harvard biomedical research communities and beyond, with collaborations spanning over a hundred private and public institutions in more than 40 countries worldwide. For further information about the Broad Institute, go to <a href="http://www.broadinstitute.org">www.broadinstitute.org</a>.</p>
|
||||
|
||||
<h2>About edX</h2>
|
||||
|
||||
<p><a href="https://www.edx.org/">EdX</a> is a not-for-profit enterprise of its founding partners <a href="http://www.harvard.edu">Harvard University</a> and the <a href="http://www.mit.edu">Massachusetts Institute of Technology</a> focused on transforming online and on-campus learning through groundbreaking methodologies, game-like experiences and cutting-edge research. EdX provides inspirational and transformative knowledge to students of all ages, social status, and income who form worldwide communities of learners. EdX uses its open source technology to transcend physical and social borders. We’re focused on people, not profit. EdX is based in Cambridge, Massachusetts in the USA.</p>
|
||||
|
||||
<section class="contact">
|
||||
<p><strong>Contact:</strong></p>
|
||||
<p>Brad Baker, Weber Shandwick for edX</p>
|
||||
<p>BBaker@webershandwick.com</p>
|
||||
<p>(617) 520-7043</p>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="footer">
|
||||
<hr class="horizontal-divider">
|
||||
<div class="logo"></div><h3 class="date">01 - 30 - 2013</h3>
|
||||
<div class="social-sharing">
|
||||
<hr class="horizontal-divider">
|
||||
<p>Share with friends and family:</p>
|
||||
<a href="http://twitter.com/intent/tweet?text=:Human+Genome+Pioneer+Eric+Lander+to+reveal+the+secret+of+life
|
||||
+http://www.edx.org/press/eric-lander-secret-of-life" class="share">
|
||||
<img src="${static.url('images/social/twitter-sharing.png')}">
|
||||
</a>
|
||||
</a>
|
||||
<a href="mailto:?subject=Human%20Genome%20Pioneer%20Eric%20Lander%20to%20reveal%20the%20secret%20of%20life…http://edx.org/press/eric-lander-secret-of-life" class="share">
|
||||
<img src="${static.url('images/social/email-sharing.png')}">
|
||||
</a>
|
||||
<div class="fb-like" data-href="http://edx.org/press/eric-lander-secret-of-life" data-send="true" data-width="450" data-show-faces="true"></div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
60
lms/templates/static_templates/press_releases/template.html
Normal file
60
lms/templates/static_templates/press_releases/template.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="../../main.html" />
|
||||
|
||||
<%namespace name='static' file='../../static_content.html'/>
|
||||
|
||||
<%block name="title"><title>TITLE</title></%block>
|
||||
<div id="fb-root"></div>
|
||||
<script>(function(d, s, id) {
|
||||
var js, fjs = d.getElementsByTagName(s)[0];
|
||||
if (d.getElementById(id)) return;
|
||||
js = d.createElement(s); js.id = id;
|
||||
js.src = "//connect.facebook.net/en_US/all.js#xfbml=1";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}(document, 'script', 'facebook-jssdk'));</script>
|
||||
|
||||
<section class="pressrelease">
|
||||
<section class="container">
|
||||
<h1>TITLE</h1>
|
||||
<hr class="horizontal-divider">
|
||||
<article>
|
||||
<h2>SUBTITLE</h2>
|
||||
|
||||
<p><strong>CAMBRIDGE, MA – MONTH DAY, YEAR –</strong>
|
||||
|
||||
Text</p>
|
||||
|
||||
<p>more text</p>
|
||||
|
||||
|
||||
<h2>About edX</h2>
|
||||
|
||||
<p><a href="https://www.edx.org/">EdX</a> is a not-for-profit enterprise of its founding partners <a href="http://www.harvard.edu">Harvard University</a> and the <a href="http://www.mit.edu">Massachusetts Institute of Technology</a> focused on transforming online and on-campus learning through groundbreaking methodologies, game-like experiences and cutting-edge research. EdX provides inspirational and transformative knowledge to students of all ages, social status, and income who form worldwide communities of learners. EdX uses its open source technology to transcend physical and social borders. We’re focused on people, not profit. EdX is based in Cambridge, Massachusetts in the USA.</p>
|
||||
|
||||
|
||||
<section class="contact">
|
||||
<p><strong>Contact:</strong></p>
|
||||
<p>Brad Baker, Weber Shandwick for edX</p>
|
||||
<p>BBaker@webershandwick.com</p>
|
||||
<p>(617) 520-7043</p>
|
||||
</section>
|
||||
|
||||
<section class="footer">
|
||||
<hr class="horizontal-divider">
|
||||
<div class="logo"></div><h3 class="date">DATE: 01 - 29 - 2013</h3>
|
||||
<div class="social-sharing">
|
||||
<hr class="horizontal-divider">
|
||||
<p>Share with friends and family:</p>
|
||||
<a href="http://twitter.com/intent/tweet?text=:BLAH+BLAH+BLAH+http://www.edx.org/press/LINK" class="share">
|
||||
<img src="${static.url('images/social/twitter-sharing.png')}">
|
||||
</a>
|
||||
</a>
|
||||
<a href="mailto:?subject=BLAH%BLAH%BLAH…http://edx.org/press/LINK" class="share">
|
||||
<img src="${static.url('images/social/email-sharing.png')}">
|
||||
</a>
|
||||
<div class="fb-like" data-href="http://edx.org/press/LINK" data-send="true" data-width="450" data-show-faces="true"></div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
14
lms/urls.py
14
lms/urls.py
@@ -125,11 +125,14 @@ urlpatterns = ('',
|
||||
url(r'^press/bostonx-announcement$', 'static_template_view.views.render',
|
||||
{'template': 'press_releases/bostonx_announcement.html'},
|
||||
name="press/bostonx-announcement"),
|
||||
url(r'^press/eric-lander-secret-of-life$', 'static_template_view.views.render',
|
||||
{'template': 'press_releases/eric_lander_secret_of_life.html'},
|
||||
name="press/eric-lander-secret-of-life"),
|
||||
|
||||
|
||||
# Should this always update to point to the latest press release?
|
||||
(r'^pressrelease$', 'django.views.generic.simple.redirect_to',
|
||||
{'url': '/press/bostonx-announcement'}),
|
||||
{'url': '/press/eric-lander-secret-of-life'}),
|
||||
|
||||
|
||||
(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
|
||||
@@ -282,7 +285,11 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$',
|
||||
'open_ended_grading.peer_grading_service.save_calibration_essay', name='peer_grading_save_calibration_essay'),
|
||||
|
||||
# Cohorts management
|
||||
# Open Ended problem list
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_problems$',
|
||||
'open_ended_grading.views.student_problem_list', name='open_ended_problems'),
|
||||
|
||||
# Cohorts management
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
|
||||
'course_groups.views.list_cohorts', name="cohorts"),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$',
|
||||
@@ -301,6 +308,9 @@ if settings.COURSEWARE_ENABLED:
|
||||
'course_groups.views.debug_cohort_mgmt',
|
||||
name="debug_cohort_mgmt"),
|
||||
|
||||
# Open Ended Notifications
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/open_ended_notifications$',
|
||||
'open_ended_grading.views.combined_notifications', name='open_ended_notifications'),
|
||||
)
|
||||
|
||||
# discussion forums live within courseware, so courseware must be enabled first
|
||||
|
||||
@@ -48,7 +48,6 @@ sorl-thumbnail==11.12
|
||||
networkx==1.7
|
||||
pygraphviz==1.1
|
||||
-r repo-requirements.txt
|
||||
pil==1.1.7
|
||||
nltk==2.0.4
|
||||
django-debug-toolbar-mongo
|
||||
dogstatsd-python==0.2.1
|
||||
@@ -59,3 +58,4 @@ Shapely==1.2.16
|
||||
ipython==0.13.1
|
||||
xmltodict==0.4.1
|
||||
paramiko==1.9.0
|
||||
Pillow==1.7.8
|
||||
|
||||
Reference in New Issue
Block a user