Commit 42cf8706 by Stephen Sanchez

Allowing file based submissions for ORA2

parent e5d1d4bd
"""
The File Upload application is designed to allow the management of files
associated with submissions. This can be used to upload new files and provide
URLs to the new location.
"""
import boto
import logging
from django.conf import settings
logger = logging.getLogger("openassessment.fileupload.api")
class FileUploadError(Exception):
"""An error related to uploading files
This is the generic error raised when a file could not be uploaded.
"""
pass
class FileUploadInternalError(FileUploadError):
"""An error internal to the File Upload API.
This is an error raised when file upload failed due to internal problems in
the File Upload API, beyond the intervention of the requester.
"""
pass
class FileUploadRequestError(FileUploadError):
"""This error is raised when the request has invalid parameters for upload.
This error will be raised if the file being uploaded is somehow invalid,
based on type restrictions, size restrictions, upload limits, etc.
"""
pass
# The setting used to find the name of the AWS Bucket used for uploading
# content.
BUCKET_SETTING = "FILE_UPLOAD_STORAGE_BUCKET_NAME"
# The setting used to prefix uploaded files using this service.
FILE_STORAGE_SETTING = "FILE_UPLOAD_STORAGE_PREFIX"
# The default file storage prefix.
FILE_STORAGE = "submissions_attachments"
def get_upload_url(key, content_type):
"""Request a one-time upload URL to upload files.
Requests a URL for a one-time file upload.
Args:
key (str): A unique identifier used to construct the upload location and
later, can be used to retrieve the same information. This service
must be able to identify data for both upload and download using
this key.
content_type (str): The content type for the file.
Returns:
A URL (str) to use for a one-time upload.
Raises:
FileUploadInternalError: Raised when an internal error occurs while
retrieving a one-time URL.
FileUploadRequestError: Raised when the request failed due to
request restrictions
"""
bucket_name, key_name = _retrieve_parameters(key)
try:
conn = _connect_to_s3()
upload_url = conn.generate_url(
3600,
'PUT',
bucket_name,
key_name,
headers={'Content-Length': '5242880', 'Content-Type': content_type}
)
return upload_url
except Exception as ex:
logger.exception(
u"An internal exception occurred while generating an upload URL."
)
raise FileUploadInternalError(ex)
def get_download_url(key):
"""Requests a URL to download the related file from.
Requests a URL for the given student_item.
Args:
key (str): A unique identifier used to identify the data requested for
download. This service must be able to identify data for both
upload and download using this key.
Returns:
A URL (str) to use for downloading related files. If no file is found,
returns an empty string.
"""
bucket_name, key_name = _retrieve_parameters(key)
try:
conn = _connect_to_s3()
bucket = conn.get_bucket(bucket_name)
s3_key = bucket.get_key(key_name)
return s3_key.generate_url(expires_in=1000) if s3_key else ""
except Exception as ex:
logger.exception(
u"An internal exception occurred while generating a download URL."
)
raise FileUploadInternalError(ex)
def _connect_to_s3():
"""Connect to s3
Creates a connection to s3 for file URLs.
"""
# Try to get the AWS credentials from settings if they are available
# If not, these will default to `None`, and boto will try to use
# environment vars or configuration files instead.
aws_access_key_id = getattr(settings, 'AWS_ACCESS_KEY_ID', None)
aws_secret_access_key = getattr(settings, 'AWS_SECRET_ACCESS_KEY', None)
return boto.connect_s3(
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key
)
def _retrieve_parameters(key):
"""
Simple utility function to validate settings and arguments before compiling
bucket names and key names.
Args:
key (str): Custom key passed in with the request.
Returns:
A tuple of the bucket name and the complete key.
Raises:
FileUploadRequestError
FileUploadInternalError
"""
if not key:
raise FileUploadRequestError("Key required for URL request")
bucket_name = getattr(settings, BUCKET_SETTING, None)
if not bucket_name:
raise FileUploadInternalError("No bucket name configured for FileUpload Service.")
return bucket_name, _get_key_name(key)
def _get_key_name(key):
"""Construct a key name with the given string and configured prefix.
Constructs a unique key with the specified path and the service-specific
configured prefix.
Args:
key (str): Key to identify data for both upload and download.
Returns:
A key name (str) to use constructing URLs.
"""
# The specified file prefix for the storage must be publicly viewable
# or all uploaded images will not be seen.
prefix = getattr(settings, FILE_STORAGE_SETTING, FILE_STORAGE)
return u"{prefix}/{key}".format(
prefix=prefix,
key=key
)
\ No newline at end of file
import boto
from boto.s3.key import Key
import ddt
from django.test import TestCase
from django.test.utils import override_settings
from moto import mock_s3
from mock import patch
from nose.tools import raises
from openassessment.fileupload import api
@ddt.ddt
class TestFileUploadService(TestCase):
@mock_s3
@override_settings(
AWS_ACCESS_KEY_ID='foobar',
AWS_SECRET_ACCESS_KEY='bizbaz',
FILE_UPLOAD_STORAGE_BUCKET_NAME="mybucket"
)
def test_get_upload_url(self):
conn = boto.connect_s3()
conn.create_bucket('mybucket')
uploadUrl = api.get_upload_url("foo", "bar")
self.assertIn("https://mybucket.s3.amazonaws.com/submissions_attachments/foo", uploadUrl)
@mock_s3
@override_settings(
AWS_ACCESS_KEY_ID='foobar',
AWS_SECRET_ACCESS_KEY='bizbaz',
FILE_UPLOAD_STORAGE_BUCKET_NAME="mybucket"
)
def test_get_download_url(self):
conn = boto.connect_s3()
bucket = conn.create_bucket('mybucket')
key = Key(bucket)
key.key = "submissions_attachments/foo"
key.set_contents_from_string("How d'ya do?")
downloadUrl = api.get_download_url("foo")
self.assertIn("https://mybucket.s3.amazonaws.com/submissions_attachments/foo", downloadUrl)
@raises(api.FileUploadInternalError)
def test_get_upload_url_no_bucket(self):
api.get_upload_url("foo", "bar")
@raises(api.FileUploadRequestError)
def test_get_upload_url_no_key(self):
api.get_upload_url("", "bar")
@mock_s3
@override_settings(
AWS_ACCESS_KEY_ID='foobar',
AWS_SECRET_ACCESS_KEY='bizbaz',
FILE_UPLOAD_STORAGE_BUCKET_NAME="mybucket"
)
@patch.object(boto, 'connect_s3')
@raises(api.FileUploadInternalError)
def test_get_upload_url_error(self, mock_s3):
mock_s3.side_effect = Exception("Oh noes")
api.get_upload_url("foo", "bar")
@mock_s3
@override_settings(
AWS_ACCESS_KEY_ID='foobar',
AWS_SECRET_ACCESS_KEY='bizbaz',
FILE_UPLOAD_STORAGE_BUCKET_NAME="mybucket"
)
@patch.object(boto, 'connect_s3')
@raises(api.FileUploadInternalError, mock_s3)
def test_get_download_url_error(self, mock_s3):
mock_s3.side_effect = Exception("Oh noes")
api.get_download_url("foo")
......@@ -27,6 +27,16 @@
<div class="submission__answer__display__content">
{{ student_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and file_url %}
<h3 class="submission__answer__display__title">
{% trans "Your Image" %}
</h3>
<div class="submission__answer__display__image">
<img class="submission--image" alt="{% trans "The image associated with your submission." %}" src="{{ file_url }}"/>
</div>
{% endif %}
</article>
<article class="submission__peer-evaluations step__content__section">
......
......@@ -47,12 +47,28 @@
<article class="peer-assessment" id="peer-assessment--001">
<div class="peer-assessment__display">
<header class="peer-assessment__display__header">
{% blocktrans with review_num=review_num must_grade=must_grade%}<h3 class="peer-assessment__display__title">Assessment # <span class="peer-assessment__number--current">{{ review_num }}</span> of <span class="peer-assessment__number--required">{{ must_grade }}</span></h3>{% endblocktrans %}
{% blocktrans with review_num=review_num must_grade=must_grade%}
<h3 class="peer-assessment__display__title">
Assessment # <span class="peer-assessment__number--current">{{ review_num }}</span> of <span class="peer-assessment__number--required">{{ must_grade }}</span>
</h3>
{% endblocktrans %}
</header>
<div class="peer-assessment__display__response">
{{ peer_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and peer_file_url %}
<header class="peer-assessment__display__header">
<h3 class="peer-assessment__display__title">
{% trans "Associated Image" %}
</h3>
</header>
<div class="peer-assessment__display__image">
<img class="submission--image" alt="{% trans "The image associated with your peer's submission." %}" src="{{ peer_file_url }}"/>
</div>
{% endif %}
</div>
<form id="peer-assessment--001__assessment" class="peer-assessment__assessment" method="post">
......
......@@ -45,6 +45,18 @@
<div class="peer-assessment__display__response">
{{ peer_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and peer_file_url %}
<header class="peer-assessment__display__header">
<h3 class="peer-assessment__display__title">
{% trans "Associated Image" %}
</h3>
</header>
<div class="peer-assessment__display__image">
<img class="submission--image" alt="{% trans "The image associated with your peer's submission." %}" src="{{ peer_file_url }}"/>
</div>
{% endif %}
</div>
<form id="peer-assessment--001__assessment" class="peer-assessment__assessment" method="post">
......
......@@ -59,6 +59,29 @@
>{{ saved_response }}</textarea>
<span class="tip">{% trans "You may continue to work on your response until you submit it." %}</span>
</li>
{% if allow_file_upload %}
<li class="field">
<div id="upload__error">
<div class="message message--inline message--error message--error-server">
<h3 class="message__title">{% trans "We could not upload this image" %}</h3>
<div class="message__content"></div>
</div>
</div>
<label class="sr" for="submission__answer__upload">{% trans "Select an image to upload for this submission." %}</label>
<input type="file" id="submission__answer__upload" class="file--upload">
<button type="submit" id="file__upload" class="action action--upload is--disabled">{% trans "Upload your image" %}</button>
</li>
<li>
<div class="submission__answer__display__image">
<img id="submission__answer__image"
class="submission--image"
{% if file_url %}
alt="{% trans "The image associated with your submission." %}"
{% endif %}
src="{{ file_url }}"/>
</div>
</li>
{% endif %}
</ol>
<div class="response__submission__actions">
......
......@@ -25,6 +25,14 @@
<div class="submission__answer__display__content">
{{ student_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and file_url %}
<h3 class="submission__answer__display__title">{% trans "Your Image" %}</h3>
<div class="submission__answer__display__image">
<img class="submission--image" alt="{% trans "The image associated with your submission." %}" src="{{ file_url }}"/>
</div>
{% endif %}
</article>
</div>
</div>
......
......@@ -31,6 +31,14 @@
<div class="submission__answer__display__content">
{{ student_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and file_url %}
<h3 class="submission__answer__display__title">{% trans "Your Image" %}</h3>
<div class="submission__answer__display__image">
<img class="submission--image" alt="{% trans "The image associated with your submission." %}" src="{{ file_url }}"/>
</div>
{% endif %}
</article>
</div>
</div>
......
......@@ -44,6 +44,18 @@
<div class="self-assessment__display__response">
{{ self_submission.answer.text|linebreaks }}
</div>
{% if allow_file_upload and self_file_url %}
<header class="self-assessment__display__header">
<h3 class="self-assessment__display__title">
{% trans "Associated Image" %}
</h3>
</header>
<div class="self-assessment__display__image">
<img class="submission--image" alt="{% trans "The image associated with your submission." %}" src="{{ self_file_url }}"/>
</div>
{% endif %}
</article>
<form id="self-assessment--001__assessment" class="self-assessment__assessment" method="post">
......
......@@ -129,6 +129,8 @@ class GradeMixin(object):
'example_based_assessment': example_based_assessment,
'rubric_criteria': self._rubric_criteria_with_feedback(peer_assessments),
'has_submitted_feedback': has_submitted_feedback,
'allow_file_upload': self.allow_file_upload,
'file_url': self.get_download_url_from_submission(student_submission)
}
# Update the scores we will display to the user
......
......@@ -25,7 +25,6 @@ from openassessment.xblock.studio_mixin import StudioMixin
from openassessment.xblock.xml import update_from_xml, serialize_content_to_xml
from openassessment.xblock.staff_info_mixin import StaffInfoMixin
from openassessment.xblock.workflow_mixin import WorkflowMixin
from openassessment.workflow import api as workflow_api
from openassessment.workflow.errors import AssessmentWorkflowError
from openassessment.xblock.student_training_mixin import StudentTrainingMixin
from openassessment.xblock.validation import validator
......@@ -107,6 +106,12 @@ class OpenAssessmentBlock(
help="ISO-8601 formatted string representing the submission due date."
)
allow_file_upload = Boolean(
default=False,
scope=Scope.content,
help="File upload allowed with submission."
)
title = String(
default="",
scope=Scope.content,
......
......@@ -8,8 +8,9 @@ from openassessment.assessment.api import peer as peer_api
from openassessment.assessment.errors import (
PeerAssessmentRequestError, PeerAssessmentInternalError, PeerAssessmentWorkflowError
)
import openassessment.workflow.api as workflow_api
from openassessment.workflow.errors import AssessmentWorkflowError
from openassessment.fileupload import api as file_upload_api
from openassessment.fileupload.api import FileUploadError
from .resolve_dates import DISTANT_FUTURE
......@@ -205,6 +206,10 @@ class PeerAssessmentMixin(object):
if peer_sub:
path = 'openassessmentblock/peer/oa_peer_turbo_mode.html'
context_dict["peer_submission"] = peer_sub
# Determine if file upload is supported for this XBlock.
context_dict["allow_file_upload"] = self.allow_file_upload
context_dict["peer_file_url"] = self.get_download_url_from_submission(peer_sub)
else:
path = 'openassessmentblock/peer/oa_peer_turbo_mode_waiting.html'
elif reason == 'due' and problem_closed:
......@@ -217,6 +222,9 @@ class PeerAssessmentMixin(object):
if peer_sub:
path = 'openassessmentblock/peer/oa_peer_assessment.html'
context_dict["peer_submission"] = peer_sub
# Determine if file upload is supported for this XBlock.
context_dict["allow_file_upload"] = self.allow_file_upload
context_dict["peer_file_url"] = self.get_download_url_from_submission(peer_sub)
# Sets the XBlock boolean to signal to Message that it WAS NOT able to grab a submission
self.no_peers = False
else:
......
......@@ -84,6 +84,11 @@ class SelfAssessmentMixin(object):
context["rubric_criteria"] = self.rubric_criteria
context["estimated_time"] = "20 minutes" # TODO: Need to configure this.
context["self_submission"] = submission
# Determine if file upload is supported for this XBlock.
context["allow_file_upload"] = self.allow_file_upload
context['self_file_url'] = self.get_download_url_from_submission(submission)
path = 'openassessmentblock/self/oa_self_assessment.html'
else:
# No submission yet or in peer assessment
......
......@@ -23,6 +23,15 @@ describe("OpenAssessment.ResponseView", function() {
this.render = function(step) {
return successPromise;
};
this.getUploadUrl = function(contentType) {
return successPromise;
};
this.getDownloadUrl = function() {
return successPromise;
}
};
// Stub base view
......@@ -363,4 +372,28 @@ describe("OpenAssessment.ResponseView", function() {
// Since we haven't made any changes, the response should still be unsaved.
expect(view.saveStatus()).toContain('not been saved');
});
it("selects too large of a file", function() {
spyOn(baseView, 'toggleActionError').andCallThrough();
var files = [{type: 'image/jpg', size: 6000000, name: 'huge-picture.jpg', data: ''}];
view.prepareUpload(files);
expect(baseView.toggleActionError).toHaveBeenCalled();
});
it("selects the wrong file type", function() {
spyOn(baseView, 'toggleActionError').andCallThrough();
var files = [{type: 'bogus/jpg', size: 1024, name: 'picture.exe', data: ''}];
view.prepareUpload(files);
expect(baseView.toggleActionError).toHaveBeenCalled();
});
it("requests a file upload", function() {
spyOn(baseView, 'toggleActionError').andCallThrough();
spyOn(server, 'getUploadUrl').andCallThrough();
var files = [{type: 'image/jpg', size: 1024, name: 'picture.jpg', data: ''}];
view.prepareUpload(files);
view.fileUpload();
expect(server.getUploadUrl).toHaveBeenCalled();
expect(baseView.toggleActionError).toHaveBeenCalled();
});
});
......@@ -115,6 +115,9 @@ OpenAssessment.BaseView.prototype = {
else if (type == 'feedback_assess') {
container = '.submission__feedback__actions';
}
else if (type == 'upload') {
container = '#upload__error';
}
// If we don't have anywhere to put the message, just log it to the console
if (container === null) {
......@@ -125,7 +128,6 @@ OpenAssessment.BaseView.prototype = {
// Insert the error message
var msgHtml = (msg === null) ? "" : msg;
$(container + " .message__content", element).html('<p>' + msgHtml + '</p>');
// Toggle the error class
$(container, element).toggleClass('has--error', msg !== null);
}
......
......@@ -14,6 +14,8 @@ OpenAssessment.ResponseView = function(element, server, baseView) {
this.server = server;
this.baseView = baseView;
this.savedResponse = "";
this.files = null;
this.imageType = null;
this.lastChangeTime = Date.now();
this.errorOnLastSave = false;
this.autoSaveTimerId = null;
......@@ -29,6 +31,9 @@ OpenAssessment.ResponseView.prototype = {
// before we can autosave.
AUTO_SAVE_WAIT: 30000,
// Maximum file size (5 MB) for an attached file.
MAX_FILE_SIZE: 5242880,
/**
Load the response (submission) view.
**/
......@@ -61,6 +66,9 @@ OpenAssessment.ResponseView.prototype = {
var handleChange = function(eventData) { view.handleResponseChanged(); };
sel.find('#submission__answer__value').on('change keyup drop paste', handleChange);
var handlePrepareUpload = function(eventData) { view.prepareUpload(eventData.target.files); };
sel.find('input[type=file]').on('change', handlePrepareUpload);
// Install a click handler for submission
sel.find('#step--response__submit').click(
function(eventObject) {
......@@ -78,6 +86,15 @@ OpenAssessment.ResponseView.prototype = {
view.save();
}
);
// Install a click handler for the save button
sel.find('#file__upload').click(
function(eventObject) {
// Override default form submission
eventObject.preventDefault();
view.fileUpload();
}
);
},
/**
......@@ -225,7 +242,6 @@ OpenAssessment.ResponseView.prototype = {
}
},
/**
Check whether the response text has changed since the last save.
......@@ -393,5 +409,89 @@ OpenAssessment.ResponseView.prototype = {
if (confirm(msg)) { defer.resolve(); }
else { defer.reject(); }
});
},
/**
When selecting a file for upload, do some quick client-side validation
to ensure that it is an image, and is not larger than the maximum file
size.
Args:
files (list): A collection of files used for upload. This function assumes
there is only one file being uploaded at any time. This file must
be less than 5 MB and an image.
**/
prepareUpload: function(files) {
this.files = null;
this.imageType = files[0].type;
if (files[0].size > this.MAX_FILE_SIZE) {
this.baseView.toggleActionError(
'upload', gettext("File size must be 5MB or less.")
);
} else if (this.imageType.substring(0,6) != 'image/') {
this.baseView.toggleActionError(
'upload', gettext("File must be an image.")
);
} else {
this.baseView.toggleActionError('upload', null);
this.files = files;
}
$("#file__upload").toggleClass("is--disabled", this.files == null);
},
/**
Manages file uploads for submission attachments. Retrieves a one-time
upload URL from the server, and uses it to upload images to a designated
location.
**/
fileUpload: function() {
var view = this;
var fileUpload = $("#file__upload");
fileUpload.addClass("is--disabled");
var errorMsg = null;
// Call getUploadUrl to get the one-time upload URL for this file. Once
// completed, execute a sequential AJAX call to upload to the returned
// URL. This request requires appropriate CORS configuration for AJAX
// PUT requests on the server.
this.server.getUploadUrl(view.imageType).done(function(url) {
var image = view.files[0];
$.ajax({
url: url,
type: 'PUT',
data: image,
async: false,
processData: false,
contentType: view.imageType,
success: function(data, textStatus, jqXHR) {
view.imageUrl();
},
error: function(jqXHR, textStatus, errorThrown) {
errorMsg = textStatus;
}
});
}).fail(function(errMsg) {
errorMsg = errMsg;
});
if (errorMsg != null) {
view.baseView.toggleActionError('upload', errorMsg);
fileUpload.removeClass("is--disabled");
}
},
/**
Set the image URL, or retrieve it.
**/
imageUrl: function() {
var view = this;
var image = $('#submission__answer__image', view.element);
view.server.getDownloadUrl().done(function(url) {
image.attr('src', url);
return url;
});
}
};
......@@ -479,5 +479,53 @@ OpenAssessment.Server.prototype = {
defer.rejectWith(this, [gettext("The server could not be contacted.")]);
});
}).promise();
},
/**
Get an upload url used to asynchronously post related files for the
submission.
Args:
contentType (str): The Content Type for the file being uploaded.
Returns:
A presigned upload URL from the specified service used for uploading
files.
**/
getUploadUrl: function(contentType) {
var url = this.url('upload_url');
return $.Deferred(function(defer) {
$.ajax({
type: "POST", url: url, data: JSON.stringify({contentType: contentType})
}).done(function(data) {
if (data.success) { defer.resolve(data.url); }
else { defer.rejectWith(this, [data.msg]); }
}).fail(function(data) {
defer.rejectWith(this, [gettext('Could not retrieve upload url.')]);
});
}).promise();
},
/**
Get a download url used to download related files for the submission.
Returns:
A temporary download URL for retrieving documents from s3.
**/
getDownloadUrl: function() {
var url = this.url('download_url');
return $.Deferred(function(defer) {
$.ajax({
type: "POST", url: url, data: JSON.stringify({})
}).done(function(data) {
if (data.success) { defer.resolve(data.url); }
else { defer.rejectWith(this, [data.msg]); }
}).fail(function(data) {
defer.rejectWith(this, [gettext('Could not retrieve download url.')]);
});
}).promise();
}
};
......@@ -172,8 +172,32 @@
}
}
}
}
//---------------------------
// Developer Styles for Image Submission
//---------------------------
.step--response {
.action--upload {
@extend %btn--secondary;
@extend %action-2;
display: block;
text-align: center;
margin-bottom: ($baseline-v/2);
}
.file--upload {
margin-top: $baseline-v/2;
margin-bottom: $baseline-v/2;
}
}
.submission--image {
max-height: 600px;
max-width: $max-width/2;
margin-bottom: $baseline-v;
}
// Developer SASS for Continued Grading.
......
<openassessment submission_due="2015-03-11T18:20">
<openassessment allow_file_upload="True" submission_due="2015-03-11T18:20">
<title>
Global Poverty
</title>
......
import dateutil
import logging
from django.utils.translation import ugettext as _
from xblock.core import XBlock
from submissions import api
from openassessment.workflow import api as workflow_api
from openassessment.fileupload import api as file_upload_api
from openassessment.fileupload.api import FileUploadError
from openassessment.workflow.errors import AssessmentWorkflowError
from .resolve_dates import DISTANT_FUTURE
......@@ -33,6 +33,7 @@ class SubmissionMixin(object):
'EUNKNOWN': _(u'API returned unclassified exception.'),
'ENOMULTI': _(u'Multiple submissions are not allowed.'),
'ENOPREVIEW': _(u'To submit a response, view this component in Preview or Live mode.'),
'EBADARGS': _(u'"submission" required to submit answer.')
}
@XBlock.json_handler
......@@ -44,9 +45,10 @@ class SubmissionMixin(object):
response at this time.
Args:
data (dict): Data should contain one attribute: submission. This is
the response from the student which should be stored in the
Open Assessment system.
data (dict): Data may contain two attributes: submission and
file_url. submission is the response from the student which
should be stored in the Open Assessment system. file_url is the
path to a related file for the submission. file_url is optional.
suffix (str): Not used in this handler.
Returns:
......@@ -54,6 +56,9 @@ class SubmissionMixin(object):
associated status tag (str), and status text (unicode).
"""
if 'submission' not in data:
return False, 'EBADARGS', self.submit_errors['EBADARGS']
status = False
status_text = None
student_sub = data['submission']
......@@ -70,7 +75,10 @@ class SubmissionMixin(object):
if not workflow:
status_tag = 'ENODATA'
try:
submission = self.create_submission(student_item_dict, student_sub)
submission = self.create_submission(
student_item_dict,
student_sub
)
except api.SubmissionRequestError as err:
status_tag = 'EBADFORM'
status_text = unicode(err.field_errors)
......@@ -94,7 +102,9 @@ class SubmissionMixin(object):
Args:
data (dict): Data should have a single key 'submission' that contains
the text of the student's response.
the text of the student's response. Optionally, the data could
have a 'file_url' key that is the path to an associated file for
this submission.
suffix (str): Not used.
Returns:
......@@ -124,6 +134,8 @@ class SubmissionMixin(object):
# so that later we can add additional response fields.
student_sub_dict = {'text': student_sub}
if self.allow_file_upload:
student_sub_dict['file_key'] = self._get_student_item_key()
submission = api.create_submission(student_item_dict, student_sub_dict)
self.create_workflow(submission["uuid"])
self.submission_uuid = submission["uuid"]
......@@ -143,6 +155,90 @@ class SubmissionMixin(object):
return submission
@XBlock.json_handler
def upload_url(self, data, suffix=''):
"""
Request a URL to be used for uploading content related to this
submission.
Returns:
A URL to be used to upload content associated with this submission.
"""
if "contentType" not in data:
return {'success': False, 'msg': _(u"Must specify contentType.")}
content_type = data['contentType']
if not content_type.startswith('image/'):
return {'success': False, 'msg': _(u"contentType must be an image.")}
try:
key = self._get_student_item_key()
url = file_upload_api.get_upload_url(key, content_type)
return {'success': True, 'url': url}
except FileUploadError:
logger.exception("Error retrieving upload URL.")
return {'success': False, 'msg': _(u"Error retrieving upload URL.")}
@XBlock.json_handler
def download_url(self, data, suffix=''):
"""
Request a download URL.
Returns:
A URL to be used for downloading content related to the submission.
"""
return {'success': True, 'url': self._get_download_url()}
def _get_download_url(self):
"""
Internal function for retrieving the download url.
"""
try:
return file_upload_api.get_download_url(self._get_student_item_key())
except FileUploadError:
logger.exception("Error retrieving download URL.")
return ''
def _get_student_item_key(self):
"""
Simple utility method to generate a common file upload key based on
the student item.
Returns:
A string representation of the key.
"""
return u"{student_id}/{course_id}/{item_id}".format(
**self.get_student_item_dict()
)
def get_download_url_from_submission(self, submission):
"""
Returns a download URL for retrieving content within a submission.
Args:
submission (dict): Dictionary containing an answer and a file_key.
The file_key is used to try and retrieve a download url
with related content
Returns:
A URL to related content. If there is no content related to this
key, or if there is no key for the submission, returns an empty
string.
"""
url = ""
key = submission['answer'].get('file_key', '')
try:
if key:
url = file_upload_api.get_download_url(key)
except FileUploadError:
logger.exception("Unable to generate download url for file key {}".format(key))
return url
@staticmethod
def get_user_submission(submission_uuid):
"""Return the most recent submission by user in workflow
......@@ -220,6 +316,10 @@ class SubmissionMixin(object):
if due_date < DISTANT_FUTURE:
context["submission_due"] = due_date
context['allow_file_upload'] = self.allow_file_upload
if self.allow_file_upload:
context['file_url'] = self._get_download_url()
if not workflow and problem_closed:
if reason == 'due':
path = 'openassessmentblock/response/oa_response_closed.html'
......
......@@ -7,6 +7,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -71,6 +72,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -134,6 +136,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -198,6 +201,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -256,6 +260,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -320,6 +325,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -383,6 +389,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -441,6 +448,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 2,
......@@ -517,6 +525,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -583,6 +592,7 @@
"due": "2030-05-01T00:00:00",
"submission_start": null,
"submission_due": "2020-04-15T00:00:00",
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -649,6 +659,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -779,6 +790,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -836,6 +848,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -938,6 +951,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -1058,6 +1072,7 @@
"due": null,
"submission_start": null,
"submission_due": null,
"allow_file_upload": null,
"criteria": [
{
"order_num": 0,
......@@ -1146,5 +1161,71 @@
"</rubric>",
"</openassessment>"
]
},
"allow_file_upload": {
"title": "Foo",
"prompt": "Test prompt",
"rubric_feedback_prompt": "Test Feedback Prompt",
"allow_file_upload": true,
"start": null,
"due": null,
"submission_start": null,
"submission_due": null,
"criteria": [
{
"order_num": 0,
"name": "Test criterion",
"prompt": "Test criterion prompt",
"options": [
{
"order_num": 0,
"points": 0,
"name": "No",
"explanation": "No explanation"
},
{
"order_num": 1,
"points": 2,
"name": "Yes",
"explanation": "Yes explanation"
}
]
}
],
"assessments": [
{
"name": "peer-assessment",
"start": "2014-02-27T09:46:28",
"due": "2014-03-01T00:00:00",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"start": "2014-04-01T00:00:00",
"due": "2014-06-01T00:00:00"
}
],
"expected_xml": [
"<openassessment allow_file_upload=\"True\">",
"<title>Foo</title>",
"<assessments>",
"<assessment name=\"peer-assessment\" start=\"2014-02-27T09:46:28\" due=\"2014-03-01T00:00:00\" must_grade=\"5\" must_be_graded_by=\"3\" />",
"<assessment name=\"self-assessment\" start=\"2014-04-01T00:00:00\" due=\"2014-06-01T00:00:00\" />",
"</assessments>",
"<rubric>",
"<prompt>Test prompt</prompt>",
"<criterion>",
"<name>Test criterion</name>",
"<prompt>Test criterion prompt</prompt>",
"<option points=\"0\"><name>No</name><explanation>No explanation</explanation></option>",
"<option points=\"2\"><name>Yes</name><explanation>Yes explanation</explanation></option>",
"</criterion>",
"<feedbackprompt>Test Feedback Prompt</feedbackprompt>",
"</rubric>",
"</openassessment>"
]
}
}
......@@ -1042,5 +1042,69 @@
]
}
]
},
"file_upload": {
"xml": [
"<openassessment allow_file_upload=\"True\">",
"<title>Foo</title>",
"<assessments>",
"<assessment name=\"peer-assessment\" start=\"2014-02-27T09:46:28\" due=\"2014-03-01T00:00:00\" must_grade=\"5\" must_be_graded_by=\"3\" />",
"<assessment name=\"self-assessment\" start=\"2014-04-01T00:00:00\" due=\"2014-06-01T00:00:00\" />",
"</assessments>",
"<rubric>",
"<prompt>Test prompt</prompt>",
"<criterion>",
"<name>Test criterion</name>",
"<prompt>Test criterion prompt</prompt>",
"<option points=\"0\"><name>No</name><explanation>No explanation</explanation></option>",
"<option points=\"2\"><name>Yes</name><explanation>Yes explanation</explanation></option>",
"</criterion>",
"</rubric>",
"</openassessment>"
],
"title": "Foo",
"prompt": "Test prompt",
"start": "2000-01-01T00:00:00",
"due": "3000-01-01T00:00:00",
"submission_start": null,
"submission_due": null,
"allow_file_upload": true,
"criteria": [
{
"order_num": 0,
"name": "Test criterion",
"prompt": "Test criterion prompt",
"feedback": "disabled",
"options": [
{
"order_num": 0,
"points": 0,
"name": "No",
"explanation": "No explanation"
},
{
"order_num": 1,
"points": 2,
"name": "Yes",
"explanation": "Yes explanation"
}
]
}
],
"assessments": [
{
"name": "peer-assessment",
"start": "2014-02-27T09:46:28",
"due": "2014-03-01T00:00:00",
"must_grade": 5,
"must_be_graded_by": 3
},
{
"name": "self-assessment",
"start": "2014-04-01T00:00:00",
"due": "2014-06-01T00:00:00"
}
]
}
}
......@@ -321,6 +321,8 @@ class TestPeerAssessmentRender(XBlockHandlerTestCase):
'must_grade': 5,
'review_num': 1,
'peer_submission': submission,
'allow_file_upload': False,
'peer_file_url': '',
'submit_button_text': 'submit your assessment & move to response #2',
}
self._assert_path_and_context(
......@@ -456,6 +458,8 @@ class TestPeerAssessmentRender(XBlockHandlerTestCase):
'must_grade': 5,
'peer_due': dt.datetime(2000, 1, 1).replace(tzinfo=pytz.utc),
'peer_submission': submission,
'allow_file_upload': False,
'peer_file_url': '',
'review_num': 1,
'rubric_criteria': xblock.rubric_criteria,
'submit_button_text': 'Submit your assessment & review another response',
......
......@@ -248,7 +248,9 @@ class TestSelfAssessmentRender(XBlockHandlerTestCase):
{
'rubric_criteria': xblock.rubric_criteria,
'estimated_time': '20 minutes',
'self_submission': submission
'self_submission': submission,
'allow_file_upload': False,
'self_file_url': '',
},
workflow_status='self',
submission_uuid=submission['uuid']
......
......@@ -87,7 +87,10 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
def test_unavailable(self, xblock):
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response_unavailable.html',
{'submission_start': dt.datetime(4999, 4, 1).replace(tzinfo=pytz.utc)}
{
'allow_file_upload': False,
'submission_start': dt.datetime(4999, 4, 1).replace(tzinfo=pytz.utc)
}
)
@scenario('data/submission_unavailable.xml', user_id="Bob")
......@@ -103,7 +106,10 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
)
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response_submitted.html',
{'student_submission': submission}
{
'student_submission': submission,
'allow_file_upload': False,
}
)
@scenario('data/submission_open.xml', user_id="Bob")
......@@ -111,6 +117,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response.html',
{
'allow_file_upload': False,
'saved_response': '',
'save_status': 'This response has not been saved.',
'submit_enabled': False,
......@@ -123,6 +130,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response.html',
{
'allow_file_upload': False,
'saved_response': '',
'save_status': 'This response has not been saved.',
'submit_enabled': False,
......@@ -139,6 +147,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response.html',
{
'allow_file_upload': False,
'saved_response': 'A man must have a code',
'save_status': 'This response has been saved but not submitted.',
'submit_enabled': True,
......@@ -157,6 +166,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
{
'submission_due': dt.datetime(2999, 5, 6).replace(tzinfo=pytz.utc),
'student_submission': submission,
'allow_file_upload': False,
}
)
......@@ -165,6 +175,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
self._assert_path_and_context(
xblock, 'openassessmentblock/response/oa_response_closed.html',
{
'allow_file_upload': False,
'submission_due': dt.datetime(2014, 4, 5).replace(tzinfo=pytz.utc),
}
)
......@@ -180,6 +191,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
{
'submission_due': dt.datetime(2014, 4, 5).replace(tzinfo=pytz.utc),
'student_submission': submission,
'allow_file_upload': False,
}
)
......@@ -202,6 +214,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
{
'submission_due': dt.datetime(2999, 5, 6).replace(tzinfo=pytz.utc),
'student_submission': submission,
'allow_file_upload': False,
}
)
......@@ -224,6 +237,7 @@ class SubmissionRenderTest(XBlockHandlerTestCase):
{
'submission_due': dt.datetime(2014, 4, 5).replace(tzinfo=pytz.utc),
'student_submission': submission,
'allow_file_upload': False,
}
)
......
......@@ -102,6 +102,7 @@ class TestSerializeContent(TestCase):
self.oa_block.submission_due = data['submission_due']
self.oa_block.rubric_criteria = data['criteria']
self.oa_block.rubric_assessments = data['assessments']
self.oa_block.allow_file_upload = data['allow_file_upload']
xml = serialize_content(self.oa_block)
# Compare the XML with our expected output
......@@ -196,6 +197,7 @@ class TestSerializeContent(TestCase):
self.oa_block.due = None
self.oa_block.submission_start = None
self.oa_block.submission_due = None
self.oa_block.allow_file_upload = None
for mutated_value in [0, u"\u9282", None]:
setattr(self.oa_block, field, mutated_value)
......
......@@ -156,6 +156,7 @@ def _serialize_rubric(rubric_root, oa_block):
feedback_prompt = etree.SubElement(rubric_root, 'feedbackprompt')
feedback_prompt.text = unicode(oa_block.rubric_feedback_prompt)
def _parse_date(date_str):
"""
Attempt to parse a date string into ISO format (without milliseconds)
......@@ -177,6 +178,21 @@ def _parse_date(date_str):
return None
def _parse_boolean(boolean_str):
"""
Attempt to parse a boolean string into a boolean value. Leniently accepts
both 'True' and 'true', but is otherwise declared false.
Args:
boolean_str (unicode): The boolean string to parse.
Returns:
The boolean value of the string. True if the string equals 'True' or
'true'
"""
return boolean_str in ['True', 'true']
def _parse_options_xml(options_root):
"""
Parse <options> element in the OpenAssessment XBlock's content XML.
......@@ -501,6 +517,9 @@ def serialize_content_to_xml(oa_block, root):
if oa_block.submission_due is not None:
root.set('submission_due', unicode(oa_block.submission_due))
if oa_block.allow_file_upload is not None:
root.set('allow_file_upload', unicode(oa_block.allow_file_upload))
# Open assessment displayed title
title = etree.SubElement(root, 'title')
title.text = unicode(oa_block.title)
......@@ -607,6 +626,10 @@ def update_from_xml(oa_block, root, validator=DEFAULT_VALIDATOR):
if submission_due is None:
raise UpdateFromXmlError(_('The format for the submission due date is invalid. Make sure the date is formatted as YYYY-MM-DDTHH:MM:SS.'))
allow_file_upload = False
if 'allow_file_upload' in root.attrib:
allow_file_upload = _parse_boolean(unicode(root.attrib['allow_file_upload']))
# Retrieve the title
title_el = root.find('title')
if title_el is None:
......@@ -642,6 +665,7 @@ def update_from_xml(oa_block, root, validator=DEFAULT_VALIDATOR):
oa_block.rubric_feedback_prompt = rubric['feedbackprompt']
oa_block.submission_start = submission_start
oa_block.submission_due = submission_due
oa_block.allow_file_upload = allow_file_upload
return oa_block
......
......@@ -131,8 +131,10 @@ INSTALLED_APPS = (
# ora2 apps
'submissions',
'openassessment',
'openassessment.fileupload',
'openassessment.workflow',
'openassessment.assessment',
)
# TODO: add config for XBLOCK_WORKBENCH { SCENARIO_CLASSES }
......
......@@ -4,6 +4,7 @@ from setuptools import setup
PACKAGES = [
'openassessment',
'openassessment.assessment',
'openassessment.fileupload',
'openassessment.workflow',
'openassessment.management',
'openassessment.xblock'
......
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