Commit 9d71ac2e by Sanford Student

SQL model for course grades

Includes unit tests
For TNL-5310
parent 1e1b7e1a
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
import model_utils.fields
import xmodule_django.models
import coursewarehistoryextended.fields
class Migration(migrations.Migration):
dependencies = [
('grades', '0005_multiple_course_flags'),
]
operations = [
migrations.CreateModel(
name='PersistentCourseGrade',
fields=[
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
('id', coursewarehistoryextended.fields.UnsignedBigIntAutoField(serialize=False, primary_key=True)),
('user_id', models.IntegerField(db_index=True)),
('course_id', xmodule_django.models.CourseKeyField(max_length=255)),
('course_edited_timestamp', models.DateTimeField(verbose_name='Last content edit timestamp')),
('course_version', models.CharField(max_length=255, verbose_name='Course content version identifier', blank=True)),
('grading_policy_hash', models.CharField(max_length=255, verbose_name='Hash of grading policy')),
('percent_grade', models.FloatField()),
('letter_grade', models.CharField(max_length=255, verbose_name='Letter grade for course')),
],
),
migrations.AlterUniqueTogether(
name='persistentcoursegrade',
unique_together=set([('course_id', 'user_id')]),
),
]
...@@ -3,6 +3,9 @@ Models used for robust grading. ...@@ -3,6 +3,9 @@ Models used for robust grading.
Robust grading allows student scores to be saved per-subsection independent Robust grading allows student scores to be saved per-subsection independent
of any changes that may occur to the course after the score is achieved. of any changes that may occur to the course after the score is achieved.
We also persist students' course-level grades, and update them whenever
a student's score or the course grading policy changes. As they are
persisted, course grades are also immune to changes in course content.
""" """
from base64 import b64encode from base64 import b64encode
...@@ -212,7 +215,6 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -212,7 +215,6 @@ class PersistentSubsectionGrade(TimeStampedModel):
# primary key will need to be large for this table # primary key will need to be large for this table
id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name
# uniquely identify this particular grade object
user_id = models.IntegerField(blank=False) user_id = models.IntegerField(blank=False)
course_id = CourseKeyField(blank=False, max_length=255) course_id = CourseKeyField(blank=False, max_length=255)
...@@ -363,3 +365,75 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -363,3 +365,75 @@ class PersistentSubsectionGrade(TimeStampedModel):
""" """
params['visible_blocks_id'] = params['visible_blocks'].hash_value params['visible_blocks_id'] = params['visible_blocks'].hash_value
del params['visible_blocks'] del params['visible_blocks']
class PersistentCourseGrade(TimeStampedModel):
"""
A django model tracking persistent course grades.
"""
class Meta(object):
# Indices:
# (course_id, user_id) for individual grades
# (course_id) for instructors to see all course grades, implicitly created via the unique_together constraint
# (user_id) for course dashboard; explicitly declared as an index below
unique_together = [
('course_id', 'user_id'),
]
# primary key will need to be large for this table
id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name
user_id = models.IntegerField(blank=False, db_index=True)
course_id = CourseKeyField(blank=False, max_length=255)
# Information relating to the state of content when grade was calculated
course_edited_timestamp = models.DateTimeField(u'Last content edit timestamp', blank=False)
course_version = models.CharField(u'Course content version identifier', blank=True, max_length=255)
grading_policy_hash = models.CharField(u'Hash of grading policy', blank=False, max_length=255)
# Information about the course grade itself
percent_grade = models.FloatField(blank=False)
letter_grade = models.CharField(u'Letter grade for course', blank=False, max_length=255)
def __unicode__(self):
"""
Returns a string representation of this model.
"""
return u"{} user: {}, course version: {}, grading policy: {}, percent grade {}%, letter grade {}".format(
type(self).__name__,
self.user_id,
self.course_version,
self.grading_policy_hash,
self.percent_grade,
self.letter_grade,
)
@classmethod
def read_course_grade(cls, user_id, course_id):
"""
Reads a grade from database
Arguments:
user_id: The user associated with the desired grade
course_id: The id of the course associated with the desired grade
Raises PersistentCourseGrade.DoesNotExist if applicable
"""
return cls.objects.get(user_id=user_id, course_id=course_id)
@classmethod
def update_or_create_course_grade(cls, user_id, course_id, course_version=None, **kwargs):
"""
Creates a course grade in the database.
Returns a PersistedCourseGrade object.
"""
if course_version is None:
course_version = ""
grade, _ = cls.objects.update_or_create(
user_id=user_id,
course_id=course_id,
course_version=course_version,
defaults=kwargs
)
return grade
...@@ -3,6 +3,7 @@ Unit tests for grades models. ...@@ -3,6 +3,7 @@ Unit tests for grades models.
""" """
from base64 import b64encode from base64 import b64encode
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime
import ddt import ddt
from hashlib import sha1 from hashlib import sha1
import json import json
...@@ -15,6 +16,7 @@ from lms.djangoapps.grades.models import ( ...@@ -15,6 +16,7 @@ from lms.djangoapps.grades.models import (
BlockRecord, BlockRecord,
BlockRecordList, BlockRecordList,
BLOCK_RECORD_LIST_VERSION, BLOCK_RECORD_LIST_VERSION,
PersistentCourseGrade,
PersistentSubsectionGrade, PersistentSubsectionGrade,
VisibleBlocks VisibleBlocks
) )
...@@ -157,8 +159,8 @@ class VisibleBlocksTest(GradesModelTestCase): ...@@ -157,8 +159,8 @@ class VisibleBlocksTest(GradesModelTestCase):
self.assertNotEqual(stored_vblocks.pk, repeat_vblocks.pk) self.assertNotEqual(stored_vblocks.pk, repeat_vblocks.pk)
self.assertNotEqual(stored_vblocks.hashed, repeat_vblocks.hashed) self.assertNotEqual(stored_vblocks.hashed, repeat_vblocks.hashed)
self.assertEquals(stored_vblocks.pk, same_order_vblocks.pk) self.assertEqual(stored_vblocks.pk, same_order_vblocks.pk)
self.assertEquals(stored_vblocks.hashed, same_order_vblocks.hashed) self.assertEqual(stored_vblocks.hashed, same_order_vblocks.hashed)
self.assertNotEqual(stored_vblocks.pk, new_vblocks.pk) self.assertNotEqual(stored_vblocks.pk, new_vblocks.pk)
self.assertNotEqual(stored_vblocks.hashed, new_vblocks.hashed) self.assertNotEqual(stored_vblocks.hashed, new_vblocks.hashed)
...@@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
usage_key=self.params["usage_key"], usage_key=self.params["usage_key"],
) )
self.assertEqual(created_grade, read_grade) self.assertEqual(created_grade, read_grade)
self.assertEquals(read_grade.visible_blocks.blocks, self.block_records) self.assertEqual(read_grade.visible_blocks.blocks, self.block_records)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
PersistentSubsectionGrade.create_grade(**self.params) PersistentSubsectionGrade.create_grade(**self.params)
...@@ -234,7 +236,71 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -234,7 +236,71 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
self.params["earned_all"] = 7 self.params["earned_all"] = 7
updated_grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) updated_grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertEquals(updated_grade.earned_all, 7) self.assertEqual(updated_grade.earned_all, 7)
if already_created: if already_created:
self.assertEquals(created_grade.id, updated_grade.id) self.assertEqual(created_grade.id, updated_grade.id)
self.assertEquals(created_grade.earned_all, 6) self.assertEqual(created_grade.earned_all, 6)
@ddt.ddt
class PersistentCourseGradesTest(GradesModelTestCase):
"""
Tests the PersistentCourseGrade model.
"""
def setUp(self):
super(PersistentCourseGradesTest, self).setUp()
self.params = {
"user_id": 12345,
"course_id": self.course_key,
"course_version": "JoeMcEwing",
"course_edited_timestamp": datetime(
year=2016,
month=8,
day=1,
hour=18,
minute=53,
second=24,
microsecond=354741,
),
"percent_grade": 77.7,
"letter_grade": "Great job",
}
def test_update(self):
created_grade = PersistentCourseGrade.objects.create(**self.params)
self.params["percent_grade"] = 88.8
self.params["letter_grade"] = "Better job"
updated_grade = PersistentCourseGrade.update_or_create_course_grade(**self.params)
self.assertEqual(updated_grade.percent_grade, 88.8)
self.assertEqual(updated_grade.letter_grade, "Better job")
self.assertEqual(created_grade.id, updated_grade.id)
def test_create_and_read_grade(self):
created_grade = PersistentCourseGrade.update_or_create_course_grade(**self.params)
read_grade = PersistentCourseGrade.read_course_grade(self.params["user_id"], self.params["course_id"])
for param in self.params:
self.assertEqual(self.params[param], getattr(created_grade, param))
self.assertEqual(created_grade, read_grade)
def test_course_version_optional(self):
del self.params["course_version"]
grade = PersistentCourseGrade.update_or_create_course_grade(**self.params)
self.assertEqual("", grade.course_version)
@ddt.data(
("percent_grade", "Not a float at all", ValueError),
("percent_grade", None, IntegrityError),
("letter_grade", None, IntegrityError),
("course_id", "Not a course key at all", AssertionError),
("user_id", None, IntegrityError),
("grading_policy_hash", None, IntegrityError)
)
@ddt.unpack
def test_update_or_create_with_bad_params(self, param, val, error):
self.params[param] = val
with self.assertRaises(error):
PersistentCourseGrade.update_or_create_course_grade(**self.params)
def test_grade_does_not_exist(self):
with self.assertRaises(PersistentCourseGrade.DoesNotExist):
PersistentCourseGrade.read_course_grade(self.params["user_id"], self.params["course_id"])
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