diff --git a/lms/djangoapps/grades/migrations/0006_persistent_course_grades.py b/lms/djangoapps/grades/migrations/0006_persistent_course_grades.py new file mode 100644 index 0000000000..28f0d46fd4 --- /dev/null +++ b/lms/djangoapps/grades/migrations/0006_persistent_course_grades.py @@ -0,0 +1,37 @@ +# -*- 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')]), + ), + ] diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index eabf9037ed..4bd3f4ba8b 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -3,6 +3,9 @@ Models used for robust grading. 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. +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 @@ -212,7 +215,6 @@ class PersistentSubsectionGrade(TimeStampedModel): # primary key will need to be large for this table id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name - # uniquely identify this particular grade object user_id = models.IntegerField(blank=False) course_id = CourseKeyField(blank=False, max_length=255) @@ -363,3 +365,75 @@ class PersistentSubsectionGrade(TimeStampedModel): """ params['visible_blocks_id'] = params['visible_blocks'].hash_value 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 diff --git a/lms/djangoapps/grades/tests/test_models.py b/lms/djangoapps/grades/tests/test_models.py index e3dd0a24a7..c29f20f39f 100644 --- a/lms/djangoapps/grades/tests/test_models.py +++ b/lms/djangoapps/grades/tests/test_models.py @@ -3,6 +3,7 @@ Unit tests for grades models. """ from base64 import b64encode from collections import OrderedDict +from datetime import datetime import ddt from hashlib import sha1 import json @@ -15,6 +16,7 @@ from lms.djangoapps.grades.models import ( BlockRecord, BlockRecordList, BLOCK_RECORD_LIST_VERSION, + PersistentCourseGrade, PersistentSubsectionGrade, VisibleBlocks ) @@ -157,8 +159,8 @@ class VisibleBlocksTest(GradesModelTestCase): self.assertNotEqual(stored_vblocks.pk, repeat_vblocks.pk) self.assertNotEqual(stored_vblocks.hashed, repeat_vblocks.hashed) - self.assertEquals(stored_vblocks.pk, same_order_vblocks.pk) - self.assertEquals(stored_vblocks.hashed, same_order_vblocks.hashed) + self.assertEqual(stored_vblocks.pk, same_order_vblocks.pk) + self.assertEqual(stored_vblocks.hashed, same_order_vblocks.hashed) self.assertNotEqual(stored_vblocks.pk, new_vblocks.pk) self.assertNotEqual(stored_vblocks.hashed, new_vblocks.hashed) @@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): usage_key=self.params["usage_key"], ) 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): PersistentSubsectionGrade.create_grade(**self.params) @@ -234,7 +236,71 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): self.params["earned_all"] = 7 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: - self.assertEquals(created_grade.id, updated_grade.id) - self.assertEquals(created_grade.earned_all, 6) + self.assertEqual(created_grade.id, updated_grade.id) + 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"])