Commit 8f3e8024 by cahrens

Merge branch 'master' into feature/cas/manual-policy

parents d52be034 a6e0fb91
1.8.7-p371 1.8.7-p371
\ No newline at end of file
...@@ -8,6 +8,8 @@ from django.core.urlresolvers import reverse ...@@ -8,6 +8,8 @@ from django.core.urlresolvers import reverse
from path import path from path import path
from tempfile import mkdtemp from tempfile import mkdtemp
import json import json
from fs.osfs import OSFS
from student.models import Registration from student.models import Registration
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -443,6 +445,17 @@ class ContentStoreTest(TestCase): ...@@ -443,6 +445,17 @@ class ContentStoreTest(TestCase):
items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None]))
self.assertEqual(len(items), 0) 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): def test_export_course(self):
ms = modulestore('direct') ms = modulestore('direct')
cs = contentstore() cs = contentstore()
...@@ -457,6 +470,16 @@ class ContentStoreTest(TestCase): ...@@ -457,6 +470,16 @@ class ContentStoreTest(TestCase):
# export out to a tempdir # export out to a tempdir
export_to_xml(ms, cs, location, root_dir, 'test_export') 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 # remove old course
delete_course(ms, cs, location) delete_course(ms, cs, location)
...@@ -472,6 +495,7 @@ class ContentStoreTest(TestCase): ...@@ -472,6 +495,7 @@ class ContentStoreTest(TestCase):
shutil.rmtree(root_dir) shutil.rmtree(root_dir)
def test_course_handouts_rewrites(self): def test_course_handouts_rewrites(self):
ms = modulestore('direct') ms = modulestore('direct')
cs = contentstore() cs = contentstore()
......
...@@ -261,7 +261,6 @@ def edit_unit(request, location): ...@@ -261,7 +261,6 @@ def edit_unit(request, location):
break break
lms_link = get_lms_link_for_item(item.location) 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) component_templates = defaultdict(list)
...@@ -1379,7 +1378,6 @@ def import_course(request, org, course, name): ...@@ -1379,7 +1378,6 @@ def import_course(request, org, course, name):
@login_required @login_required
def generate_export_course(request, org, course, name): def generate_export_course(request, org, course, name):
location = ['i4x', org, course, 'course', name] location = ['i4x', org, course, 'course', name]
course_module = modulestore().get_item(location)
# check that logged in user has permissions to this item # check that logged in user has permissions to this item
if not has_access(request.user, location): if not has_access(request.user, location):
raise PermissionDenied() raise PermissionDenied()
...@@ -1426,3 +1424,10 @@ def export_course(request, org, course, name): ...@@ -1426,3 +1424,10 @@ def export_course(request, org, course, name):
'active_tab': 'export', 'active_tab': 'export',
'successful_import_redirect_url' : '' '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)
\ No newline at end of file
...@@ -5,8 +5,22 @@ import json ...@@ -5,8 +5,22 @@ import json
from .common import * from .common import *
from logsettings import get_logger_config 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 DEBUG = False
TEMPLATE_DEBUG = False TEMPLATE_DEBUG = False
...@@ -14,9 +28,9 @@ EMAIL_BACKEND = 'django_ses.SESBackend' ...@@ -14,9 +28,9 @@ EMAIL_BACKEND = 'django_ses.SESBackend'
SESSION_ENGINE = 'django.contrib.sessions.backends.cache' SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage'
########################### NON-SECURE ENV CONFIG ############################## ############# NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc. # 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) ENV_TOKENS = json.load(env_file)
LMS_BASE = ENV_TOKENS.get('LMS_BASE') LMS_BASE = ENV_TOKENS.get('LMS_BASE')
...@@ -35,15 +49,16 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): ...@@ -35,15 +49,16 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
LOGGING = get_logger_config(LOG_DIR, LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'], logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
debug=False) debug=False,
service_variant=SERVICE_VARIANT)
with open(ENV_ROOT / "repos.json") as repos_file: with open(ENV_ROOT / "repos.json") as repos_file:
REPOS = json.load(repos_file) REPOS = json.load(repos_file)
############################## SECURE AUTH ITEMS ############################### ################ SECURE AUTH ITEMS ###############################
# Secret things: passwords, access keys, etc. # 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) AUTH_TOKENS = json.load(auth_file)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
......
{ {
"js_files": [ "js_files": [
"/static/js/vendor/RequireJS.js",
"/static/js/vendor/jquery.min.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/json2.js",
"/static/js/vendor/underscore-min.js", "/static/js/vendor/underscore-min.js",
"/static/js/vendor/backbone-min.js", "/static/js/vendor/backbone-min.js"
"/static/js/vendor/RequireJS.js"
] ]
} }
...@@ -80,64 +80,6 @@ $(document).ready(function() { ...@@ -80,64 +80,6 @@ $(document).ready(function() {
$('.import .file-input').click(); $('.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); $('.new-course-button').bind('click', addNewCourse);
// section name editing // section name editing
...@@ -279,136 +221,6 @@ function removePolicyMetadata(e) { ...@@ -279,136 +221,6 @@ function removePolicyMetadata(e) {
saveSubsection() 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) { function getEdxTimeFromDateTimeVals(date_val, time_val, format) {
var edxTimeStr = null; var edxTimeStr = null;
......
$(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({ ...@@ -211,15 +211,15 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
'intro_video' : 'course-introduction-video', 'intro_video' : 'course-introduction-video',
'effort' : "course-effort" 'effort' : "course-effort"
}, },
setupDatePicker : function(fieldName) { setupDatePicker: function (fieldName) {
var cacheModel = this.model; var cacheModel = this.model;
var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]); var div = this.$el.find('#' + this.fieldToSelectorMap[fieldName]);
var datefield = $(div).find(".date"); var datefield = $(div).find(".date");
var timefield = $(div).find(".time"); var timefield = $(div).find(".time");
var cachethis = this; var cachethis = this;
var savefield = function() { var savefield = function () {
cachethis.clearValidationErrors(); cachethis.clearValidationErrors();
var date = datefield.datepicker('getDate'); var date = datefield.datepicker('getDate');
if (date) { if (date) {
var time = timefield.timepicker("getSecondsFromMidnight"); var time = timefield.timepicker("getSecondsFromMidnight");
...@@ -227,21 +227,24 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -227,21 +227,24 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
time = 0; time = 0;
} }
var newVal = new Date(date.getTime() + time * 1000); var newVal = new Date(date.getTime() + time * 1000);
if (cacheModel.get(fieldName) != newVal) cacheModel.save(fieldName, newVal, if (cacheModel.get(fieldName).getTime() !== newVal.getTime()) {
{ error : CMS.ServerError}); cacheModel.save(fieldName, newVal, { error: CMS.ServerError});
}
} }
}; };
// instrument as date and time pickers // instrument as date and time pickers
timefield.timepicker(); timefield.timepicker();
datefield.datepicker();
// FIXME being called 2x on each change. Was trapping datepicker onSelect b4 but change to datepair broke that
datefield.datepicker({ onSelect : savefield }); // Using the change event causes savefield to be triggered twice, but it is necessary
timefield.on('changeTime', savefield); // to pick up when the date is typed directly in the field.
datefield.change(savefield);
datefield.datepicker('setDate', this.model.get(fieldName)); timefield.on('changeTime', savefield);
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
}, datefield.datepicker('setDate', this.model.get(fieldName));
if (this.model.has(fieldName)) timefield.timepicker('setTime', this.model.get(fieldName));
},
updateModel: function(event) { updateModel: function(event) {
switch (event.currentTarget.id) { switch (event.currentTarget.id) {
...@@ -294,29 +297,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ ...@@ -294,29 +297,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
} }
}, },
codeMirrors : {}, codeMirrors : {},
codeMirrorize : function(e, forcedTarget) { codeMirrorize: function (e, forcedTarget) {
if (forcedTarget) { var thisTarget;
thisTarget = forcedTarget; if (forcedTarget) {
thisTarget.id = $(thisTarget).attr('id'); thisTarget = forcedTarget;
} else { thisTarget.id = $(thisTarget).attr('id');
thisTarget = e.currentTarget; } else {
} thisTarget = e.currentTarget;
}
if (!this.codeMirrors[thisTarget.id]) { if (!this.codeMirrors[thisTarget.id]) {
var cachethis = this; var cachethis = this;
var field = this.selectorToField[thisTarget.id]; var field = this.selectorToField[thisTarget.id];
this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, { this.codeMirrors[thisTarget.id] = CodeMirror.fromTextArea(thisTarget, {
mode: "text/html", lineNumbers: true, lineWrapping: true, mode: "text/html", lineNumbers: true, lineWrapping: true,
onBlur : function(mirror) { onBlur: function (mirror) {
mirror.save(); mirror.save();
cachethis.clearValidationErrors(); cachethis.clearValidationErrors();
var newVal = mirror.getValue(); var newVal = mirror.getValue();
if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal, if (cachethis.model.get(field) != newVal) cachethis.model.save(field, newVal,
{ error : CMS.ServerError}); { error: CMS.ServerError});
} }
}); });
} }
} }
}); });
...@@ -668,7 +672,7 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({ ...@@ -668,7 +672,7 @@ CMS.Views.Settings.GraderView = CMS.Views.ValidatingView.extend({
$(event.currentTarget).parent().append( $(event.currentTarget).parent().append(
this.errorTemplate({message : 'For grading to work, you must change all "' + oldName + this.errorTemplate({message : 'For grading to work, you must change all "' + oldName +
'" subsections to "' + this.model.get('type') + '".'})); '" subsections to "' + this.model.get('type') + '".'}));
}; }
break; break;
default: default:
this.saveIfChanged(event); this.saveIfChanged(event);
......
...@@ -88,6 +88,40 @@ ...@@ -88,6 +88,40 @@
background: #f6f6f6; background: #f6f6f6;
padding: 20px; 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 { .new-update-button {
......
...@@ -23,10 +23,6 @@ ...@@ -23,10 +23,6 @@
<label>Display Name:</label> <label>Display Name:</label>
<input type="text" value="${subsection.display_name}" class="subsection-display-name-input" data-metadata-name="display_name"/> <input type="text" value="${subsection.display_name}" class="subsection-display-name-input" data-metadata-name="display_name"/>
</div> </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"> <div class="sortable-unit-list">
<label>Units:</label> <label>Units:</label>
${units.enum_units(subsection, subsection_units=subsection_units)} ${units.enum_units(subsection, subsection_units=subsection_units)}
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
<script src="${static.url('js/vendor/date.js')}"></script> <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/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/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" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
<script type="text/javascript"> <script type="text/javascript">
......
...@@ -68,6 +68,8 @@ urlpatterns = ('', ...@@ -68,6 +68,8 @@ urlpatterns = ('',
# temporary landing page for edge # temporary landing page for edge
url(r'^edge$', 'contentstore.views.edge', name='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')), url(r'^heartbeat$', include('heartbeat.urls')),
) )
......
from optparse import make_option from optparse import make_option
from json import dump from json import dump
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
...@@ -32,10 +33,9 @@ class Command(BaseCommand): ...@@ -32,10 +33,9 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
if len(args) < 1: if len(args) < 1:
raise CommandError("Missing single argument: output JSON file") outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json")
else:
# get output location: outputfile = args[0]
outputfile = args[0]
# construct the query object to dump: # construct the query object to dump:
registrations = TestCenterRegistration.objects.all() registrations = TestCenterRegistration.objects.all()
...@@ -65,6 +65,8 @@ class Command(BaseCommand): ...@@ -65,6 +65,8 @@ class Command(BaseCommand):
} }
if len(registration.upload_error_message) > 0: if len(registration.upload_error_message) > 0:
record['registration_error'] = registration.upload_error_message 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: if registration.needs_uploading:
record['needs_uploading'] = True record['needs_uploading'] = True
...@@ -72,5 +74,5 @@ class Command(BaseCommand): ...@@ -72,5 +74,5 @@ class Command(BaseCommand):
# dump output: # dump output:
with open(outputfile, 'w') as outfile: with open(outputfile, 'w') as outfile:
dump(output, outfile) dump(output, outfile, indent=2)
...@@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler ...@@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler
LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
def get_logger_config(log_dir, def get_logger_config(log_dir,
logging_env="no_env", logging_env="no_env",
tracking_filename="tracking.log", tracking_filename="tracking.log",
...@@ -13,7 +14,8 @@ def get_logger_config(log_dir, ...@@ -13,7 +14,8 @@ def get_logger_config(log_dir,
syslog_addr=None, syslog_addr=None,
debug=False, debug=False,
local_loglevel='INFO', local_loglevel='INFO',
console_loglevel=None): console_loglevel=None,
service_variant=None):
""" """
...@@ -39,13 +41,15 @@ def get_logger_config(log_dir, ...@@ -39,13 +41,15 @@ def get_logger_config(log_dir,
console_loglevel = 'DEBUG' if debug else 'INFO' console_loglevel = 'DEBUG' if debug else 'INFO'
hostname = platform.node().split(".")[0] 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] " "[{hostname} %(process)d] [%(filename)s:%(lineno)d] "
"- %(message)s").format( "- %(message)s").format(service_variant=service_variant,
logging_env=logging_env, hostname=hostname) logging_env=logging_env,
hostname=hostname)
handlers = ['console', 'local'] if debug else ['console', handlers = ['console', 'local'] if debug else ['console',
'syslogger-remote', 'local'] 'syslogger-remote', 'local']
logger_config = { logger_config = {
'version': 1, 'version': 1,
...@@ -78,11 +82,6 @@ def get_logger_config(log_dir, ...@@ -78,11 +82,6 @@ def get_logger_config(log_dir,
} }
}, },
'loggers': { 'loggers': {
'django': {
'handlers': handlers,
'propagate': True,
'level': 'INFO'
},
'tracking': { 'tracking': {
'handlers': ['tracking'], 'handlers': ['tracking'],
'level': 'DEBUG', 'level': 'DEBUG',
...@@ -93,16 +92,6 @@ def get_logger_config(log_dir, ...@@ -93,16 +92,6 @@ def get_logger_config(log_dir,
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False '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, ...@@ -128,6 +117,9 @@ def get_logger_config(log_dir,
}, },
}) })
else: else:
# for production environments we will only
# log INFO and up
logger_config['loggers']['']['level'] = 'INFO'
logger_config['handlers'].update({ logger_config['handlers'].update({
'local': { 'local': {
'level': local_loglevel, 'level': local_loglevel,
......
...@@ -14,7 +14,6 @@ ...@@ -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/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/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/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/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/tiny_mce/tiny_mce.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script> <script type="text/javascript" src="<%= common_js_root %>/vendor/RequireJS.js"></script>
......
...@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000 ...@@ -35,6 +35,29 @@ MAX_ATTEMPTS = 10000
# Overriden by max_score specified in xml. # Overriden by max_score specified in xml.
MAX_SCORE = 1 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): class CombinedOpenEndedModule(XModule):
""" """
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
...@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule): ...@@ -135,24 +158,31 @@ class CombinedOpenEndedModule(XModule):
#Allow reset is true if student has failed the criteria to move to the next child task #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.allow_reset = instance_state.get('ready_to_reset', False)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) 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 # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) 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) rubric_renderer = CombinedOpenEndedRubric(system, True)
try: rubric_string = stringify_children(definition['rubric'])
rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric'])) rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED)
except RubricParsingError:
log.error("Failed to parse rubric in location: {1}".format(location))
raise
#Static data is passed to the child modules to render #Static data is passed to the child modules to render
self.static_data = { self.static_data = {
'max_score': self._max_score, 'max_score': self._max_score,
'max_attempts': self.max_attempts, 'max_attempts': self.max_attempts,
'prompt': definition['prompt'], 'prompt': definition['prompt'],
'rubric': definition['rubric'], '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'] self.task_xml = definition['task_xml']
...@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule): ...@@ -245,13 +275,13 @@ class CombinedOpenEndedModule(XModule):
elif current_task_state is None and self.current_task_number > 0: 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_data = self.get_last_response(self.current_task_number - 1)
last_response = last_response_data['response'] last_response = last_response_data['response']
current_task_state=json.dumps({ current_task_state = json.dumps({
'state' : self.ASSESSING, 'state': self.ASSESSING,
'version' : self.STATE_VERSION, 'version': self.STATE_VERSION,
'max_score' : self._max_score, 'max_score': self._max_score,
'attempts' : 0, 'attempts': 0,
'created' : True, 'created': True,
'history' : [{'answer' : str(last_response)}], 'history': [{'answer': last_response}],
}) })
self.current_task = child_task_module(self.system, self.location, self.current_task = child_task_module(self.system, self.location,
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
...@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule): ...@@ -265,7 +295,6 @@ class CombinedOpenEndedModule(XModule):
self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
instance_state=current_task_state) instance_state=current_task_state)
log.debug(current_task_state)
return True return True
def check_allow_reset(self): def check_allow_reset(self):
...@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule): ...@@ -304,7 +333,8 @@ class CombinedOpenEndedModule(XModule):
'task_count': len(self.task_xml), 'task_count': len(self.task_xml),
'task_number': self.current_task_number + 1, 'task_number': self.current_task_number + 1,
'status': self.get_status(), 'status': self.get_status(),
'display_name': self.display_name 'display_name': self.display_name,
'accept_file_upload': self.accept_file_upload,
} }
return context return context
...@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule): ...@@ -392,6 +422,15 @@ class CombinedOpenEndedModule(XModule):
last_correctness = task.is_last_response_correct() last_correctness = task.is_last_response_correct()
max_score = task.max_score() max_score = task.max_score()
state = task.state 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 = { last_response_dict = {
'response': last_response, 'response': last_response,
'score': last_score, 'score': last_score,
...@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule): ...@@ -399,7 +438,8 @@ class CombinedOpenEndedModule(XModule):
'type': task_type, 'type': task_type,
'max_score': max_score, 'max_score': max_score,
'state': state, 'state': state,
'human_state': task.HUMAN_NAMES[state], 'human_state': human_state,
'human_task': human_task_name,
'correct': last_correctness, 'correct': last_correctness,
'min_score_to_attempt': min_score_to_attempt, 'min_score_to_attempt': min_score_to_attempt,
'max_score_to_attempt': max_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt,
...@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule): ...@@ -547,6 +587,63 @@ class CombinedOpenEndedModule(XModule):
return status_html 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): class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
""" """
...@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -603,4 +700,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
for child in ['task']: for child in ['task']:
add_child(child) add_child(child)
return elt return elt
\ No newline at end of file
...@@ -4,7 +4,8 @@ from lxml import etree ...@@ -4,7 +4,8 @@ from lxml import etree
log=logging.getLogger(__name__) log=logging.getLogger(__name__)
class RubricParsingError(Exception): class RubricParsingError(Exception):
pass def __init__(self, msg):
self.msg = msg
class CombinedOpenEndedRubric(object): class CombinedOpenEndedRubric(object):
...@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object): ...@@ -23,15 +24,32 @@ class CombinedOpenEndedRubric(object):
Output: Output:
html: the html that corresponds to the xml given html: the html that corresponds to the xml given
''' '''
success = False
try: try:
rubric_categories = self.extract_categories(rubric_xml) rubric_categories = self.extract_categories(rubric_xml)
html = self.system.render_template('open_ended_rubric.html', html = self.system.render_template('open_ended_rubric.html',
{'categories' : rubric_categories, {'categories' : rubric_categories,
'has_score': self.has_score, 'has_score': self.has_score,
'view_only': self.view_only}) 'view_only': self.view_only})
success = True
except: except:
raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)) 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): def extract_categories(self, element):
''' '''
......
...@@ -442,6 +442,14 @@ section.open-ended-child { ...@@ -442,6 +442,14 @@ section.open-ended-child {
margin: 10px; margin: 10px;
} }
span.short-form-response {
padding: 9px;
background: #F6F6F6;
border: 1px solid #ddd;
border-top: 0;
margin-bottom: 20px;
@include clearfix;
}
.grader-status { .grader-status {
padding: 9px; padding: 9px;
......
...@@ -117,7 +117,7 @@ th { ...@@ -117,7 +117,7 @@ th {
table td, th { table td, th {
margin: 20px 0; margin: 20px 0;
padding: 10px; padding: 10px;
border: 1px solid #ccc !important; border: 1px solid #ccc;
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
} }
\ No newline at end of file
<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"):&nbsp; </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>
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) -> ...@@ -64,7 +64,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
if createPlayer if createPlayer
return new VideoPlayer(video: context.video) return new VideoPlayer(video: context.video)
spyOn(window, 'onunload')
# Stub jQuery.cookie # Stub jQuery.cookie
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0' $.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
......
...@@ -140,15 +140,15 @@ class @Problem ...@@ -140,15 +140,15 @@ class @Problem
allowed_files = $(element).data("allowed_files") allowed_files = $(element).data("allowed_files")
for file in element.files for file in element.files
if allowed_files.length != 0 and file.name not in allowed_files if allowed_files.length != 0 and file.name not in allowed_files
unallowed_file_submitted = true unallowed_file_submitted = true
errors.push "You submitted #{file.name}; only #{allowed_files} are allowed." errors.push "You submitted #{file.name}; only #{allowed_files} are allowed."
if file.name in required_files 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 if file.size > max_filesize
file_too_large = true file_too_large = true
errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)' errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
fd.append(element.id, file) fd.append(element.id, file)
if element.files.length == 0 if element.files.length == 0
file_not_selected = true file_not_selected = true
fd.append(element.id, '') # In case we want to allow submissions with no file fd.append(element.id, '') # In case we want to allow submissions with no file
if required_files.length != 0 if required_files.length != 0
...@@ -157,7 +157,7 @@ class @Problem ...@@ -157,7 +157,7 @@ class @Problem
else else
fd.append(element.id, element.value) fd.append(element.id, element.value)
if file_not_selected if file_not_selected
errors.push 'You did not select any files to submit' errors.push 'You did not select any files to submit'
......
...@@ -12,6 +12,7 @@ class @CombinedOpenEnded ...@@ -12,6 +12,7 @@ class @CombinedOpenEnded
@state = @el.data('state') @state = @el.data('state')
@task_count = @el.data('task-count') @task_count = @el.data('task-count')
@task_number = @el.data('task-number') @task_number = @el.data('task-number')
@accept_file_upload = @el.data('accept-file-upload')
@allow_reset = @el.data('allow_reset') @allow_reset = @el.data('allow_reset')
@reset_button = @$('.reset-button') @reset_button = @$('.reset-button')
...@@ -44,6 +45,8 @@ class @CombinedOpenEnded ...@@ -44,6 +45,8 @@ class @CombinedOpenEnded
@skip_button = @$('.skip-button') @skip_button = @$('.skip-button')
@skip_button.click @skip_post_assessment @skip_button.click @skip_post_assessment
@file_upload_area = @$('.file-upload')
@can_upload_files = false
@open_ended_child= @$('.open-ended-child') @open_ended_child= @$('.open-ended-child')
@find_assessment_elements() @find_assessment_elements()
...@@ -55,6 +58,16 @@ class @CombinedOpenEnded ...@@ -55,6 +58,16 @@ class @CombinedOpenEnded
$: (selector) -> $: (selector) ->
$(selector, @el) $(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) => show_results: (event) =>
status_item = $(event.target).parent().parent() status_item = $(event.target).parent().parent()
status_number = status_item.data('status-number') status_number = status_item.data('status-number')
...@@ -67,7 +80,7 @@ class @CombinedOpenEnded ...@@ -67,7 +80,7 @@ class @CombinedOpenEnded
@submit_evaluation_button.click @message_post @submit_evaluation_button.click @message_post
Collapsible.setCollapsibles(@results_container) Collapsible.setCollapsibles(@results_container)
else else
@errors_area.html(response.error) @gentle_alert response.error
message_post: (event)=> message_post: (event)=>
Logger.log 'message_post', @answers Logger.log 'message_post', @answers
...@@ -108,22 +121,28 @@ class @CombinedOpenEnded ...@@ -108,22 +121,28 @@ class @CombinedOpenEnded
@submit_button.show() @submit_button.show()
@reset_button.hide() @reset_button.hide()
@next_problem_button.hide() @next_problem_button.hide()
@hide_file_upload()
@hint_area.attr('disabled', false) @hint_area.attr('disabled', false)
if @child_state == 'done' if @child_state == 'done'
@rubric_wrapper.hide() @rubric_wrapper.hide()
if @child_type=="openended" if @child_type=="openended"
@skip_button.hide() @skip_button.hide()
if @allow_reset=="True" if @allow_reset=="True"
@show_results_current
@reset_button.show() @reset_button.show()
@submit_button.hide() @submit_button.hide()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hint_area.attr('disabled', true) @hint_area.attr('disabled', true)
else if @child_state == 'initial' else if @child_state == 'initial'
@answer_area.attr("disabled", false) @answer_area.attr("disabled", false)
@submit_button.prop('value', 'Submit') @submit_button.prop('value', 'Submit')
@submit_button.click @save_answer @submit_button.click @save_answer
@setup_file_upload()
else if @child_state == 'assessing' else if @child_state == 'assessing'
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hide_file_upload()
@submit_button.prop('value', 'Submit assessment') @submit_button.prop('value', 'Submit assessment')
@submit_button.click @save_assessment @submit_button.click @save_assessment
if @child_type == "openended" if @child_type == "openended"
...@@ -134,6 +153,7 @@ class @CombinedOpenEnded ...@@ -134,6 +153,7 @@ class @CombinedOpenEnded
@skip_button.show() @skip_button.show()
@skip_post_assessment() @skip_post_assessment()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@submit_button.prop('value', 'Submit post-assessment') @submit_button.prop('value', 'Submit post-assessment')
if @child_type=="selfassessment" if @child_type=="selfassessment"
@submit_button.click @save_hint @submit_button.click @save_hint
...@@ -142,6 +162,7 @@ class @CombinedOpenEnded ...@@ -142,6 +162,7 @@ class @CombinedOpenEnded
else if @child_state == 'done' else if @child_state == 'done'
@rubric_wrapper.hide() @rubric_wrapper.hide()
@answer_area.attr("disabled", true) @answer_area.attr("disabled", true)
@replace_text_inputs()
@hint_area.attr('disabled', true) @hint_area.attr('disabled', true)
@submit_button.hide() @submit_button.hide()
if @child_type=="openended" if @child_type=="openended"
...@@ -149,6 +170,7 @@ class @CombinedOpenEnded ...@@ -149,6 +170,7 @@ class @CombinedOpenEnded
if @task_number<@task_count if @task_number<@task_count
@next_problem() @next_problem()
else else
@show_results_current()
@reset_button.show() @reset_button.show()
...@@ -160,17 +182,41 @@ class @CombinedOpenEnded ...@@ -160,17 +182,41 @@ class @CombinedOpenEnded
save_answer: (event) => save_answer: (event) =>
event.preventDefault() event.preventDefault()
max_filesize = 2*1000*1000 #2MB
if @child_state == 'initial' if @child_state == 'initial'
data = {'student_answer' : @answer_area.val()} files = ""
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) => if @can_upload_files == true
if response.success files = $('.file-upload-box')[0].files[0]
@rubric_wrapper.html(response.rubric_html) if files != undefined
@rubric_wrapper.show() if files.size > max_filesize
@child_state = 'assessing' @can_upload_files = false
@find_assessment_elements() files = ""
@rebind()
else 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 else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html('Problem state got out of sync. Try reloading the page.')
...@@ -260,6 +306,7 @@ class @CombinedOpenEnded ...@@ -260,6 +306,7 @@ class @CombinedOpenEnded
@gentle_alert "Moved to next step." @gentle_alert "Moved to next step."
else else
@gentle_alert "Your score did not meet the criteria to move to the next step." @gentle_alert "Your score did not meet the criteria to move to the next step."
@show_results_current()
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
...@@ -282,6 +329,31 @@ class @CombinedOpenEnded ...@@ -282,6 +329,31 @@ class @CombinedOpenEnded
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) => $.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
if response.state == "done" or response.state=="post_assessment" if response.state == "done" or response.state=="post_assessment"
delete window.queuePollerID delete window.queuePollerID
location.reload() @reload
else else
window.queuePollerID = window.setTimeout(@poll, 10000) 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 ...@@ -17,4 +17,26 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
# export the static assets # export the static assets
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/') 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'))
\ No newline at end of file
"""
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): ...@@ -258,7 +258,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
""" """
new_score_msg = self._parse_score_msg(score_msg, system) new_score_msg = self._parse_score_msg(score_msg, system)
if not new_score_msg['valid']: 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_score(new_score_msg['score'])
self.record_latest_post_assessment(score_msg) self.record_latest_post_assessment(score_msg)
...@@ -378,12 +378,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -378,12 +378,11 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
Return error message or feedback template Return error message or feedback template
""" """
log.debug(response_items) rubric_feedback = ""
rubric_feedback=""
feedback = self._convert_longform_feedback_to_html(response_items) 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_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']: if not response_items['success']:
return system.render_template("open_ended_error.html", return system.render_template("open_ended_error.html",
...@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -393,7 +392,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'grader_type': response_items['grader_type'], 'grader_type': response_items['grader_type'],
'score': "{0} / {1}".format(response_items['score'], self.max_score()), 'score': "{0} / {1}".format(response_items['score'], self.max_score()),
'feedback': feedback, 'feedback': feedback,
'rubric_feedback' : rubric_feedback 'rubric_feedback': rubric_feedback
}) })
return feedback_template return feedback_template
...@@ -406,6 +405,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -406,6 +405,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'score': Numeric value (floating point is okay) to assign to answer 'score': Numeric value (floating point is okay) to assign to answer
'msg': grader_msg 'msg': grader_msg
'feedback' : feedback from grader '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): Returns (valid_score_msg, correct, score, msg):
...@@ -437,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -437,7 +443,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
log.error(error_message) log.error(error_message)
fail['feedback'] = error_message fail['feedback'] = error_message
return fail return fail
#This is to support peer grading #This is to support peer grading
if isinstance(score_result['score'], list): if isinstance(score_result['score'], list):
feedback_items = [] feedback_items = []
for i in xrange(0, len(score_result['score'])): for i in xrange(0, len(score_result['score'])):
...@@ -448,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -448,8 +454,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'success': score_result['success'], 'success': score_result['success'],
'grader_id': score_result['grader_id'][i], 'grader_id': score_result['grader_id'][i],
'submission_id': score_result['submission_id'], 'submission_id': score_result['submission_id'],
'rubric_scores_complete' : score_result['rubric_scores_complete'][i], 'rubric_scores_complete': score_result['rubric_scores_complete'][i],
'rubric_xml' : score_result['rubric_xml'][i], 'rubric_xml': score_result['rubric_xml'][i],
} }
feedback_items.append(self._format_feedback(new_score_result, system)) feedback_items.append(self._format_feedback(new_score_result, system))
if join_feedback: if join_feedback:
...@@ -476,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -476,7 +482,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if not self.history: if not self.history:
return "" 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: if not short_feedback:
return feedback_dict['feedback'] if feedback_dict['valid'] else '' return feedback_dict['feedback'] if feedback_dict['valid'] else ''
if feedback_dict['valid']: if feedback_dict['valid']:
...@@ -554,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -554,11 +561,21 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
# add new history element with answer and empty score and hint. # add new history element with answer and empty score and hint.
self.new_history_entry(get['student_answer']) success, get = self.append_image_to_student_answer(get)
self.send_to_grader(get['student_answer'], system) error_message = ""
self.change_state(self.ASSESSING) 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): def update_score(self, get, system):
""" """
...@@ -602,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -602,8 +619,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
'msg': post_assessment, 'msg': post_assessment,
'child_type': 'openended', 'child_type': 'openended',
'correct': correct, 'correct': correct,
'accept_file_upload': self.accept_file_upload,
} }
log.debug(context)
html = system.render_template('open_ended.html', context) html = system.render_template('open_ended.html', context)
return html return html
......
...@@ -5,11 +5,13 @@ import json ...@@ -5,11 +5,13 @@ import json
import logging import logging
from lxml import etree from lxml import etree
from lxml.html import rewrite_links from lxml.html import rewrite_links
from lxml.html.clean import Cleaner, autolink_html
from path import path from path import path
import os import os
import sys import sys
import hashlib import hashlib
import capa.xqueue_interface as xqueue_interface import capa.xqueue_interface as xqueue_interface
import re
from pkg_resources import resource_string from pkg_resources import resource_string
...@@ -21,6 +23,7 @@ from .stringify import stringify_children ...@@ -21,6 +23,7 @@ from .stringify import stringify_children
from .xml_module import XmlDescriptor from .xml_module import XmlDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from capa.util import * from capa.util import *
import open_ended_image_submission
from datetime import datetime from datetime import datetime
...@@ -94,6 +97,7 @@ class OpenEndedChild(object): ...@@ -94,6 +97,7 @@ class OpenEndedChild(object):
self.prompt = static_data['prompt'] self.prompt = static_data['prompt']
self.rubric = static_data['rubric'] self.rubric = static_data['rubric']
self.display_name = static_data['display_name'] 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 # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
...@@ -113,7 +117,7 @@ class OpenEndedChild(object): ...@@ -113,7 +117,7 @@ class OpenEndedChild(object):
pass pass
def latest_answer(self): def latest_answer(self):
"""None if not available""" """Empty string if not available"""
if not self.history: if not self.history:
return "" return ""
return self.history[-1].get('answer', "") return self.history[-1].get('answer', "")
...@@ -125,17 +129,31 @@ class OpenEndedChild(object): ...@@ -125,17 +129,31 @@ class OpenEndedChild(object):
return self.history[-1].get('score') return self.history[-1].get('score')
def latest_post_assessment(self, system): def latest_post_assessment(self, system):
"""None if not available""" """Empty string if not available"""
if not self.history: if not self.history:
return "" return ""
return self.history[-1].get('post_assessment', "") 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): def new_history_entry(self, answer):
""" """
Adds a new entry to the history dictionary Adds a new entry to the history dictionary
@param answer: The student supplied answer @param answer: The student supplied answer
@return: None @return: None
""" """
answer = OpenEndedChild.sanitize_html(answer)
self.history.append({'answer': answer}) self.history.append({'answer': answer})
def record_latest_score(self, score): def record_latest_score(self, score):
...@@ -260,5 +278,115 @@ class OpenEndedChild(object): ...@@ -260,5 +278,115 @@ class OpenEndedChild(object):
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect' correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
return correctness 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): ...@@ -80,6 +80,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'state': self.state, 'state': self.state,
'allow_reset': self._allow_reset(), 'allow_reset': self._allow_reset(),
'child_type': 'selfassessment', 'child_type': 'selfassessment',
'accept_file_upload': self.accept_file_upload,
} }
html = system.render_template('self_assessment_prompt.html', context) html = system.render_template('self_assessment_prompt.html', context)
...@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -106,6 +107,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if dispatch not in handlers: if dispatch not in handlers:
return 'Error' return 'Error'
log.debug(get)
before = self.get_progress() before = self.get_progress()
d = handlers[dispatch](get, system) d = handlers[dispatch](get, system)
after = self.get_progress() after = self.get_progress()
...@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -123,7 +125,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
return '' return ''
rubric_renderer = CombinedOpenEndedRubric(system, True) 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 # we'll render it
context = {'rubric': rubric_html, context = {'rubric': rubric_html,
...@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -200,13 +202,21 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if self.state != self.INITIAL: if self.state != self.INITIAL:
return self.out_of_sync_error(get) return self.out_of_sync_error(get)
error_message = ""
# add new history element with answer and empty score and hint. # add new history element with answer and empty score and hint.
self.new_history_entry(get['student_answer']) success, get = self.append_image_to_student_answer(get)
self.change_state(self.ASSESSING) 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 { return {
'success': True, 'success': success,
'rubric_html': self.get_rubric_html(system) 'rubric_html': self.get_rubric_html(system),
'error': error_message,
'student_response': get['student_answer'],
} }
def save_assessment(self, get, system): def save_assessment(self, get, system):
......
...@@ -10,8 +10,16 @@ from . import test_system ...@@ -10,8 +10,16 @@ from . import test_system
class SelfAssessmentTest(unittest.TestCase): class SelfAssessmentTest(unittest.TestCase):
definition = {'rubric': 'A rubric', rubric = '''<rubric><rubric>
'prompt': 'Who?', <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?', 'submitmessage': 'Shall we submit now?',
'hintprompt': 'Consider this...', 'hintprompt': 'Consider this...',
} }
...@@ -23,47 +31,47 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -23,47 +31,47 @@ class SelfAssessmentTest(unittest.TestCase):
descriptor = Mock() descriptor = Mock()
def test_import(self): def setUp(self):
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"], state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
'scores': [0, 1], 'scores': [0, 1],
'hints': ['o hai'], 'hints': ['o hai'],
'state': SelfAssessmentModule.INITIAL, 'state': SelfAssessmentModule.INITIAL,
'attempts': 2}) '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 = { static_data = {
'max_attempts': 10, 'max_attempts': 10,
'rubric': etree.XML(rubric), 'rubric': etree.XML(self.rubric),
'prompt': prompt, 'prompt': self.prompt,
'max_score': 1, '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, self.definition, self.descriptor,
static_data, state, metadata=self.metadata) 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)
module.save_answer({'student_answer': "I am an answer"}, test_system) self.module.save_answer({'student_answer': "I am an answer"}, test_system)
self.assertEqual(module.state, module.ASSESSING) self.assertEqual(self.module.state, self.module.ASSESSING)
module.save_assessment({'assessment': '0'}, test_system) self.module.save_assessment({'assessment': '0'}, test_system)
self.assertEqual(module.state, module.DONE) self.assertEqual(self.module.state, self.module.DONE)
d = module.reset({})
d = self.module.reset({})
self.assertTrue(d['success']) 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 # if we now assess as right, skip the REQUEST_HINT state
module.save_answer({'student_answer': 'answer 4'}, test_system) self.module.save_answer({'student_answer': 'answer 4'}, test_system)
module.save_assessment({'assessment': '1'}, test_system) self.module.save_assessment({'assessment': '1'}, test_system)
self.assertEqual(module.state, module.DONE) self.assertEqual(self.module.state, self.module.DONE)
...@@ -406,7 +406,7 @@ class ResourceTemplates(object): ...@@ -406,7 +406,7 @@ class ResourceTemplates(object):
log.warning("Skipping unknown template file %s" % template_file) log.warning("Skipping unknown template file %s" % template_file)
continue continue
template_content = resource_string(__name__, os.path.join(dirname, template_file)) 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)) templates.append(Template(**template))
return templates return templates
......
<p>This is another sample tab</p>
\ No newline at end of file
...@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse ...@@ -18,8 +18,10 @@ from django.core.urlresolvers import reverse
from fs.errors import ResourceNotFoundError 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 module_render import get_module
from courseware.access import has_access from courseware.access import has_access
from static_replace import replace_urls from static_replace import replace_urls
...@@ -27,13 +29,10 @@ from xmodule.modulestore import Location ...@@ -27,13 +29,10 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.xml import XMLModuleStore
from xmodule.x_module import XModule 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 student.models import unique_id_for_user
from open_ended_grading import open_ended_notifications
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InvalidTabsException(Exception): class InvalidTabsException(Exception):
...@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page): ...@@ -118,49 +117,45 @@ def _textbooks(tab, user, course, active_page):
def _staff_grading(tab, user, course, active_page): def _staff_grading(tab, user, course, active_page):
if has_access(user, course, 'staff'): if has_access(user, course, 'staff'):
link = reverse('staff_grading', args=[course.id]) link = reverse('staff_grading', args=[course.id])
staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
pending_grading=False
tab_name = "Staff grading" tab_name = "Staff grading"
img_path= ""
try: notifications = open_ended_notifications.staff_grading_notifications(course, user)
notifications = json.loads(staff_gs.get_notifications(course.id)) pending_grading = notifications['pending_grading']
if notifications['success']: img_path = notifications['img_path']
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 = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)] tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
return tab return tab
return [] return []
def _peer_grading(tab, user, course, active_page): def _peer_grading(tab, user, course, active_page):
if user.is_authenticated(): if user.is_authenticated():
link = reverse('peer_grading', args=[course.id]) link = reverse('peer_grading', args=[course.id])
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
pending_grading=False
tab_name = "Peer grading" tab_name = "Peer grading"
img_path= ""
try: notifications = open_ended_notifications.peer_grading_notifications(course, user)
notifications = json.loads(peer_gs.get_notifications(course.id,unique_id_for_user(user))) pending_grading = notifications['pending_grading']
if notifications['success']: img_path = notifications['img_path']
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"
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)] tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
return tab return tab
return [] 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 #### Validators
...@@ -198,6 +193,7 @@ VALID_TAB_TYPES = { ...@@ -198,6 +193,7 @@ VALID_TAB_TYPES = {
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab), 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
'peer_grading': TabImpl(null_validator, _peer_grading), 'peer_grading': TabImpl(null_validator, _peer_grading),
'staff_grading': TabImpl(null_validator, _staff_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): ...@@ -326,4 +322,4 @@ def get_static_tab_contents(request, cache, course, tab):
if tab_module is not None: if tab_module is not None:
html = tab_module.get_html() html = tab_module.get_html()
return html return html
\ No newline at end of file
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'
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
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): ...@@ -116,7 +116,7 @@ class GradingService(object):
if 'rubric' in response_json: if 'rubric' in response_json:
rubric = response_json['rubric'] rubric = response_json['rubric']
rubric_renderer = CombinedOpenEndedRubric(self.system, False) 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 response_json['rubric'] = rubric_html
return response_json return response_json
# if we can't parse the rubric into HTML, # if we can't parse the rubric into HTML,
......
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)
\ No newline at end of file
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 ...@@ -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 without making actual service calls to the grading controller
""" """
class MockPeerGradingService(object): 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): def get_next_submission(self, problem_location, grader_id):
return json.dumps({'success': True, return json.dumps({'success': True,
'submission_id':1, 'submission_id':1,
...@@ -41,7 +50,7 @@ class MockPeerGradingService(object): ...@@ -41,7 +50,7 @@ class MockPeerGradingService(object):
'max_score': 4}) 'max_score': 4})
def save_grade(self, location, grader_id, submission_id, def save_grade(self, location, grader_id, submission_id,
score, feedback, submission_key): score, feedback, submission_key, rubric_scores):
return json.dumps({'success': True}) return json.dumps({'success': True})
def is_student_calibrated(self, problem_location, grader_id): def is_student_calibrated(self, problem_location, grader_id):
...@@ -57,16 +66,16 @@ class MockPeerGradingService(object): ...@@ -57,16 +66,16 @@ class MockPeerGradingService(object):
'max_score': 4}) 'max_score': 4})
def save_calibration_essay(self, problem_location, grader_id, def save_calibration_essay(self, problem_location, grader_id,
calibration_essay_id, submission_key, score, feedback): calibration_essay_id, submission_key, score, feedback, rubric_scores):
return {'success': True, 'actual_score': 2} return json.dumps({'success': True, 'actual_score': 2})
def get_problem_list(self, course_id, grader_id): def get_problem_list(self, course_id, grader_id):
return json.dumps({'success': True, return json.dumps({'success': True,
'problem_list': [ 'problem_list': [
json.dumps({'location': 'i4x://MITx/3.091x/problem/open_ended_demo1', 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', 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): class PeerGradingService(GradingService):
......
...@@ -6,6 +6,7 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open ...@@ -6,6 +6,7 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open
from django.test import TestCase from django.test import TestCase
from open_ended_grading import staff_grading_service from open_ended_grading import staff_grading_service
from open_ended_grading import peer_grading_service
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
...@@ -17,9 +18,10 @@ from nose import SkipTest ...@@ -17,9 +18,10 @@ from nose import SkipTest
from mock import patch, Mock from mock import patch, Mock
import json import json
import logging
log = logging.getLogger(__name__)
from override_settings import override_settings from override_settings import override_settings
_mock_service = staff_grading_service.MockStaffGradingService()
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
class TestStaffGradingService(ct.PageLoader): class TestStaffGradingService(ct.PageLoader):
...@@ -111,3 +113,144 @@ class TestStaffGradingService(ct.PageLoader): ...@@ -111,3 +113,144 @@ class TestStaffGradingService(ct.PageLoader):
d = json.loads(r.content) d = json.loads(r.content)
self.assertTrue(d['success'], str(d)) self.assertTrue(d['success'], str(d))
self.assertIsNotNone(d['problem_list']) 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 ...@@ -13,10 +13,17 @@ from courseware.courses import get_course_with_access
from peer_grading_service import PeerGradingService from peer_grading_service import PeerGradingService
from peer_grading_service import MockPeerGradingService from peer_grading_service import MockPeerGradingService
from controller_query_service import ControllerQueryService
from grading_service import GradingServiceError from grading_service import GradingServiceError
import json import json
from .staff_grading import StaffGrading 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__) log = logging.getLogger(__name__)
...@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING: ...@@ -26,18 +33,34 @@ if settings.MOCK_PEER_GRADING:
else: else:
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE) 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 Reverses the URL from the name and the course id, and then adds a trailing slash if
it does not exist yet it does not exist yet
""" """
def _reverse_with_slash(url_name, course_id): 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('/'): if not ajax_url.endswith('/'):
ajax_url += '/' ajax_url += '/'
return 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) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def staff_grading(request, course_id): def staff_grading(request, course_id):
""" """
...@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id): ...@@ -114,5 +137,111 @@ def peer_grading_problem(request, course_id):
'ajax_url': ajax_url, 'ajax_url': ajax_url,
# Checked above # Checked above
'staff_access': False, }) '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 ...@@ -10,8 +10,23 @@ import json
from .common import * from .common import *
from logsettings import get_logger_config 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 DEBUG = False
TEMPLATE_DEBUG = False TEMPLATE_DEBUG = False
...@@ -25,14 +40,15 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True ...@@ -25,14 +40,15 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
# IMPORTANT: With this enabled, the server must always be behind a proxy that # IMPORTANT: With this enabled, the server must always be behind a proxy that
# strips the header HTTP_X_FORWARDED_PROTO from client requests. Otherwise, # strips the header HTTP_X_FORWARDED_PROTO from client requests. Otherwise,
# a user can fool our server into thinking it was an https connection. # 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. # for other warnings.
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
########################### NON-SECURE ENV CONFIG ############################## ################# NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc. # 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) ENV_TOKENS = json.load(env_file)
SITE_NAME = ENV_TOKENS['SITE_NAME'] SITE_NAME = ENV_TOKENS['SITE_NAME']
...@@ -55,18 +71,19 @@ LOGGING = get_logger_config(LOG_DIR, ...@@ -55,18 +71,19 @@ LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'], logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
local_loglevel=local_loglevel, local_loglevel=local_loglevel,
debug=False) debug=False,
service_variant=SERVICE_VARIANT)
COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {}) COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {})
SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {})
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', []) VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL",'') COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '')
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY",'') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
############################## SECURE AUTH ITEMS ############################### ############################## SECURE AUTH ITEMS ###############
# Secret things: passwords, access keys, etc. # 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) AUTH_TOKENS = json.load(auth_file)
SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
...@@ -84,8 +101,10 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE'] ...@@ -84,8 +101,10 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
MODULESTORE = AUTH_TOKENS.get('MODULESTORE', MODULESTORE) MODULESTORE = AUTH_TOKENS.get('MODULESTORE', MODULESTORE)
CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE) CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE', STAFF_GRADING_INTERFACE) STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE',
PEER_GRADING_INTERFACE = AUTH_TOKENS.get('PEER_GRADING_INTERFACE', PEER_GRADING_INTERFACE) STAFF_GRADING_INTERFACE)
PEER_GRADING_INTERFACE = AUTH_TOKENS.get('PEER_GRADING_INTERFACE',
PEER_GRADING_INTERFACE)
PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
......
<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', -> ...@@ -10,19 +10,6 @@ describe 'Courseware', ->
Courseware.start() Courseware.start()
expect(Logger.bind).toHaveBeenCalled() 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', -> describe 'render', ->
beforeEach -> beforeEach ->
jasmine.stubRequests() jasmine.stubRequests()
...@@ -30,6 +17,7 @@ describe 'Courseware', -> ...@@ -30,6 +17,7 @@ describe 'Courseware', ->
spyOn(window, 'Histogram') spyOn(window, 'Histogram')
spyOn(window, 'Problem') spyOn(window, 'Problem')
spyOn(window, 'Video') spyOn(window, 'Video')
spyOn(XModule, 'loadModules')
setFixtures """ setFixtures """
<div class="course-content"> <div class="course-content">
<div id="video_1" class="video" data-streams="1.0:abc1234"></div> <div id="video_1" class="video" data-streams="1.0:abc1234"></div>
...@@ -41,12 +29,8 @@ describe 'Courseware', -> ...@@ -41,12 +29,8 @@ describe 'Courseware', ->
""" """
@courseware.render() @courseware.render()
it 'detect the video elements and convert them', -> it 'ensure that the XModules have been loaded', ->
expect(window.Video).toHaveBeenCalledWith('1', '1.0:abc1234') expect(XModule.loadModules).toHaveBeenCalled()
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 'detect the histrogram element and convert it', -> it 'detect the histrogram element and convert it', ->
expect(window.Histogram).toHaveBeenCalledWith('3', [[0, 1]]) expect(window.Histogram).toHaveBeenCalledWith('3', [[0, 1]])
...@@ -16,6 +16,7 @@ describe 'Navigation', -> ...@@ -16,6 +16,7 @@ describe 'Navigation', ->
active: 1 active: 1
header: 'h3' header: 'h3'
autoHeight: false autoHeight: false
heightStyle: 'content'
describe 'when there is no active section', -> describe 'when there is no active section', ->
beforeEach -> beforeEach ->
...@@ -23,11 +24,12 @@ describe 'Navigation', -> ...@@ -23,11 +24,12 @@ describe 'Navigation', ->
$('#accordion').append('<ul><li></li></ul><ul><li></li></ul>') $('#accordion').append('<ul><li></li></ul><ul><li></li></ul>')
new Navigation new Navigation
it 'activate the accordian with section 1 as active', -> it 'activate the accordian with no section as active', ->
expect($('#accordion').accordion).toHaveBeenCalledWith expect($('#accordion').accordion).toHaveBeenCalledWith
active: 1 active: 0
header: 'h3' header: 'h3'
autoHeight: false autoHeight: false
heightStyle: 'content'
it 'binds the accordionchange event', -> it 'binds the accordionchange event', ->
Navigation.bind() Navigation.bind()
......
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" ...@@ -9,9 +9,13 @@ state_graded = "graded"
state_no_data = "no_data" state_no_data = "no_data"
state_error = "error" state_error = "error"
class StaffGradingBackend class @StaffGradingBackend
constructor: (ajax_url, mock_backend) -> constructor: (ajax_url, mock_backend) ->
@ajax_url = ajax_url @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 @mock_backend = mock_backend
if @mock_backend if @mock_backend
@mock_cnt = 0 @mock_cnt = 0
...@@ -142,7 +146,7 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t ...@@ -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"}) .error => callback({success: false, error: "Error occured while performing this operation"})
class StaffGrading class @StaffGrading
constructor: (backend) -> constructor: (backend) ->
@backend = backend @backend = backend
......
...@@ -44,6 +44,8 @@ ...@@ -44,6 +44,8 @@
@import "course/gradebook"; @import "course/gradebook";
@import "course/tabs"; @import "course/tabs";
@import "course/staff_grading"; @import "course/staff_grading";
@import "course/open_ended_grading";
// instructor // instructor
@import "course/instructor/instructor"; @import "course/instructor/instructor";
......
...@@ -65,6 +65,11 @@ div.info-wrapper { ...@@ -65,6 +65,11 @@ div.info-wrapper {
list-style-type: disc; list-style-type: disc;
} }
> ol {
list-style: decimal outside none;
padding: 0 0 0 1em;
}
li { li {
margin-bottom: lh(.5); margin-bottom: lh(.5);
} }
......
.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 { ...@@ -13,7 +13,7 @@ div.syllabus {
} }
table { table {
table-layout: auto;
text-align: left; text-align: left;
margin: 10px 0; margin: 10px 0;
...@@ -25,18 +25,19 @@ div.syllabus { ...@@ -25,18 +25,19 @@ div.syllabus {
tr.first { tr.first {
td { td {
padding-top: 15px; padding-top: 15px !important;
} }
} }
td { td {
border: none !important;
padding: 5px 10px !important;
vertical-align: middle; vertical-align: middle;
font-size: 1em !important;
padding: 5px 10px; line-height: auto;
&.day, &.due, &.slides, &.assignment { &.day, &.due, &.slides, &.assignment {
white-space: nowrap; white-space: nowrap !important;
} }
&.no_class { &.no_class {
...@@ -48,16 +49,12 @@ div.syllabus { ...@@ -48,16 +49,12 @@ div.syllabus {
} }
&.week_separator { &.week_separator {
padding: 0px; padding: 0px !important;
hr { hr {
margin: 10px; margin: 10px;
} }
} }
} }
} }
} }
<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> <h2>${display_name}</h2>
<div class="status-container"> <div class="status-container">
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div class="statusitem" data-status-number="${i}"> <div class="statusitem" data-status-number="${i}">
%endif %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': % if status['state'] == 'initial':
<span class="unanswered" id="status"></span> <span class="unanswered" id="status"></span>
% elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct': % elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct':
......
...@@ -6,7 +6,16 @@ ...@@ -6,7 +6,16 @@
<link type="text/html" rel="alternate" href="http://blog.edx.org/"/> <link type="text/html" rel="alternate" href="http://blog.edx.org/"/>
##<link type="application/atom+xml" rel="self" href="https://github.com/blog.atom"/> ##<link type="application/atom+xml" rel="self" href="https://github.com/blog.atom"/>
<title>EdX Blog</title> <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">&lt;img src=&quot;${static.url('images/press/releases/eric-lander_240x180.jpg')}&quot; /&gt;
&lt;p&gt;&lt;/p&gt;</content>
</entry>
<entry> <entry>
<id>tag:www.edx.org,2012:Post/12</id> <id>tag:www.edx.org,2012:Post/12</id>
<published>2013-01-29T10:00:00-07:00</published> <published>2013-01-29T10:00:00-07:00</published>
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
<div class="prompt"> <div class="prompt">
${prompt|n} ${prompt|n}
</div> </div>
<h4>Answer</h4>
<textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea> <textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea>
<div class="message-wrapper"></div> <div class="message-wrapper"></div>
...@@ -22,6 +23,8 @@ ...@@ -22,6 +23,8 @@
% endif % endif
</div> </div>
<div class="file-upload"></div>
<input type="button" value="Submit" class="submit-button" name="show"/> <input type="button" value="Submit" class="submit-button" name="show"/>
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/> <input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/>
......
<%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>
<%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 @@ ...@@ -5,8 +5,9 @@
${prompt} ${prompt}
</div> </div>
<h4>Answer</h4>
<div> <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>
<div class="open-ended-action"></div> <div class="open-ended-action"></div>
...@@ -16,6 +17,7 @@ ...@@ -16,6 +17,7 @@
<div class="hint-wrapper">${initial_hint}</div> <div class="hint-wrapper">${initial_hint}</div>
<div class="message-wrapper">${initial_message}</div> <div class="message-wrapper">${initial_message}</div>
<div class="file-upload"></div>
<input type="button" value="Submit" class="submit-button" name="show"/> <input type="button" value="Submit" class="submit-button" name="show"/>
</section> </section>
...@@ -39,7 +39,14 @@ ...@@ -39,7 +39,14 @@
</article> </article>
<article class="response"> <article class="response">
<h3>Will certificates be awarded?</h3> <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>
<article class="response"> <article class="response">
<h3>What will the scope of the online courses be? How many? Which faculty?</h3> <h3>What will the scope of the online courses be? How many? Which faculty?</h3>
......
...@@ -180,8 +180,17 @@ ...@@ -180,8 +180,17 @@
<article class="response"> <article class="response">
<h3 class="question">Will I get a certificate for taking an edX course?</h3> <h3 class="question">Will I get a certificate for taking an edX course?</h3>
<div class="answer" id="certificates_and_credits_faq_answer_0"> <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> <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 &amp;
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> </div>
</article> </article>
<article class="response"> <article class="response">
......
<%! 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 &ndash; MONTH DAY, YEAR &ndash;</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>
...@@ -125,11 +125,14 @@ urlpatterns = ('', ...@@ -125,11 +125,14 @@ urlpatterns = ('',
url(r'^press/bostonx-announcement$', 'static_template_view.views.render', url(r'^press/bostonx-announcement$', 'static_template_view.views.render',
{'template': 'press_releases/bostonx_announcement.html'}, {'template': 'press_releases/bostonx_announcement.html'},
name="press/bostonx-announcement"), 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? # Should this always update to point to the latest press release?
(r'^pressrelease$', 'django.views.generic.simple.redirect_to', (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'}), (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}),
...@@ -282,7 +285,11 @@ if settings.COURSEWARE_ENABLED: ...@@ -282,7 +285,11 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$', 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'), '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$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
'course_groups.views.list_cohorts', name="cohorts"), 'course_groups.views.list_cohorts', name="cohorts"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$',
...@@ -301,6 +308,9 @@ if settings.COURSEWARE_ENABLED: ...@@ -301,6 +308,9 @@ if settings.COURSEWARE_ENABLED:
'course_groups.views.debug_cohort_mgmt', 'course_groups.views.debug_cohort_mgmt',
name="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 # discussion forums live within courseware, so courseware must be enabled first
......
...@@ -48,7 +48,6 @@ sorl-thumbnail==11.12 ...@@ -48,7 +48,6 @@ sorl-thumbnail==11.12
networkx==1.7 networkx==1.7
pygraphviz==1.1 pygraphviz==1.1
-r repo-requirements.txt -r repo-requirements.txt
pil==1.1.7
nltk==2.0.4 nltk==2.0.4
django-debug-toolbar-mongo django-debug-toolbar-mongo
dogstatsd-python==0.2.1 dogstatsd-python==0.2.1
...@@ -59,3 +58,4 @@ Shapely==1.2.16 ...@@ -59,3 +58,4 @@ Shapely==1.2.16
ipython==0.13.1 ipython==0.13.1
xmltodict==0.4.1 xmltodict==0.4.1
paramiko==1.9.0 paramiko==1.9.0
Pillow==1.7.8
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment