Commit 14aeaa9e by Will Daly

Merge pull request #20 from edx/will/upgrade-django-rest-framework

Upgrade djangorestframework to v3.1
parents 05f737c2 caded932
...@@ -2,6 +2,7 @@ dogapi==1.2.1 ...@@ -2,6 +2,7 @@ dogapi==1.2.1
django>=1.4,<1.5 django>=1.4,<1.5
django-extensions==1.5.5 django-extensions==1.5.5
django-model-utils==2.3.1 django-model-utils==2.3.1
djangorestframework<2.4 djangorestframework>=3.1,<3.2
jsonfield==1.0.3
pytz==2015.2 pytz==2015.2
South==0.7.6 South==1.0.1
...@@ -46,6 +46,7 @@ INSTALLED_APPS = ( ...@@ -46,6 +46,7 @@ INSTALLED_APPS = (
# Third party # Third party
'django_extensions', 'django_extensions',
'south',
# Test # Test
'django_nose', 'django_nose',
......
...@@ -33,7 +33,7 @@ def load_requirements(*requirements_paths): ...@@ -33,7 +33,7 @@ def load_requirements(*requirements_paths):
setup( setup(
name='edx-submissions', name='edx-submissions',
version='0.0.8', version='0.1.0',
author='edX', author='edX',
description='An API for creating submissions and scores.', description='An API for creating submissions and scores.',
url='http://github.com/edx/edx-submissions.git', url='http://github.com/edx/edx-submissions.git',
......
...@@ -56,7 +56,7 @@ class SubmissionAdmin(admin.ModelAdmin, StudentItemAdminMixin): ...@@ -56,7 +56,7 @@ class SubmissionAdmin(admin.ModelAdmin, StudentItemAdminMixin):
'student_item_id', 'student_item_id',
'course_id', 'item_id', 'student_id', 'course_id', 'item_id', 'student_id',
'attempt_number', 'submitted_at', 'created_at', 'attempt_number', 'submitted_at', 'created_at',
'raw_answer', 'all_scores', 'answer', 'all_scores',
) )
search_fields = ('id', 'uuid') + StudentItemAdminMixin.search_fields search_fields = ('id', 'uuid') + StudentItemAdminMixin.search_fields
...@@ -126,4 +126,4 @@ class ScoreSummaryAdmin(admin.ModelAdmin, StudentItemAdminMixin): ...@@ -126,4 +126,4 @@ class ScoreSummaryAdmin(admin.ModelAdmin, StudentItemAdminMixin):
admin.site.register(Score, ScoreAdmin) admin.site.register(Score, ScoreAdmin)
admin.site.register(StudentItem, StudentItemAdmin) admin.site.register(StudentItem, StudentItemAdmin)
admin.site.register(Submission, SubmissionAdmin) admin.site.register(Submission, SubmissionAdmin)
admin.site.register(ScoreSummary, ScoreSummaryAdmin) admin.site.register(ScoreSummary, ScoreSummaryAdmin)
\ No newline at end of file
...@@ -14,7 +14,7 @@ from django.db import IntegrityError, DatabaseError ...@@ -14,7 +14,7 @@ from django.db import IntegrityError, DatabaseError
from dogapi import dog_stats_api from dogapi import dog_stats_api
from submissions.serializers import ( from submissions.serializers import (
SubmissionSerializer, StudentItemSerializer, ScoreSerializer, JsonFieldError SubmissionSerializer, StudentItemSerializer, ScoreSerializer
) )
from submissions.models import Submission, StudentItem, Score, ScoreSummary, score_set, score_reset from submissions.models import Submission, StudentItem, Score, ScoreSummary, score_set, score_reset
...@@ -177,11 +177,6 @@ def create_submission(student_item_dict, answer, submitted_at=None, attempt_numb ...@@ -177,11 +177,6 @@ def create_submission(student_item_dict, answer, submitted_at=None, attempt_numb
return sub_data return sub_data
except JsonFieldError:
error_message = u"Could not serialize JSON field in submission {} for student item {}".format(
model_kwargs, student_item_dict
)
raise SubmissionRequestError(msg=error_message)
except DatabaseError: except DatabaseError:
error_message = u"An error occurred while creating submission {} for student item: {}".format( error_message = u"An error occurred while creating submission {} for student item: {}".format(
model_kwargs, model_kwargs,
......
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# For the upgrade to Django Rest Framework v3, we replaced the `raw_answer` TextField
# with an `answer` JsonField that maps to the `raw_answer` DB column.
# This doesn't require any changes to the database schema, but we generate a migration
# anyway so that South knows about the change.
pass
def backwards(self, orm):
pass
models = {
'submissions.score': {
'Meta': {'object_name': 'Score'},
'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'points_earned': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
'points_possible': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
'reset': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'student_item': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['submissions.StudentItem']"}),
'submission': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['submissions.Submission']", 'null': 'True'})
},
'submissions.scoresummary': {
'Meta': {'object_name': 'ScoreSummary'},
'highest': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['submissions.Score']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'latest': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['submissions.Score']"}),
'student_item': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['submissions.StudentItem']", 'unique': 'True'})
},
'submissions.studentitem': {
'Meta': {'unique_together': "(('course_id', 'student_id', 'item_id'),)", 'object_name': 'StudentItem'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'item_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'item_type': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
},
'submissions.submission': {
'Meta': {'ordering': "['-submitted_at', '-id']", 'object_name': 'Submission'},
'answer': ('jsonfield.fields.JSONField', [], {'db_column': "'raw_answer'", 'blank': 'True'}),
'attempt_number': ('django.db.models.fields.PositiveIntegerField', [], {}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'student_item': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['submissions.StudentItem']"}),
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'db_index': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '36', 'blank': 'True'})
}
}
complete_apps = ['submissions']
...@@ -9,7 +9,6 @@ need to then generate a matching migration for it using: ...@@ -9,7 +9,6 @@ need to then generate a matching migration for it using:
./manage.py schemamigration submissions --auto ./manage.py schemamigration submissions --auto
""" """
import json
import logging import logging
from django.db import models, DatabaseError from django.db import models, DatabaseError
...@@ -17,22 +16,23 @@ from django.db.models.signals import post_save ...@@ -17,22 +16,23 @@ from django.db.models.signals import post_save
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.utils.timezone import now from django.utils.timezone import now
from django_extensions.db.fields import UUIDField from django_extensions.db.fields import UUIDField
from jsonfield import JSONField
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Signal to inform listeners that a score has been changed # Signal to inform listeners that a score has been changed
score_set = Signal(providing_args=[ score_set = Signal(providing_args=[
'points_possible', 'points_earned', 'anonymous_user_id', 'points_possible', 'points_earned', 'anonymous_user_id',
'course_id', 'item_id' 'course_id', 'item_id'
] ])
)
# Signal to inform listeners that a score has been reset # Signal to inform listeners that a score has been reset
score_reset = Signal( score_reset = Signal(
providing_args=['anonymous_user_id', 'course_id', 'item_id'] providing_args=['anonymous_user_id', 'course_id', 'item_id']
) )
class StudentItem(models.Model): class StudentItem(models.Model):
"""Represents a single item for a single course for a single user. """Represents a single item for a single course for a single user.
...@@ -99,7 +99,12 @@ class Submission(models.Model): ...@@ -99,7 +99,12 @@ class Submission(models.Model):
created_at = models.DateTimeField(editable=False, default=now, db_index=True) created_at = models.DateTimeField(editable=False, default=now, db_index=True)
# The answer (JSON-serialized) # The answer (JSON-serialized)
raw_answer = models.TextField(blank=True) # NOTE: previously, this field was a TextField named `raw_answer`.
# Since JSONField is a subclass of TextField, we can use it as a drop-in
# replacement for TextField that performs JSON serialization/deserialization.
# For backwards compatibility, we override the default database column
# name so it continues to use `raw_answer`.
answer = JSONField(blank=True, db_column="raw_answer")
def __repr__(self): def __repr__(self):
return repr(dict( return repr(dict(
...@@ -108,7 +113,7 @@ class Submission(models.Model): ...@@ -108,7 +113,7 @@ class Submission(models.Model):
attempt_number=self.attempt_number, attempt_number=self.attempt_number,
submitted_at=self.submitted_at, submitted_at=self.submitted_at,
created_at=self.created_at, created_at=self.created_at,
raw_answer=self.raw_answer, answer=self.answer,
)) ))
def __unicode__(self): def __unicode__(self):
......
""" """
Serializers are created to ensure models do not have to be accessed outside the Serializers are created to ensure models do not have to be accessed outside the
scope of the Tim APIs. scope of the submissions API.
""" """
import json import json
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import Field, DateTimeField, IntegerField
from submissions.models import StudentItem, Submission, Score from submissions.models import StudentItem, Submission, Score
class JsonFieldError(Exception): class RawField(Field):
"""
An error occurred while serializing/deserializing JSON.
""" """
pass Serializer field that does NOT modify its value.
This is useful when the Django model field is handling serialization/deserialization.
For example, `JsonField` already converts its value to JSON internally. If we use
the default DRF text field, the value would be converted to a string, which would then
be encoded as JSON:
class JsonField(serializers.WritableField): 1) field value is {"foo": "bar"} (a dict)
""" 2) DRF's default field implementation converts the dict to a string: "{'foo': 'bar'}"
JSON-serializable field. 3) JsonField encodes the string as JSON: '"{\'foo\': \'bar\'}"'
"""
def to_native(self, obj):
"""
Deserialize the JSON string.
Args:
obj (str): The JSON string stored in the database.
Returns: This is a problem, because when we load the data back from the database, we'll end
JSON-serializable up with a string instead of a dictionary!
Raises: """
JsonFieldError: The field could not be deserialized. def to_representation(self, obj):
""" return obj
try:
return json.loads(obj)
except (TypeError, ValueError):
raise JsonFieldError(u"Could not deserialize as JSON: {}".format(obj))
def from_native(self, data):
"""
Serialize an object to JSON.
Args: def to_internal_value(self, data):
data (JSON-serializable): The data to serialize. return data
Returns:
str
Raises:
ValueError: The data could not be serialized as JSON.
"""
try:
return json.dumps(data)
except (TypeError, ValueError):
raise JsonFieldError(u"Could not serialize as JSON: {}".format(data))
class StudentItemSerializer(serializers.ModelSerializer): class StudentItemSerializer(serializers.ModelSerializer):
...@@ -63,14 +41,37 @@ class StudentItemSerializer(serializers.ModelSerializer): ...@@ -63,14 +41,37 @@ class StudentItemSerializer(serializers.ModelSerializer):
class SubmissionSerializer(serializers.ModelSerializer): class SubmissionSerializer(serializers.ModelSerializer):
answer = JsonField(source='raw_answer') # Django Rest Framework v3 uses the Django setting `DATETIME_FORMAT`
# when serializing datetimes. This differs from v2, which always
# returned a datetime. To preserve the old behavior, we explicitly
# set `format` to None.
# http://www.django-rest-framework.org/api-guide/fields/#datetimefield
submitted_at = DateTimeField(format=None, required=False)
created_at = DateTimeField(format=None, required=False)
# Django Rest Framework v3 apparently no longer validates that
# `PositiveIntegerField`s are positive!
attempt_number = IntegerField(min_value=0)
# Prevent Django Rest Framework from converting the answer (dict or str)
# to a string.
answer = RawField()
def validate_answer(self, value):
"""
Check that the answer is JSON-serializable and not too long.
"""
# Check that the answer is JSON-serializable
try:
serialized = json.dumps(value)
except (ValueError, TypeError):
raise serializers.ValidationError("Answer value must be JSON-serializable")
def validate_answer(self, attrs, source): # Check the length of the serialized representation
"""Check that the answer is within an acceptable size range.""" if len(serialized) > Submission.MAXSIZE:
value = attrs[source]
if len(value) > Submission.MAXSIZE:
raise serializers.ValidationError("Maximum answer size exceeded.") raise serializers.ValidationError("Maximum answer size exceeded.")
return attrs
return value
class Meta: class Meta:
model = Submission model = Submission
...@@ -80,15 +81,14 @@ class SubmissionSerializer(serializers.ModelSerializer): ...@@ -80,15 +81,14 @@ class SubmissionSerializer(serializers.ModelSerializer):
'attempt_number', 'attempt_number',
'submitted_at', 'submitted_at',
'created_at', 'created_at',
# Computed
'answer', 'answer',
) )
class ScoreSerializer(serializers.ModelSerializer): class ScoreSerializer(serializers.ModelSerializer):
submission_uuid = serializers.Field(source='submission_uuid') # Ensure that the created_at datetime is not converted to a string.
created_at = DateTimeField(format=None, required=False)
class Meta: class Meta:
model = Score model = Score
...@@ -102,4 +102,3 @@ class ScoreSerializer(serializers.ModelSerializer): ...@@ -102,4 +102,3 @@ class ScoreSerializer(serializers.ModelSerializer):
# Computed # Computed
'submission_uuid', 'submission_uuid',
) )
import datetime import datetime
import copy import copy
from ddt import ddt, file_data import ddt
from django.db import DatabaseError from django.db import DatabaseError, connection, transaction
from django.dispatch import Signal
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from nose.tools import raises from nose.tools import raises
...@@ -11,7 +10,7 @@ from mock import patch ...@@ -11,7 +10,7 @@ from mock import patch
import pytz import pytz
from submissions import api as api from submissions import api as api
from submissions.models import ScoreSummary, Submission, StudentItem, Score, score_set from submissions.models import ScoreSummary, Submission, StudentItem, score_set
from submissions.serializers import StudentItemSerializer from submissions.serializers import StudentItemSerializer
STUDENT_ITEM = dict( STUDENT_ITEM = dict(
...@@ -32,8 +31,11 @@ ANSWER_ONE = u"this is my answer!" ...@@ -32,8 +31,11 @@ ANSWER_ONE = u"this is my answer!"
ANSWER_TWO = u"this is my other answer!" ANSWER_TWO = u"this is my other answer!"
ANSWER_THREE = u'' + 'c' * (Submission.MAXSIZE + 1) ANSWER_THREE = u'' + 'c' * (Submission.MAXSIZE + 1)
# Test a non-string JSON-serializable answer
ANSWER_DICT = {"text": "foobar"}
@ddt
@ddt.ddt
class TestSubmissionsApi(TestCase): class TestSubmissionsApi(TestCase):
""" """
Testing Submissions Testing Submissions
...@@ -45,10 +47,11 @@ class TestSubmissionsApi(TestCase): ...@@ -45,10 +47,11 @@ class TestSubmissionsApi(TestCase):
""" """
cache.clear() cache.clear()
def test_create_submission(self): @ddt.data(ANSWER_ONE, ANSWER_DICT)
submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) def test_create_submission(self, answer):
submission = api.create_submission(STUDENT_ITEM, answer)
student_item = self._get_student_item(STUDENT_ITEM) student_item = self._get_student_item(STUDENT_ITEM)
self._assert_submission(submission, ANSWER_ONE, student_item.pk, 1) self._assert_submission(submission, answer, student_item.pk, 1)
def test_create_huge_submission_fails(self): def test_create_huge_submission_fails(self):
with self.assertRaises(api.SubmissionRequestError): with self.assertRaises(api.SubmissionRequestError):
...@@ -121,7 +124,6 @@ class TestSubmissionsApi(TestCase): ...@@ -121,7 +124,6 @@ class TestSubmissionsApi(TestCase):
mock_get.side_effect = DatabaseError("Kaboom!") mock_get.side_effect = DatabaseError("Kaboom!")
api.get_submission("000000000000000") api.get_submission("000000000000000")
def test_two_students(self): def test_two_students(self):
api.create_submission(STUDENT_ITEM, ANSWER_ONE) api.create_submission(STUDENT_ITEM, ANSWER_ONE)
api.create_submission(SECOND_STUDENT_ITEM, ANSWER_TWO) api.create_submission(SECOND_STUDENT_ITEM, ANSWER_TWO)
...@@ -136,8 +138,7 @@ class TestSubmissionsApi(TestCase): ...@@ -136,8 +138,7 @@ class TestSubmissionsApi(TestCase):
student_item = self._get_student_item(SECOND_STUDENT_ITEM) student_item = self._get_student_item(SECOND_STUDENT_ITEM)
self._assert_submission(submissions[0], ANSWER_TWO, student_item.pk, 1) self._assert_submission(submissions[0], ANSWER_TWO, student_item.pk, 1)
@ddt.file_data('data/valid_student_items.json')
@file_data('data/valid_student_items.json')
def test_various_student_items(self, valid_student_item): def test_various_student_items(self, valid_student_item):
api.create_submission(valid_student_item, ANSWER_ONE) api.create_submission(valid_student_item, ANSWER_ONE)
student_item = self._get_student_item(valid_student_item) student_item = self._get_student_item(valid_student_item)
...@@ -164,12 +165,13 @@ class TestSubmissionsApi(TestCase): ...@@ -164,12 +165,13 @@ class TestSubmissionsApi(TestCase):
self._assert_submission(submissions[0], ANSWER_ONE, student_item.pk, 2) self._assert_submission(submissions[0], ANSWER_ONE, student_item.pk, 2)
@raises(api.SubmissionRequestError) @raises(api.SubmissionRequestError)
@file_data('data/bad_student_items.json') @ddt.file_data('data/bad_student_items.json')
def test_error_checking(self, bad_student_item): def test_error_checking(self, bad_student_item):
api.create_submission(bad_student_item, -100) api.create_submission(bad_student_item, -100)
@raises(api.SubmissionRequestError) @raises(api.SubmissionRequestError)
def test_error_checking_submissions(self): def test_error_checking_submissions(self):
# Attempt number should be >= 0
api.create_submission(STUDENT_ITEM, ANSWER_ONE, None, -1) api.create_submission(STUDENT_ITEM, ANSWER_ONE, None, -1)
@patch.object(Submission.objects, 'filter') @patch.object(Submission.objects, 'filter')
...@@ -183,12 +185,14 @@ class TestSubmissionsApi(TestCase): ...@@ -183,12 +185,14 @@ class TestSubmissionsApi(TestCase):
api.create_submission(STUDENT_ITEM, datetime.datetime.now()) api.create_submission(STUDENT_ITEM, datetime.datetime.now())
def test_load_non_json_answer(self): def test_load_non_json_answer(self):
# This should never happen, if folks are using the public API.
# Create a submission with a raw answer that is NOT valid JSON
submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE)
sub_model = Submission.objects.get(uuid=submission['uuid']) sub_model = Submission.objects.get(uuid=submission['uuid'])
sub_model.raw_answer = ''
sub_model.save() # This should never happen, if folks are using the public API.
# Create a submission with a raw answer that is NOT valid JSON
query = "UPDATE submissions_submission SET raw_answer = '}' WHERE id = %s"
connection.cursor().execute(query, [str(sub_model.id)])
transaction.commit_unless_managed()
with self.assertRaises(api.SubmissionInternalError): with self.assertRaises(api.SubmissionInternalError):
api.get_submission(sub_model.uuid) api.get_submission(sub_model.uuid)
......
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