Commit c16512e4 by Diana Huang

Add the ability to annotate scores.

parent 14aeaa9e
...@@ -3,3 +3,4 @@ Will Daly <will@edx.org> ...@@ -3,3 +3,4 @@ Will Daly <will@edx.org>
David Ormsbee <dave@edx.org> David Ormsbee <dave@edx.org>
Stephen Sanchez <steve@edx.org> Stephen Sanchez <steve@edx.org>
Phil McGachey <phil_mcgachey@harvard.edu> Phil McGachey <phil_mcgachey@harvard.edu>
Diana Huang <dkh@edx.org>
...@@ -10,13 +10,13 @@ import json ...@@ -10,13 +10,13 @@ import json
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import IntegrityError, DatabaseError from django.db import IntegrityError, DatabaseError, transaction
from dogapi import dog_stats_api from dogapi import dog_stats_api
from submissions.serializers import ( from submissions.serializers import (
SubmissionSerializer, StudentItemSerializer, ScoreSerializer SubmissionSerializer, StudentItemSerializer, ScoreSerializer
) )
from submissions.models import Submission, StudentItem, Score, ScoreSummary, score_set, score_reset from submissions.models import Submission, StudentItem, Score, ScoreSummary, ScoreAnnotation, score_set, score_reset
logger = logging.getLogger("submissions.api") logger = logging.getLogger("submissions.api")
...@@ -698,7 +698,8 @@ def reset_score(student_id, course_id, item_id): ...@@ -698,7 +698,8 @@ def reset_score(student_id, course_id, item_id):
logger.info(msg) logger.info(msg)
def set_score(submission_uuid, points_earned, points_possible): def set_score(submission_uuid, points_earned, points_possible,
annotation_creator=None, annotation_type=None, annotation_reason=None):
"""Set a score for a particular submission. """Set a score for a particular submission.
Sets the score for a particular submission. This score is calculated Sets the score for a particular submission. This score is calculated
...@@ -709,6 +710,11 @@ def set_score(submission_uuid, points_earned, points_possible): ...@@ -709,6 +710,11 @@ def set_score(submission_uuid, points_earned, points_possible):
points_earned (int): The earned points for this submission. points_earned (int): The earned points for this submission.
points_possible (int): The total points possible for this particular student item. points_possible (int): The total points possible for this particular student item.
annotation_creator (str): An optional field for recording who gave this particular score
annotation_type (str): An optional field for recording what type of annotation should be created,
e.g. "staff_override".
annotation_reason (str): An optional field for recording why this score was set to its value.
Returns: Returns:
None None
...@@ -761,9 +767,19 @@ def set_score(submission_uuid, points_earned, points_possible): ...@@ -761,9 +767,19 @@ def set_score(submission_uuid, points_earned, points_possible):
# even though we cannot retrieve it. # even though we cannot retrieve it.
# In this case, we assume that someone else has already created # In this case, we assume that someone else has already created
# a score summary and ignore the error. # a score summary and ignore the error.
# TODO: once we're using Django 1.8, use transactions to ensure that these
# two models are saved at the same time.
try: try:
score_model = score.save() score_model = score.save()
_log_score(score_model) _log_score(score_model)
if annotation_creator is not None:
score_annotation = ScoreAnnotation(
score=score_model,
creator=annotation_creator,
annotation_type=annotation_type,
reason=annotation_reason
)
score_annotation.save()
# Send a signal out to any listeners who are waiting for scoring events. # Send a signal out to any listeners who are waiting for scoring events.
score_set.send( score_set.send(
sender=None, sender=None,
......
# -*- 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):
# Adding model 'ScoreAnnotation'
db.create_table('submissions_scoreannotation', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('score', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['submissions.Score'])),
('annotation_type', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('creator', self.gf('submissions.models.AnonymizedUserIDField')(max_length=255, db_index=True)),
('reason', self.gf('django.db.models.fields.TextField')()),
))
db.send_create_signal('submissions', ['ScoreAnnotation'])
# Changing field 'StudentItem.student_id'
db.alter_column('submissions_studentitem', 'student_id', self.gf('submissions.models.AnonymizedUserIDField')(max_length=255))
def backwards(self, orm):
# Deleting model 'ScoreAnnotation'
db.delete_table('submissions_scoreannotation')
# Changing field 'StudentItem.student_id'
db.alter_column('submissions_studentitem', 'student_id', self.gf('django.db.models.fields.CharField')(max_length=255))
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.scoreannotation': {
'Meta': {'object_name': 'ScoreAnnotation'},
'annotation_type': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'creator': ('submissions.models.AnonymizedUserIDField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'reason': ('django.db.models.fields.TextField', [], {}),
'score': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['submissions.Score']"})
},
'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': ('submissions.models.AnonymizedUserIDField', [], {'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']
\ No newline at end of file
...@@ -11,6 +11,7 @@ need to then generate a matching migration for it using: ...@@ -11,6 +11,7 @@ need to then generate a matching migration for it using:
""" """
import logging import logging
from south.modelsinspector import add_introspection_rules
from django.db import models, DatabaseError from django.db import models, DatabaseError
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
...@@ -21,6 +22,9 @@ from jsonfield import JSONField ...@@ -21,6 +22,9 @@ from jsonfield import JSONField
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
add_introspection_rules([], ["submissions\.models\.AnonymizedUserIDField"])
# 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',
...@@ -33,6 +37,16 @@ score_reset = Signal( ...@@ -33,6 +37,16 @@ score_reset = Signal(
) )
class AnonymizedUserIDField(models.CharField):
""" Field for storing anonymized user ids. """
description = "The anonymized User ID that the XBlock sees"
def __init__(self, *args, **kwargs):
kwargs['max_length'] = 255
kwargs['db_index'] = True
super(AnonymizedUserIDField, self).__init__(*args, **kwargs)
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.
...@@ -41,7 +55,7 @@ class StudentItem(models.Model): ...@@ -41,7 +55,7 @@ class StudentItem(models.Model):
""" """
# The anonymized Student ID that the XBlock sees, not their real ID. # The anonymized Student ID that the XBlock sees, not their real ID.
student_id = models.CharField(max_length=255, blank=False, db_index=True) student_id = AnonymizedUserIDField()
# Not sure yet whether these are legacy course_ids or new course_ids # Not sure yet whether these are legacy course_ids or new course_ids
course_id = models.CharField(max_length=255, blank=False, db_index=True) course_id = models.CharField(max_length=255, blank=False, db_index=True)
...@@ -274,3 +288,15 @@ class ScoreSummary(models.Model): ...@@ -274,3 +288,15 @@ class ScoreSummary(models.Model):
u"Error while updating score summary for student item {}" u"Error while updating score summary for student item {}"
.format(score.student_item) .format(score.student_item)
) )
class ScoreAnnotation(models.Model):
""" Annotate individual scores with extra information if necessary. """
score = models.ForeignKey(Score)
# A string that will represent the 'type' of annotation,
# e.g. staff_override, etc.
annotation_type = models.CharField(max_length=255, blank=False, db_index=True)
creator = AnonymizedUserIDField()
reason = models.TextField()
# -*- coding: utf-8 -*-
import datetime import datetime
import copy import copy
...@@ -10,7 +12,7 @@ from mock import patch ...@@ -10,7 +12,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_set from submissions.models import ScoreSummary, ScoreAnnotation, Submission, StudentItem, score_set
from submissions.serializers import StudentItemSerializer from submissions.serializers import StudentItemSerializer
STUDENT_ITEM = dict( STUDENT_ITEM = dict(
...@@ -252,6 +254,7 @@ class TestSubmissionsApi(TestCase): ...@@ -252,6 +254,7 @@ class TestSubmissionsApi(TestCase):
api.set_score(submission["uuid"], 11, 12) api.set_score(submission["uuid"], 11, 12)
score = api.get_latest_score_for_submission(submission["uuid"]) score = api.get_latest_score_for_submission(submission["uuid"])
self._assert_score(score, 11, 12) self._assert_score(score, 11, 12)
self.assertFalse(ScoreAnnotation.objects.all().exists())
@patch.object(score_set, 'send') @patch.object(score_set, 'send')
def test_set_score_signal(self, send_mock): def test_set_score_signal(self, send_mock):
...@@ -268,6 +271,28 @@ class TestSubmissionsApi(TestCase): ...@@ -268,6 +271,28 @@ class TestSubmissionsApi(TestCase):
item_id=STUDENT_ITEM['item_id'] item_id=STUDENT_ITEM['item_id']
) )
@ddt.data(u"First score was incorrect", u"☃")
def test_set_score_with_annotation(self, reason):
submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE)
creator_uuid = "Bob"
annotation_type = "staff_override"
api.set_score(submission["uuid"], 11, 12, creator_uuid, annotation_type, reason)
score = api.get_latest_score_for_submission(submission["uuid"])
self._assert_score(score, 11, 12)
# We need to do this to verify that one score annotation exists and was
# created for this score. We do not have an api point for retrieving
# annotations, and it doesn't make sense to expose them, since they're
# for auditing purposes.
annotations = ScoreAnnotation.objects.all()
self.assertGreater(len(annotations), 0)
annotation = annotations[0]
self.assertEqual(annotation.score.points_earned, 11)
self.assertEqual(annotation.score.points_possible, 12)
self.assertEqual(annotation.annotation_type, annotation_type)
self.assertEqual(annotation.creator, creator_uuid)
self.assertEqual(annotation.reason, reason)
def test_get_score(self): def test_get_score(self):
submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE) submission = api.create_submission(STUDENT_ITEM, ANSWER_ONE)
api.set_score(submission["uuid"], 11, 12) api.set_score(submission["uuid"], 11, 12)
...@@ -595,4 +620,3 @@ class TestSubmissionsApi(TestCase): ...@@ -595,4 +620,3 @@ class TestSubmissionsApi(TestCase):
self.assertIsNotNone(score) self.assertIsNotNone(score)
self.assertEqual(score["points_earned"], expected_points_earned) self.assertEqual(score["points_earned"], expected_points_earned)
self.assertEqual(score["points_possible"], expected_points_possible) self.assertEqual(score["points_possible"], expected_points_possible)
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