Commit f88acf51 by Stephen Sanchez

Merge pull request #14 from edx/sanchez/first_pass_at_basic_api

My first pass at populating the submission API.
parents 73409417 5d935d88
......@@ -10,8 +10,14 @@ might be tricky with XML import based courses).
"""
def create_evaluation(submission_id, score, rubric):
pass
def get_evaluations(submission_id):
pass
def get_submission_to_evaluate(student_item, scorer_student_id):
pass
......@@ -2,20 +2,277 @@
Public interface for the submissions app.
"""
import copy
import logging
def create_submission(student_item, answer, submitted_at=None):
# score could be an optional param in the future.
from django.db import DatabaseError
from django.utils.encoding import force_unicode
from submissions.serializers import SubmissionSerializer, StudentItemSerializer
from submissions.models import Submission, StudentItem
logger = logging.getLogger(__name__)
class SubmissionError(Exception):
"""An error that occurs during submission actions.
This error is raised when the submission API cannot perform a requested
action.
"""
pass
def get_submissions(student_item, limit=None):
class SubmissionInternalError(SubmissionError):
"""An error internal to the Submission API has occurred.
This error is raised when an error occurs that is not caused by incorrect
use of the API, but rather internal implementation of the underlying
services.
"""
pass
class SubmissionNotFoundError(SubmissionError):
"""This error is raised when no submission is found for the request.
If a state is specified in a call to the API that results in no matching
Submissions, this error may be raised.
"""
pass
class SubmissionRequestError(SubmissionError):
"""This error is raised when there was a request-specific error
This error is reserved for problems specific to the use of the API.
"""
def __init__(self, field_errors):
Exception.__init__(self, repr(field_errors))
self.field_errors = copy.deepcopy(field_errors)
def create_submission(student_item_dict, answer, submitted_at=None,
attempt_number=None):
"""Creates a submission for evaluation.
Generic means by which to submit an answer for evaluation.
Args:
student_item_dict (dict): The student_item this
submission is associated with. This is used to determine which
course, student, and location this submission belongs to.
answer (str): The answer given by the student to be evaluated.
submitted_at (datetime): The date in which this submission was submitted.
If not specified, defaults to the current date.
attempt_number (int): A student may be able to submit multiple attempts
per question. This allows the designated attempt to be overridden.
If the attempt is not specified, it will take the most recent
submission, as specified by the submitted_at time, and use its
attempt_number plus one.
Returns:
dict: A representation of the created Submission. The submission
contains five attributes: student_item, attempt_number, submitted_at,
created_at, and answer. 'student_item' is the ID of the related student
item for the submission. 'attempt_number' is the attempt this submission
represents for this question. 'submitted_at' represents the time this
submission was submitted, which can be configured, versus the
'created_at' date, which is when the submission is first created.
Raises:
SubmissionRequestError: Raised when there are validation errors for the
student item or submission. This can be caused by the student item
missing required values, the submission being too long, the
attempt_number is negative, or the given submitted_at time is invalid.
SubmissionInternalError: Raised when submission access causes an
internal error.
Examples:
>>> student_item_dict = dict(
>>> student_id="Tim",
>>> item_id="item_1",
>>> course_id="course_1",
>>> item_type="type_one"
>>> )
>>> create_submission(student_item_dict, "The answer is 42.", datetime.utcnow, 1)
{
'student_item': 2,
'attempt_number': 1,
'submitted_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 649284 tzinfo=<UTC>),
'created_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 668850, tzinfo=<UTC>),
'answer': u'The answer is 42.'
}
"""
student_item_model = _get_or_create_student_item(student_item_dict)
if attempt_number is None:
try:
submissions = Submission.objects.filter(
student_item=student_item_model)[:1]
except DatabaseError:
error_message = u"An error occurred while filtering submissions for student item: {}".format(
student_item_dict)
logger.exception(error_message)
raise SubmissionInternalError(error_message)
attempt_number = submissions[0].attempt_number + 1 if submissions else 1
try:
answer = force_unicode(answer)
except UnicodeDecodeError:
raise SubmissionRequestError(
u"Submission answer could not be properly decoded to unicode.")
model_kwargs = {
"student_item": student_item_model,
"answer": answer,
"attempt_number": attempt_number,
}
if submitted_at:
model_kwargs["submitted_at"] = submitted_at
try:
# Serializer validation requires the student item primary key, rather
# than the student item model itself. Create a copy of the submission
# kwargs and replace the student item model with it's primary key.
validation_data = model_kwargs.copy()
validation_data["student_item"] = student_item_model.pk
submission_serializer = SubmissionSerializer(data=validation_data)
if not submission_serializer.is_valid():
raise SubmissionRequestError(submission_serializer.errors)
submission_serializer.save()
return submission_serializer.data
except DatabaseError:
error_message = u"An error occurred while creating submission {} for student item: {}".format(
model_kwargs,
student_item_dict
)
logger.exception(error_message)
raise SubmissionInternalError(error_message)
def get_submissions(student_item_dict, limit=None):
"""Retrieves the submissions for the specified student item,
ordered by most recent submitted date.
Returns the submissions relative to the specified student item. Exception
thrown if no submission is found relative to this location.
Args:
student_item_dict (dict): The location of the problem this submission is
associated with, as defined by a course, student, and item.
limit (int): Optional parameter for limiting the returned number of
submissions associated with this student item. If not specified, all
associated submissions are returned.
Returns:
List dict: A list of dicts for the associated student item. The submission
contains five attributes: student_item, attempt_number, submitted_at,
created_at, and answer. 'student_item' is the ID of the related student
item for the submission. 'attempt_number' is the attempt this submission
represents for this question. 'submitted_at' represents the time this
submission was submitted, which can be configured, versus the
'created_at' date, which is when the submission is first created.
Raises:
SubmissionRequestError: Raised when the associated student item fails
validation.
SubmissionNotFoundError: Raised when a submission cannot be found for
the associated student item.
Examples:
>>> student_item_dict = dict(
>>> student_id="Tim",
>>> item_id="item_1",
>>> course_id="course_1",
>>> item_type="type_one"
>>> )
>>> get_submissions(student_item_dict, 3)
[{
'student_item': 2,
'attempt_number': 1,
'submitted_at': datetime.datetime(2014, 1, 29, 23, 14, 52, 649284, tzinfo=<UTC>),
'created_at': datetime.datetime(2014, 1, 29, 17, 14, 52, 668850, tzinfo=<UTC>),
'answer': u'The answer is 42.'
}]
"""
student_item_model = _get_or_create_student_item(student_item_dict)
try:
submission_models = Submission.objects.filter(
student_item=student_item_model)
except DatabaseError:
error_message = (
u"Error getting submission request for student item {}"
.format(student_item_dict)
)
logger.exception(error_message)
raise SubmissionNotFoundError(error_message)
if limit:
submission_models = submission_models[:limit]
return [SubmissionSerializer(submission).data for submission in
submission_models]
def get_score(student_item):
pass
def get_scores(course_id, student_id, types=None):
pass
def set_score(student_item):
pass
def _get_or_create_student_item(student_item_dict):
"""Gets or creates a Student Item that matches the values specified.
Attempts to get the specified Student Item. If it does not exist, the
specified parameters are validated, and a new Student Item is created.
Args:
student_item_dict (dict): The dict containing the student_id, item_id,
course_id, and item_type that uniquely defines a student item.
Returns:
StudentItem: The student item that was retrieved or created.
Raises:
SubmissionInternalError: Thrown if there was an internal error while
attempting to create or retrieve the specified student item.
SubmissionRequestError: Thrown if the given student item parameters fail
validation.
Examples:
>>> student_item_dict = dict(
>>> student_id="Tim",
>>> item_id="item_1",
>>> course_id="course_1",
>>> item_type="type_one"
>>> )
>>> _get_or_create_student_item(student_item_dict)
{'item_id': 'item_1', 'item_type': 'type_one', 'course_id': 'course_1', 'student_id': 'Tim'}
"""
try:
try:
return StudentItem.objects.get(**student_item_dict)
except StudentItem.DoesNotExist:
student_item_serializer = StudentItemSerializer(data=student_item_dict)
if not student_item_serializer.is_valid():
raise SubmissionRequestError(student_item_serializer.errors)
return student_item_serializer.save()
except DatabaseError:
error_message = u"An error occurred creating student item: {}".format(
student_item_dict)
logger.exception(error_message)
raise SubmissionInternalError(error_message)
......@@ -6,8 +6,8 @@ Student submission:
* XBlock creates a Submission
* submissions app sends a general notification that a submission has happened
* openresponse can listen for that signal if it wants, or query itself on demand
* when openresponse is satistifed that it has collected enough information to
* openassessment can listen for that signal if it wants, or query itself on demand
* when openassessment is satisfied that it has collected enough information to
score the student, it will push that score information back to this app.
* when the LMS wants to know what raw scores a student has, it calls this app.
......@@ -19,6 +19,7 @@ Things to consider probably aren't worth the extra effort/complexity in the MVP:
from django.db import models
from django.utils.timezone import now
class StudentItem(models.Model):
"""Represents a single item for a single course for a single user.
......@@ -39,13 +40,18 @@ class StudentItem(models.Model):
# What kind of problem is this? The XBlock tag if it's an XBlock
item_type = models.CharField(max_length=100)
def __repr__(self):
return repr(dict(
student_id=self.student_id,
course_id=self.course_id,
item_id=self.item_id,
item_type=self.item_type,
))
class Meta:
unique_together = (
# For integrity reasons, and looking up all of a student's items
("course_id", "student_id", "item_id"),
# Composite index for getting information across a course
("course_id", "item_id"),
)
......@@ -71,6 +77,18 @@ class Submission(models.Model):
# The actual answer, assumed to be a JSON string
answer = models.TextField(blank=True)
def __repr__(self):
return repr(dict(
student_item=self.student_item,
attempt_number=self.attempt_number,
submitted_at=self.submitted_at,
created_at=self.created_at,
answer=self.answer,
))
class Meta:
ordering = ["-submitted_at"]
class Score(models.Model):
"""What the user scored for a given StudentItem.
......@@ -85,3 +103,12 @@ class Score(models.Model):
points_possible = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(editable=False, default=now, db_index=True)
def __repr__(self):
return repr(dict(
student_item=self.student_item,
submission=self.submission,
created_at=self.created_at,
points_earned=self.points_earned,
points_possible=self.points_possible,
))
"""
Serializers are created to ensure models do not have to be accessed outside the scope of the Tim APIs.
"""
from rest_framework import serializers
from submissions.models import StudentItem, Submission, Score
class StudentItemSerializer(serializers.ModelSerializer):
class Meta:
model = StudentItem
fields = ('student_id', 'course_id', 'item_id', 'item_type')
class SubmissionSerializer(serializers.ModelSerializer):
class Meta:
model = Submission
fields = ('student_item', 'attempt_number', 'submitted_at', 'created_at', 'answer')
class ScoreSerializer(serializers.ModelSerializer):
class Meta:
model = Score
fields = ('student_item', 'submission', 'points_earned', 'points_possible', 'created_at')
import datetime
from ddt import ddt, file_data
from django.db import DatabaseError
from django.test import TestCase
from nose.tools import raises
from mock import patch
from submissions.api import create_submission, get_submissions, SubmissionRequestError, SubmissionInternalError
from submissions.models import Submission
from submissions.serializers import StudentItemSerializer
STUDENT_ITEM = dict(
student_id="Tim",
course_id="Demo_Course",
item_id="item_one",
item_type="Peer_Submission",
)
ANSWER_ONE = u"this is my answer!"
ANSWER_TWO = u"this is my other answer!"
@ddt
class TestApi(TestCase):
def test_create_submission(self):
submission = create_submission(STUDENT_ITEM, ANSWER_ONE)
self._assert_submission(submission, ANSWER_ONE, 1, 1)
def test_get_submissions(self):
create_submission(STUDENT_ITEM, ANSWER_ONE)
create_submission(STUDENT_ITEM, ANSWER_TWO)
submissions = get_submissions(STUDENT_ITEM)
self._assert_submission(submissions[1], ANSWER_ONE, 1, 1)
self._assert_submission(submissions[0], ANSWER_TWO, 1, 2)
@file_data('test_valid_student_items.json')
def test_various_student_items(self, valid_student_item):
create_submission(valid_student_item, ANSWER_ONE)
submission = get_submissions(valid_student_item)[0]
self._assert_submission(submission, ANSWER_ONE, 1, 1)
def test_get_latest_submission(self):
past_date = datetime.date(2007, 11, 23)
more_recent_date = datetime.date(2011, 10, 15)
create_submission(STUDENT_ITEM, ANSWER_ONE, more_recent_date)
create_submission(STUDENT_ITEM, ANSWER_TWO, past_date)
# Test a limit on the submissions
submissions = get_submissions(STUDENT_ITEM, 1)
self.assertEqual(1, len(submissions))
self.assertEqual(ANSWER_ONE, submissions[0]["answer"])
self.assertEqual(more_recent_date.year,
submissions[0]["submitted_at"].year)
def test_set_attempt_number(self):
create_submission(STUDENT_ITEM, ANSWER_ONE, None, 2)
submissions = get_submissions(STUDENT_ITEM)
self._assert_submission(submissions[0], ANSWER_ONE, 1, 2)
@raises(SubmissionRequestError)
@file_data('test_bad_student_items.json')
def test_error_checking(self, bad_student_item):
create_submission(bad_student_item, -100)
@raises(SubmissionRequestError)
def test_error_checking_submissions(self):
create_submission(STUDENT_ITEM, ANSWER_ONE, None, -1)
@patch.object(Submission.objects, 'filter')
@raises(SubmissionInternalError)
def test_error_on_submission_creation(self, mock_filter):
mock_filter.side_effect = DatabaseError("Bad things happened")
create_submission(STUDENT_ITEM, ANSWER_ONE)
@patch.object(StudentItemSerializer, 'save')
@raises(SubmissionInternalError)
def test_create_student_item_validation(self, mock_save):
mock_save.side_effect = DatabaseError("Bad things happened")
create_submission(STUDENT_ITEM, ANSWER_ONE)
def test_unicode_enforcement(self):
create_submission(STUDENT_ITEM, "Testing unicode answers.")
submissions = get_submissions(STUDENT_ITEM, 1)
self.assertEqual(u"Testing unicode answers.", submissions[0]["answer"])
def _assert_submission(self, submission, expected_answer, expected_item,
expected_attempt):
self.assertIsNotNone(submission)
self.assertEqual(submission["answer"], expected_answer)
self.assertEqual(submission["student_item"], expected_item)
self.assertEqual(submission["attempt_number"], expected_attempt)
\ No newline at end of file
{
"no_item_type": {
"student_id": "Bad Tim",
"course_id": "451",
"item_id": "2"
},
"no_student_id": {
"course_id": "Course_One",
"item_id": "5",
"item_type": "Peer"
},
"just_student_and_course": {
"student_id": "Tim",
"course_id": "Course_One"
},
"just_student_id": {
"student_id": "Tim"
},
"just_item_id_and_type": {
"item_id": "5",
"item_type": "Peer"
},
"just_course_id": {
"course_id": "Course_One"
},
"just_item_id": {
"item_id": "5"
},
"bad_item_id_empty": {
"student_id": "Tim",
"course_id": "Course_One",
"item_id": "",
"item_type": "Peer"
}
}
\ No newline at end of file
{
"unicode_characters": {
"student_id": "学生",
"course_id": "漢字",
"item_id": "这是中国",
"item_type": "窥视"
},
"basic_student_item": {
"student_id": "Tom",
"course_id": "Demo_Course",
"item_id": "1",
"item_type": "Peer"
}
}
\ No newline at end of file
__author__ = 'stephensanchez'
......@@ -3,3 +3,4 @@
# Third Party Requirements
django==1.4.8
djangorestframework==2.3.5
# Grab everything in base requirements
-r base.txt
ddt==0.4.0
django-nose==1.2
mock==1.0.1
nose==1.3.0
......
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