Commit f96594b7 by David Ormsbee

Add ability to get_scores() and do some minor submissions API cleanup.

This is what will be called by the LMS when querying the submissions
app for the raw scores of a student in a particular course.

[TIM-232]
parent a8956a55
...@@ -171,7 +171,6 @@ class TestPeerApi(TestCase): ...@@ -171,7 +171,6 @@ class TestPeerApi(TestCase):
"must_be_graded_by": REQUIRED_GRADED_BY, "must_be_graded_by": REQUIRED_GRADED_BY,
} }
} }
# score = sub_api.get_score(STUDENT_ITEM)
score = workflow_api.get_workflow_for_submission( score = workflow_api.get_workflow_for_submission(
tim_sub["uuid"], requirements tim_sub["uuid"], requirements
)["score"] )["score"]
...@@ -188,8 +187,7 @@ class TestPeerApi(TestCase): ...@@ -188,8 +187,7 @@ class TestPeerApi(TestCase):
# Tim should not have a score, because his submission does not have # Tim should not have a score, because his submission does not have
# enough assessments. # enough assessments.
scores = sub_api.get_score(STUDENT_ITEM) self.assertIsNone(sub_api.get_score(STUDENT_ITEM))
self.assertFalse(scores)
sub = peer_api.get_submission_to_assess(bob, REQUIRED_GRADED) sub = peer_api.get_submission_to_assess(bob, REQUIRED_GRADED)
self.assertEqual(sub["uuid"], tim_sub["uuid"]) self.assertEqual(sub["uuid"], tim_sub["uuid"])
...@@ -254,7 +252,7 @@ class TestPeerApi(TestCase): ...@@ -254,7 +252,7 @@ class TestPeerApi(TestCase):
"must_be_graded_by": REQUIRED_GRADED_BY, "must_be_graded_by": REQUIRED_GRADED_BY,
} }
} }
# 1) Angel Submits # 1) Angel Submits
angel_sub, angel = self._create_student_and_submission("Angel", "Angel's answer") angel_sub, angel = self._create_student_and_submission("Angel", "Angel's answer")
......
...@@ -257,11 +257,19 @@ class OpenAssessmentBlock( ...@@ -257,11 +257,19 @@ class OpenAssessmentBlock(
includes the student id, item id, and course id. includes the student id, item id, and course id.
""" """
item_id, student_id = self.get_xblock_trace() item_id, student_id = self.get_xblock_trace()
# This is not the real way course_ids should work, but this is a
# temporary expediency for LMS integratino
if hasattr(self, "xmodule_runtime"):
course_id = self.xmodule_runtime.course_id
else:
course_id = "edX/Enchantment_101/April_1"
student_item_dict = dict( student_item_dict = dict(
student_id=student_id, student_id=student_id,
item_id=item_id, item_id=item_id,
course_id=self.course_id, course_id=course_id,
item_type='openassessment' # XXX: Is this the tag we want? Why? item_type='openassessment'
) )
return student_item_dict return student_item_dict
......
...@@ -116,24 +116,6 @@ class SubmissionMixin(object): ...@@ -116,24 +116,6 @@ class SubmissionMixin(object):
return submission return submission
@staticmethod @staticmethod
def _get_submission_score(student_item_dict):
"""Return the most recent score, if any, for student item
Gets the score, if available.
Args:
student_item_dict (dict): The student item we want to check for a
score.
Returns:
(dict): Dictionary representing the score for this particular
question.
"""
scores = api.get_score(student_item_dict)
return scores[0] if scores else None
@staticmethod
def get_user_submission(submission_uuid): def get_user_submission(submission_uuid):
"""Return the most recent submission by user in workflow """Return the most recent submission by user in workflow
......
...@@ -8,8 +8,10 @@ import logging ...@@ -8,8 +8,10 @@ import logging
from django.db import DatabaseError from django.db import DatabaseError
from django.utils.encoding import force_unicode from django.utils.encoding import force_unicode
from submissions.serializers import SubmissionSerializer, StudentItemSerializer, ScoreSerializer from submissions.serializers import (
from submissions.models import Submission, StudentItem, Score SubmissionSerializer, StudentItemSerializer, ScoreSerializer
)
from submissions.models import Submission, StudentItem, Score, ScoreSummary
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -320,12 +322,49 @@ def get_score(student_item): ...@@ -320,12 +322,49 @@ def get_score(student_item):
""" """
try: try:
student_item_model = StudentItem.objects.get(**student_item) student_item_model = StudentItem.objects.get(**student_item)
scores = Score.objects.filter(student_item=student_item_model) score = ScoreSummary.objects.get(student_item=student_item_model).latest
except (ScoreSummary.DoesNotExist, StudentItem.DoesNotExist):
except StudentItem.DoesNotExist:
return None return None
return ScoreSerializer(scores, many=True).data return ScoreSerializer(score).data
def get_scores(course_id, student_id):
"""Return a dict mapping item_ids -> (points_earned, points_possible).
This method would be used by an LMS to find all the scores for a given
student in a given course.
Args:
course_id (str): Course ID, used to do a lookup on the `StudentItem`.
student_id (str): Student ID, used to do a lookup on the `StudentItem`.
Returns:
dict: The keys are `item_id`s (`str`) and the values are tuples of
`(points_earned, points_possible)`. All points are integer values and
represent the raw, unweighted scores. Submissions does not have any
concept of weights. If there are no entries matching the `course_id` or
`student_id`, we simply return an empty dictionary. This is not
considered an error because there might be many queries for the progress
page of a person who has never submitted anything.
"""
try:
score_summaries = ScoreSummary.objects.filter(
student_item__course_id=course_id,
student_item__student_id=student_id,
).select_related('latest', 'student_item')
except DatabaseError:
msg = u"Could not fetch scores for course {}, student {}".format(
course_id, student_id
)
logger.exception(msg)
raise SubmissionInternalError(msg)
scores = {
summary.student_item.item_id:
(summary.latest.points_earned, summary.latest.points_possible)
for summary in score_summaries
}
return scores
def get_latest_score_for_submission(submission_uuid): def get_latest_score_for_submission(submission_uuid):
...@@ -341,9 +380,6 @@ def get_latest_score_for_submission(submission_uuid): ...@@ -341,9 +380,6 @@ def get_latest_score_for_submission(submission_uuid):
return ScoreSerializer(score).data return ScoreSerializer(score).data
def get_scores(course_id, student_id, types=None):
pass
def set_score(submission_uuid, score, points_possible): def set_score(submission_uuid, score, points_possible):
"""Set a score for a particular submission. """Set a score for a particular submission.
......
# -*- coding: utf-8 -*-
import 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 'ScoreSummary'
db.create_table('submissions_scoresummary', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('student_item', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['submissions.StudentItem'], unique=True)),
('highest', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['submissions.Score'])),
('latest', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['submissions.Score'])),
))
db.send_create_signal('submissions', ['ScoreSummary'])
def backwards(self, orm):
# Deleting model 'ScoreSummary'
db.delete_table('submissions_scoresummary')
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'}),
'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': ('django.db.models.fields.TextField', [], {'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
...@@ -9,11 +9,18 @@ need to then generate a matching migration for it using: ...@@ -9,11 +9,18 @@ need to then generate a matching migration for it using:
./manage.py schemamigration submissions --auto ./manage.py schemamigration submissions --auto
""" """
from django.db import models import logging
from django.db import models, DatabaseError
from django.db.models.signals import post_save
from django.dispatch import receiver
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
logger = logging.getLogger(__name__)
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.
...@@ -95,12 +102,7 @@ class Submission(models.Model): ...@@ -95,12 +102,7 @@ class Submission(models.Model):
class Score(models.Model): class Score(models.Model):
"""What the user scored for a given StudentItem. """What the user scored for a given StudentItem at a given time."""
TODO: Make a ScoreHistory that has more detailed log information so that we
can reconstruct what the state was at a given point in time and debug
more easily.
"""
student_item = models.ForeignKey(StudentItem) student_item = models.ForeignKey(StudentItem)
submission = models.ForeignKey(Submission, null=True) submission = models.ForeignKey(Submission, null=True)
points_earned = models.PositiveIntegerField(default=0) points_earned = models.PositiveIntegerField(default=0)
...@@ -111,6 +113,11 @@ class Score(models.Model): ...@@ -111,6 +113,11 @@ class Score(models.Model):
def submission_uuid(self): def submission_uuid(self):
return self.submission.uuid return self.submission.uuid
def to_float(self):
if self.points_possible == 0:
return None
return float(self.points_earned) / self.points_possible
def __repr__(self): def __repr__(self):
return repr(dict( return repr(dict(
student_item=self.student_item, student_item=self.student_item,
...@@ -120,3 +127,33 @@ class Score(models.Model): ...@@ -120,3 +127,33 @@ class Score(models.Model):
points_possible=self.points_possible, points_possible=self.points_possible,
)) ))
class ScoreSummary(models.Model):
"""Running store of the highest and most recent Scores for a StudentItem."""
student_item = models.ForeignKey(StudentItem, unique=True)
highest = models.ForeignKey(Score, related_name="+")
latest = models.ForeignKey(Score, related_name="+")
@receiver(post_save, sender=Score)
def update_score_summary(sender, **kwargs):
"""Listen for new Scores and update the relevant ScoreSummary."""
score = kwargs['instance']
try:
score_summary = ScoreSummary.objects.get(
student_item=score.student_item
)
score_summary.latest = score
if score.to_float() > score_summary.highest.to_float():
score_summary.highest = score
score_summary.save()
except ScoreSummary.DoesNotExist:
ScoreSummary.objects.create(
student_item=score.student_item,
highest=score,
latest=score,
)
except DatabaseError as err:
logger.exception(
u"Error while updating score summary for student item {}"
.format(score.student_item)
)
...@@ -9,7 +9,7 @@ from mock import patch ...@@ -9,7 +9,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 Submission, StudentItem from submissions.models import ScoreSummary, Submission, StudentItem
from submissions.serializers import StudentItemSerializer from submissions.serializers import StudentItemSerializer
STUDENT_ITEM = dict( STUDENT_ITEM = dict(
...@@ -183,15 +183,55 @@ class TestSubmissionsApi(TestCase): ...@@ -183,15 +183,55 @@ class TestSubmissionsApi(TestCase):
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)
scores = api.get_score(STUDENT_ITEM) score = api.get_score(STUDENT_ITEM)
self._assert_score(scores[0], 11, 12) self._assert_score(score, 11, 12)
self.assertEqual(scores[0]['submission_uuid'], submission['uuid']) self.assertEqual(score['submission_uuid'], submission['uuid'])
def test_get_score_no_student_id(self): def test_get_score_no_student_id(self):
student_item = copy.deepcopy(STUDENT_ITEM) student_item = copy.deepcopy(STUDENT_ITEM)
student_item['student_id'] = None student_item['student_id'] = None
self.assertIs(api.get_score(student_item), None) self.assertIs(api.get_score(student_item), None)
def test_get_scores(self):
student_item = copy.deepcopy(STUDENT_ITEM)
student_item["course_id"] = "get_scores_course"
student_item["item_id"] = "i4x://a/b/c/s1"
s1 = api.create_submission(student_item, "Hello World")
student_item["item_id"] = "i4x://a/b/c/s2"
s2 = api.create_submission(student_item, "Hello World")
student_item["item_id"] = "i4x://a/b/c/s3"
s3 = api.create_submission(student_item, "Hello World")
api.set_score(s1['uuid'], 3, 5)
api.set_score(s1['uuid'], 4, 5)
api.set_score(s1['uuid'], 2, 5) # Should overwrite previous lines
api.set_score(s2['uuid'], 0, 10)
api.set_score(s3['uuid'], 4, 4)
# Getting the scores for a user should never take more than one query
with self.assertNumQueries(1):
scores = api.get_scores(
student_item["course_id"], student_item["student_id"]
)
self.assertEqual(
scores,
{
u"i4x://a/b/c/s1": (2, 5),
u"i4x://a/b/c/s2": (0, 10),
u"i4x://a/b/c/s3": (4, 4),
}
)
@patch.object(ScoreSummary.objects, 'filter')
@raises(api.SubmissionInternalError)
def test_error_on_get_scores(self, mock_filter):
mock_filter.side_effect = DatabaseError("Bad things happened")
api.get_scores("some_course", "some_student")
def _assert_score( def _assert_score(
self, self,
score, score,
......
"""
Tests for submission models.
"""
from django.test import TestCase
from submissions.models import Score, ScoreSummary, StudentItem
class TestScoreSummary(TestCase):
"""
Test selection of options from a rubric.
"""
def test_latest(self):
item = StudentItem.objects.create(
student_id="score_test_student",
course_id="score_test_course",
item_id="i4x://mycourse/class_participation.section_attendance"
)
first_score = Score.objects.create(
student_item=item,
submission=None,
points_earned=8,
points_possible=10,
)
second_score = Score.objects.create(
student_item=item,
submission=None,
points_earned=5,
points_possible=10,
)
latest_score = ScoreSummary.objects.get(student_item=item).latest
self.assertEqual(second_score, latest_score)
def test_highest(self):
item = StudentItem.objects.create(
student_id="score_test_student",
course_id="score_test_course",
item_id="i4x://mycourse/special_presentation"
)
# Low score is higher than no score...
low_score = Score.objects.create(
student_item=item,
submission=None,
points_earned=0,
points_possible=0,
)
self.assertEqual(
low_score,
ScoreSummary.objects.get(student_item=item).highest
)
# Medium score should supplant low score
med_score = Score.objects.create(
student_item=item,
submission=None,
points_earned=8,
points_possible=10,
)
self.assertEqual(
med_score,
ScoreSummary.objects.get(student_item=item).highest
)
# Even though the points_earned is higher in the med_score, high_score
# should win because it's 4/4 as opposed to 8/10.
high_score = Score.objects.create(
student_item=item,
submission=None,
points_earned=4,
points_possible=4,
)
self.assertEqual(
high_score,
ScoreSummary.objects.get(student_item=item).highest
)
# Put another medium score to make sure it doesn't get set back down
med_score2 = Score.objects.create(
student_item=item,
submission=None,
points_earned=5,
points_possible=10,
)
self.assertEqual(
high_score,
ScoreSummary.objects.get(student_item=item).highest
)
self.assertEqual(
med_score2,
ScoreSummary.objects.get(student_item=item).latest
)
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