Files
edx-platform/lms/djangoapps/grades/tests/test_models.py
Feanil Patel 9cf2f9f298 Run 2to3 -f future . -w
This will remove imports from __future__ that are no longer needed.

https://docs.python.org/3.5/library/2to3.html#2to3fixer-future
2019-12-30 10:35:30 -05:00

497 lines
19 KiB
Python

"""
Unit tests for grades models.
"""
import json
from base64 import b64encode
from collections import OrderedDict
from datetime import datetime
from hashlib import sha1
import ddt
import pytz
import six
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils.timezone import now
from freezegun import freeze_time
from mock import patch
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from lms.djangoapps.grades.constants import GradeOverrideFeatureEnum
from lms.djangoapps.grades.models import (
BLOCK_RECORD_LIST_VERSION,
BlockRecord,
BlockRecordList,
PersistentCourseGrade,
PersistentSubsectionGrade,
PersistentSubsectionGradeOverride,
VisibleBlocks
)
from student.tests.factories import UserFactory
from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
class BlockRecordListTestCase(TestCase):
"""
Verify the behavior of BlockRecordList, particularly around edge cases
"""
def setUp(self):
super(BlockRecordListTestCase, self).setUp()
self.course_key = CourseLocator(
org='some_org',
course='some_course',
run='some_run'
)
def test_empty_block_record_set(self):
empty_json = u'{"blocks":[],"course_key":"%s","version":%s}' % (
six.text_type(self.course_key),
BLOCK_RECORD_LIST_VERSION,
)
brs = BlockRecordList((), self.course_key)
self.assertFalse(brs)
self.assertEqual(
brs.json_value,
empty_json
)
self.assertEqual(
BlockRecordList.from_json(empty_json),
brs
)
class GradesModelTestCase(TestCase):
"""
Base class for common setup of grades model tests.
"""
def setUp(self):
super(GradesModelTestCase, self).setUp()
self.course_key = CourseLocator(
org='some_org',
course='some_course',
run='some_run'
)
self.locator_a = BlockUsageLocator(
course_key=self.course_key,
block_type='problem',
block_id='block_id_a'
)
self.locator_b = BlockUsageLocator(
course_key=self.course_key,
block_type='problem',
block_id='block_id_b'
)
self.record_a = BlockRecord(locator=self.locator_a, weight=1, raw_possible=10, graded=False)
self.record_b = BlockRecord(locator=self.locator_b, weight=1, raw_possible=10, graded=True)
@ddt.ddt
class BlockRecordTest(GradesModelTestCase):
"""
Test the BlockRecord model.
"""
def test_creation(self):
"""
Tests creation of a BlockRecord.
"""
weight = 1
raw_possible = 10
record = BlockRecord(
self.locator_a,
weight,
raw_possible,
graded=False,
)
self.assertEqual(record.locator, self.locator_a)
@ddt.data(
(0, 0, "0123456789abcdef", True),
(1, 10, 'totally_a_real_block_key', False),
("BlockRecord is", "a dumb data store", "with no validation", None),
)
@ddt.unpack
def test_serialization(self, weight, raw_possible, block_key, graded):
"""
Tests serialization of a BlockRecord using the _asdict() method.
"""
record = BlockRecord(block_key, weight, raw_possible, graded)
expected = OrderedDict([
("locator", block_key),
("weight", weight),
("raw_possible", raw_possible),
("graded", graded),
])
self.assertEqual(expected, record._asdict())
class VisibleBlocksTest(GradesModelTestCase):
"""
Test the VisibleBlocks model.
"""
def setUp(self):
super(VisibleBlocksTest, self).setUp()
self.user_id = 12345
def _create_block_record_list(self, blocks, user_id=None):
"""
Creates and returns a BlockRecordList for the given blocks.
"""
block_record_list = BlockRecordList.from_list(blocks, self.course_key)
return VisibleBlocks.cached_get_or_create(user_id or self.user_id, block_record_list)
def test_creation(self):
"""
Happy path test to ensure basic create functionality works as expected.
"""
vblocks = self._create_block_record_list([self.record_a])
list_of_block_dicts = [self.record_a._asdict()]
for block_dict in list_of_block_dicts:
block_dict['locator'] = six.text_type(block_dict['locator']) # BlockUsageLocator is not json-serializable
expected_data = {
'blocks': [{
'locator': six.text_type(self.record_a.locator),
'raw_possible': 10,
'weight': 1,
'graded': self.record_a.graded,
}],
'course_key': six.text_type(self.record_a.locator.course_key),
'version': BLOCK_RECORD_LIST_VERSION,
}
expected_json = json.dumps(expected_data, separators=(',', ':'), sort_keys=True)
expected_hash = b64encode(sha1(expected_json.encode('utf-8')).digest()).decode('utf-8')
self.assertEqual(expected_data, json.loads(vblocks.blocks_json))
self.assertEqual(expected_json, vblocks.blocks_json)
self.assertEqual(expected_hash, vblocks.hashed)
def test_ordering_matters(self):
"""
When creating new vblocks, different ordering of blocks produces
different records in the database.
"""
stored_vblocks = self._create_block_record_list([self.record_a, self.record_b])
repeat_vblocks = self._create_block_record_list([self.record_b, self.record_a])
same_order_vblocks = self._create_block_record_list([self.record_a, self.record_b])
new_vblocks = self._create_block_record_list([self.record_b])
self.assertNotEqual(stored_vblocks.pk, repeat_vblocks.pk)
self.assertNotEqual(stored_vblocks.hashed, repeat_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)
def test_blocks_property(self):
"""
Ensures that, given an array of BlockRecord, creating visible_blocks
and accessing visible_blocks.blocks yields a copy of the initial array.
Also, trying to set the blocks property should raise an exception.
"""
expected_blocks = BlockRecordList.from_list([self.record_a, self.record_b], self.course_key)
visible_blocks = self._create_block_record_list(expected_blocks)
self.assertEqual(expected_blocks, visible_blocks.blocks)
with self.assertRaises(AttributeError):
visible_blocks.blocks = expected_blocks
@ddt.ddt
class PersistentSubsectionGradeTest(GradesModelTestCase):
"""
Test the PersistentSubsectionGrade model.
"""
def setUp(self):
super(PersistentSubsectionGradeTest, self).setUp()
self.usage_key = BlockUsageLocator(
course_key=self.course_key,
block_type='subsection',
block_id='subsection_12345',
)
self.block_records = BlockRecordList([self.record_a, self.record_b], self.course_key)
self.params = {
"user_id": 12345,
"usage_key": self.usage_key,
"course_version": "deadbeef",
"subtree_edited_timestamp": "2016-08-01 18:53:24.354741Z",
"earned_all": 6.0,
"possible_all": 12.0,
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": self.block_records,
"first_attempted": datetime(2000, 1, 1, 12, 30, 45, tzinfo=pytz.UTC),
}
self.user = UserFactory(id=self.params['user_id'])
@ddt.data('course_version', 'subtree_edited_timestamp')
def test_optional_fields(self, field):
del self.params[field]
PersistentSubsectionGrade.update_or_create_grade(**self.params)
@ddt.data(
("user_id", KeyError),
("usage_key", KeyError),
("earned_all", IntegrityError),
("possible_all", IntegrityError),
("earned_graded", IntegrityError),
("possible_graded", IntegrityError),
("visible_blocks", KeyError),
("first_attempted", KeyError),
)
@ddt.unpack
def test_non_optional_fields(self, field, error):
del self.params[field]
with self.assertRaises(error):
PersistentSubsectionGrade.update_or_create_grade(**self.params)
@ddt.data(True, False)
def test_update_or_create_grade(self, already_created):
created_grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) if already_created else None
self.params["earned_all"] = 7
updated_grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertEqual(updated_grade.earned_all, 7)
if already_created:
self.assertEqual(created_grade.id, updated_grade.id)
self.assertEqual(created_grade.earned_all, 6)
with self.assertNumQueries(1):
read_grade = PersistentSubsectionGrade.read_grade(
user_id=self.params["user_id"],
usage_key=self.params["usage_key"],
)
self.assertEqual(updated_grade, read_grade)
self.assertEqual(read_grade.visible_blocks.blocks, self.block_records)
def test_unattempted(self):
self.params['first_attempted'] = None
self.params['earned_all'] = 0.0
self.params['earned_graded'] = 0.0
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsNone(grade.first_attempted)
self.assertEqual(grade.earned_all, 0.0)
self.assertEqual(grade.earned_graded, 0.0)
def test_first_attempted_not_changed_on_update(self):
PersistentSubsectionGrade.update_or_create_grade(**self.params)
moment = now()
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertLess(grade.first_attempted, moment)
def test_unattempted_save_does_not_remove_attempt(self):
PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.params['first_attempted'] = None
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime)
self.assertEqual(grade.earned_all, 6.0)
def test_update_or_create_event(self):
with patch('lms.djangoapps.grades.events.tracker') as tracker_mock:
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade)
def test_create_event(self):
with patch('lms.djangoapps.grades.events.tracker') as tracker_mock:
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade)
def test_grade_override(self):
"""
Creating a subsection grade override should NOT change the score values
of the related PersistentSubsectionGrade.
"""
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
override = PersistentSubsectionGradeOverride.update_or_create_override(
requesting_user=self.user,
subsection_grade_model=grade,
earned_all_override=0.0,
earned_graded_override=0.0,
feature=GradeOverrideFeatureEnum.gradebook,
)
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertEqual(self.params['earned_all'], grade.earned_all)
self.assertEqual(self.params['earned_graded'], grade.earned_graded)
history = override.get_history()
self.assertEqual(1, len(list(history)))
self.assertEqual('+', list(history)[0].history_type)
# Any score values that aren't specified should use the values from grade as defaults
self.assertEqual(0, override.earned_all_override)
self.assertEqual(0, override.earned_graded_override)
self.assertEqual(grade.possible_all, override.possible_all_override)
self.assertEqual(grade.possible_graded, override.possible_graded_override)
def _assert_tracker_emitted_event(self, tracker_mock, grade):
"""
Helper function to ensure that the mocked event tracker
was called with the expected info based on the passed grade.
"""
tracker_mock.emit.assert_called_with(
u'edx.grades.subsection.grade_calculated',
{
'user_id': six.text_type(grade.user_id),
'course_id': six.text_type(grade.course_id),
'block_id': six.text_type(grade.usage_key),
'course_version': six.text_type(grade.course_version),
'weighted_total_earned': grade.earned_all,
'weighted_total_possible': grade.possible_all,
'weighted_graded_earned': grade.earned_graded,
'weighted_graded_possible': grade.possible_graded,
'first_attempted': six.text_type(grade.first_attempted),
'subtree_edited_timestamp': six.text_type(grade.subtree_edited_timestamp),
'event_transaction_id': six.text_type(get_event_transaction_id()),
'event_transaction_type': six.text_type(get_event_transaction_type()),
'visible_blocks_hash': six.text_type(grade.visible_blocks_id),
}
)
@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,
tzinfo=pytz.UTC,
),
"percent_grade": 77.7,
"letter_grade": "Great job",
"passed": True,
}
def test_update(self):
created_grade = PersistentCourseGrade.update_or_create(**self.params)
self.params["percent_grade"] = 88.8
self.params["letter_grade"] = "Better job"
updated_grade = PersistentCourseGrade.update_or_create(**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_passed_timestamp(self):
# When the user has not passed, passed_timestamp is None
self.params.update({
u'percent_grade': 25.0,
u'letter_grade': u'',
u'passed': False,
})
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertIsNone(grade.passed_timestamp)
# After the user earns a passing grade, the passed_timestamp is set
self.params.update({
u'percent_grade': 75.0,
u'letter_grade': u'C',
u'passed': True,
})
grade = PersistentCourseGrade.update_or_create(**self.params)
passed_timestamp = grade.passed_timestamp
self.assertEqual(grade.letter_grade, u'C')
self.assertIsInstance(passed_timestamp, datetime)
# After the user improves their score, the new grade is reflected, but
# the passed_timestamp remains the same.
self.params.update({
u'percent_grade': 95.0,
u'letter_grade': u'A',
u'passed': True,
})
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertEqual(grade.letter_grade, u'A')
self.assertEqual(grade.passed_timestamp, passed_timestamp)
# If the grade later reverts to a failing grade, passed_timestamp remains the same.
self.params.update({
u'percent_grade': 20.0,
u'letter_grade': u'',
u'passed': False,
})
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertEqual(grade.letter_grade, u'')
self.assertEqual(grade.passed_timestamp, passed_timestamp)
def test_passed_timestamp_is_now(self):
with freeze_time(now()):
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertEqual(now(), grade.passed_timestamp)
def test_create_and_read_grade(self):
created_grade = PersistentCourseGrade.update_or_create(**self.params)
read_grade = PersistentCourseGrade.read(self.params["user_id"], self.params["course_id"])
for param in self.params:
if param == u'passed':
continue # passed/passed_timestamp takes special handling, and is tested separately
self.assertEqual(self.params[param], getattr(created_grade, param))
self.assertIsInstance(created_grade.passed_timestamp, datetime)
self.assertEqual(created_grade, read_grade)
@ddt.data('course_version', 'course_edited_timestamp')
def test_optional_fields(self, field):
del self.params[field]
grade = PersistentCourseGrade.update_or_create(**self.params)
self.assertFalse(getattr(grade, field))
@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", InvalidKeyError),
("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(**self.params)
def test_grade_does_not_exist(self):
with self.assertRaises(PersistentCourseGrade.DoesNotExist):
PersistentCourseGrade.read(self.params["user_id"], self.params["course_id"])
def test_update_or_create_event(self):
with patch('lms.djangoapps.grades.events.tracker') as tracker_mock:
grade = PersistentCourseGrade.update_or_create(**self.params)
self._assert_tracker_emitted_event(tracker_mock, grade)
def _assert_tracker_emitted_event(self, tracker_mock, grade):
"""
Helper function to ensure that the mocked event tracker
was called with the expected info based on the passed grade.
"""
tracker_mock.emit.assert_called_with(
u'edx.grades.course.grade_calculated',
{
'user_id': six.text_type(grade.user_id),
'course_id': six.text_type(grade.course_id),
'course_version': six.text_type(grade.course_version),
'percent_grade': grade.percent_grade,
'letter_grade': six.text_type(grade.letter_grade),
'course_edited_timestamp': six.text_type(grade.course_edited_timestamp),
'event_transaction_id': six.text_type(get_event_transaction_id()),
'event_transaction_type': six.text_type(get_event_transaction_type()),
'grading_policy_hash': six.text_type(grade.grading_policy_hash),
}
)