Commit e4a9bef8 by Tyler Hallada

Trigger recalculate subsection, undo override

parent cf39bef7
...@@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object): ...@@ -9,3 +9,4 @@ class ScoreDatabaseTableEnum(object):
""" """
courseware_student_module = 'csm' courseware_student_module = 'csm'
submissions = 'submissions' submissions = 'submissions'
overrides = 'overrides'
...@@ -695,6 +695,10 @@ class PersistentSubsectionGradeOverride(models.Model): ...@@ -695,6 +695,10 @@ class PersistentSubsectionGradeOverride(models.Model):
grade = models.OneToOneField(PersistentSubsectionGrade, related_name='override') grade = models.OneToOneField(PersistentSubsectionGrade, related_name='override')
# Created/modified timestamps prevent race-conditions when using with async rescoring tasks
created = models.DateTimeField(auto_now_add=True, db_index=True)
modified = models.DateTimeField(auto_now=True, db_index=True)
# earned/possible refers to the number of points achieved and available to achieve. # earned/possible refers to the number of points achieved and available to achieve.
# graded refers to the subset of all problems that are marked as being graded. # graded refers to the subset of all problems that are marked as being graded.
earned_all_override = models.FloatField(null=True, blank=True) earned_all_override = models.FloatField(null=True, blank=True)
......
from datetime import datetime
import pytz
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride
from .constants import ScoreDatabaseTableEnum
from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride
def _get_key(key_or_id, key_cls): def _get_key(key_or_id, key_cls):
...@@ -24,48 +30,93 @@ class GradesService(object): ...@@ -24,48 +30,93 @@ class GradesService(object):
def get_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id): def get_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
""" """
Finds and returns the earned subsection grade for user Finds and returns the earned subsection grade for user
Result is a dict of two key value pairs with keys: earned_all and earned_graded.
""" """
course_key = _get_key(course_key_or_id, CourseKey) course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey) usage_key = _get_key(usage_key_or_id, UsageKey)
grade = PersistentSubsectionGrade.objects.get( return PersistentSubsectionGrade.objects.get(
user_id=user_id, user_id=user_id,
course_id=course_key, course_id=course_key,
usage_key=usage_key usage_key=usage_key
) )
return {
'earned_all': grade.earned_all, def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id):
'earned_graded': grade.earned_graded """
} Finds the subsection grade for user and returns the override for that grade if it exists
If override does not exist, returns None. If subsection grade does not exist, will raise an exception.
"""
course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey)
grade = self.get_subsection_grade(user_id, course_key, usage_key)
try:
return PersistentSubsectionGradeOverride.objects.get(
grade=grade
)
except PersistentSubsectionGradeOverride.DoesNotExist:
return None
def override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id, earned_all=None, def override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id, earned_all=None,
earned_graded=None): earned_graded=None):
""" """
Override subsection grade (the PersistentSubsectionGrade model must already exist) Override subsection grade (the PersistentSubsectionGrade model must already exist)
Will not override earned_all or earned_graded value if they are None. Both default to None. Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. Will not
override earned_all or earned_graded value if they are None. Both default to None.
""" """
from .tasks import recalculate_subsection_grade_v3 # prevent circular import
course_key = _get_key(course_key_or_id, CourseKey) course_key = _get_key(course_key_or_id, CourseKey)
subsection_key = _get_key(usage_key_or_id, UsageKey) usage_key = _get_key(usage_key_or_id, UsageKey)
grade = PersistentSubsectionGrade.objects.get( grade = PersistentSubsectionGrade.objects.get(
user_id=user_id, user_id=user_id,
course_id=course_key, course_id=course_key,
usage_key=subsection_key usage_key=usage_key
) )
# Create override that will prevent any future updates to grade # Create override that will prevent any future updates to grade
PersistentSubsectionGradeOverride.objects.create( override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(
grade=grade, grade=grade,
earned_all_override=earned_all, earned_all_override=earned_all,
earned_graded_override=earned_graded earned_graded_override=earned_graded
) )
# Change the grade as it is now # Recalculation will call PersistentSubsectionGrade.update_or_create_grade which will use the above override
if earned_all is not None: # to update the grade before writing to the table.
grade.earned_all = earned_all recalculate_subsection_grade_v3.apply_async(
if earned_graded is not None: sender=None,
grade.earned_graded = earned_graded user_id=user_id,
grade.save() course_id=unicode(course_key),
usage_id=unicode(usage_key),
only_if_higher=False,
expeected_modified=override.modified,
score_db_table=ScoreDatabaseTableEnum.overrides
)
def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
"""
Delete the override subsection grade row (the PersistentSubsectionGrade model must already exist)
Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table.
"""
from .tasks import recalculate_subsection_grade_v3 # prevent circular import
course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey)
override = self.get_subsection_grade_override(user_id, course_key, usage_key)
override.delete()
recalculate_subsection_grade_v3.apply_async(
sender=None,
user_id=user_id,
course_id=unicode(course_key),
usage_id=unicode(usage_key),
only_if_higher=False,
expected_modified=datetime.now().replace(tzinfo=pytz.UTC), # Not used when score_deleted=True
score_deleted=True,
score_db_table=ScoreDatabaseTableEnum.overrides
)
...@@ -31,6 +31,7 @@ from .constants import ScoreDatabaseTableEnum ...@@ -31,6 +31,7 @@ from .constants import ScoreDatabaseTableEnum
from .exceptions import DatabaseNotReadyError from .exceptions import DatabaseNotReadyError
from .new.course_grade_factory import CourseGradeFactory from .new.course_grade_factory import CourseGradeFactory
from .new.subsection_grade_factory import SubsectionGradeFactory from .new.subsection_grade_factory import SubsectionGradeFactory
from .services import GradesService
from .signals.signals import SUBSECTION_SCORE_CHANGED from .signals.signals import SUBSECTION_SCORE_CHANGED
from .transformer import GradesTransformer from .transformer import GradesTransformer
...@@ -201,8 +202,7 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): ...@@ -201,8 +202,7 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs):
score = get_score(kwargs['user_id'], scored_block_usage_key) score = get_score(kwargs['user_id'], scored_block_usage_key)
found_modified_time = score.modified if score is not None else None found_modified_time = score.modified if score is not None else None
else: elif kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions:
assert kwargs['score_db_table'] == ScoreDatabaseTableEnum.submissions
score = sub_api.get_score( score = sub_api.get_score(
{ {
"student_id": kwargs['anonymous_user_id'], "student_id": kwargs['anonymous_user_id'],
...@@ -212,6 +212,14 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): ...@@ -212,6 +212,14 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs):
} }
) )
found_modified_time = score['created_at'] if score is not None else None found_modified_time = score['created_at'] if score is not None else None
else:
assert kwargs['score_db_table'] == ScoreDatabaseTableEnum.overrides
score = GradesService().get_subsection_grade_override(
user_id=kwargs['user_id'],
course_key_or_id=kwargs['course_id'],
usage_key_or_id=kwargs['usage_id']
)
found_modified_time = score.modified if score is not None else None
if score is None: if score is None:
# score should be None only if it was deleted. # score should be None only if it was deleted.
......
import ddt import ddt
import pytz
from datetime import datetime
from freezegun import freeze_time
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride
from lms.djangoapps.grades.services import GradesService, _get_key from lms.djangoapps.grades.services import GradesService, _get_key
from mock import patch
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from ..constants import ScoreDatabaseTableEnum
@ddt.ddt @ddt.ddt
class GradesServiceTests(ModuleStoreTestCase): class GradesServiceTests(ModuleStoreTestCase):
...@@ -29,27 +35,73 @@ class GradesServiceTests(ModuleStoreTestCase): ...@@ -29,27 +35,73 @@ class GradesServiceTests(ModuleStoreTestCase):
earned_graded=5.0, earned_graded=5.0,
possible_graded=5.0 possible_graded=5.0
) )
self.patcher = patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.apply_async')
self.mock_recalculate = self.patcher.start()
def tearDown(self):
self.patcher.stop()
def subsection_grade_to_dict(self, grade):
return {
'earned_all': grade.earned_all,
'earned_graded': grade.earned_graded
}
def subsection_grade_override_to_dict(self, grade):
return {
'earned_all_override': grade.earned_all_override,
'earned_graded_override': grade.earned_graded_override
}
def test_get_subsection_grade(self): def test_get_subsection_grade(self):
self.assertDictEqual(self.service.get_subsection_grade( self.assertDictEqual(self.subsection_grade_to_dict(self.service.get_subsection_grade(
user_id=self.user.id, user_id=self.user.id,
course_key_or_id=self.course.id, course_key_or_id=self.course.id,
usage_key_or_id=self.subsection.location usage_key_or_id=self.subsection.location
), { )), {
'earned_all': 6.0, 'earned_all': 6.0,
'earned_graded': 5.0 'earned_graded': 5.0
}) })
# test with id strings as parameters instead # test with id strings as parameters instead
self.assertDictEqual(self.service.get_subsection_grade( self.assertDictEqual(self.subsection_grade_to_dict(self.service.get_subsection_grade(
user_id=self.user.id, user_id=self.user.id,
course_key_or_id=str(self.course.id), course_key_or_id=unicode(self.course.id),
usage_key_or_id=str(self.subsection.location) usage_key_or_id=unicode(self.subsection.location)
), { )), {
'earned_all': 6.0, 'earned_all': 6.0,
'earned_graded': 5.0 'earned_graded': 5.0
}) })
def test_get_subsection_grade_override(self):
override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade)
self.assertDictEqual(self.subsection_grade_override_to_dict(self.service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=self.course.id,
usage_key_or_id=self.subsection.location
)), {
'earned_all_override': override.earned_all_override,
'earned_graded_override': override.earned_graded_override
})
override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(
grade=self.grade,
defaults={
'earned_all_override': 9.0
}
)
# test with id strings as parameters instead
self.assertDictEqual(self.subsection_grade_override_to_dict(self.service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=unicode(self.course.id),
usage_key_or_id=unicode(self.subsection.location)
)), {
'earned_all_override': override.earned_all_override,
'earned_graded_override': override.earned_graded_override
})
@ddt.data( @ddt.data(
[{ [{
'earned_all': 0.0, 'earned_all': 0.0,
...@@ -92,14 +144,48 @@ class GradesServiceTests(ModuleStoreTestCase): ...@@ -92,14 +144,48 @@ class GradesServiceTests(ModuleStoreTestCase):
earned_graded=override['earned_graded'] earned_graded=override['earned_graded']
) )
grade = PersistentSubsectionGrade.objects.get( override_obj = self.service.get_subsection_grade_override(
self.user.id,
self.course.id,
self.subsection.location
)
self.assertIsNotNone(override_obj)
self.assertEqual(override_obj.earned_all_override, override['earned_all'])
self.assertEqual(override_obj.earned_graded_override, override['earned_graded'])
self.mock_recalculate.called_with(
sender=None,
user_id=self.user.id, user_id=self.user.id,
course_id=self.course.id, course_id=unicode(self.course.id),
usage_key=self.subsection.location usage_id=unicode(self.subsection.location),
only_if_higher=False,
expected_modified=override_obj.modified,
score_db_table=ScoreDatabaseTableEnum.overrides
)
@freeze_time('2017-01-01')
def test_undo_override_subsection_grade(self):
override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade)
self.service.undo_override_subsection_grade(
user_id=self.user.id,
course_key_or_id=self.course.id,
usage_key_or_id=self.subsection.location,
) )
self.assertEqual(grade.earned_all, expected['earned_all']) override = self.service.get_subsection_grade_override(self.user.id, self.course.id, self.subsection.location)
self.assertEqual(grade.earned_graded, expected['earned_graded']) self.assertIsNone(override)
self.mock_recalculate.called_with(
sender=None,
user_id=self.user.id,
course_id=unicode(self.course.id),
usage_id=unicode(self.subsection.location),
only_if_higher=False,
expected_modified=datetime.now().replace(tzinfo=pytz.UTC),
score_deleted=True,
score_db_table=ScoreDatabaseTableEnum.overrides
)
@ddt.data( @ddt.data(
['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey], ['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey],
......
...@@ -17,6 +17,7 @@ from mock import MagicMock, patch ...@@ -17,6 +17,7 @@ from mock import MagicMock, patch
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum
from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade
from lms.djangoapps.grades.services import GradesService
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
from lms.djangoapps.grades.tasks import ( from lms.djangoapps.grades.tasks import (
RECALCULATE_GRADE_DELAY, RECALCULATE_GRADE_DELAY,
...@@ -36,6 +37,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -36,6 +37,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
class MockGradesService(GradesService):
def __init__(self, mocked_return_value=None):
super(MockGradesService, self).__init__()
self.mocked_return_value = mocked_return_value
def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id):
return self.mocked_return_value
class HasCourseWithProblemsMixin(object): class HasCourseWithProblemsMixin(object):
""" """
Mixin to provide tests with a sample course with graded subsections Mixin to provide tests with a sample course with graded subsections
...@@ -153,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -153,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_block_structure_create.call_count, 1) self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 28, True), (ModuleStoreEnum.Type.mongo, 1, 29, True),
(ModuleStoreEnum.Type.mongo, 1, 24, False), (ModuleStoreEnum.Type.mongo, 1, 25, False),
(ModuleStoreEnum.Type.split, 3, 28, True), (ModuleStoreEnum.Type.split, 3, 29, True),
(ModuleStoreEnum.Type.split, 3, 24, False), (ModuleStoreEnum.Type.split, 3, 25, False),
) )
@ddt.unpack @ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections): def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
...@@ -168,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -168,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 28), (ModuleStoreEnum.Type.mongo, 1, 29),
(ModuleStoreEnum.Type.split, 3, 28), (ModuleStoreEnum.Type.split, 3, 29),
) )
@ddt.unpack @ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls): def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
...@@ -229,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -229,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0) self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 25), (ModuleStoreEnum.Type.mongo, 1, 26),
(ModuleStoreEnum.Type.split, 3, 25), (ModuleStoreEnum.Type.split, 3, 26),
) )
@ddt.unpack @ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries): def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
...@@ -264,7 +274,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -264,7 +274,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
self._assert_retry_called(mock_retry) self._assert_retry_called(mock_retry)
@ddt.data(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions) @ddt.data(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions,
ScoreDatabaseTableEnum.overrides)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry') @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
@patch('lms.djangoapps.grades.tasks.log') @patch('lms.djangoapps.grades.tasks.log')
def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry): def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry):
...@@ -279,10 +290,16 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -279,10 +290,16 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(module_type='any_block_type') mock_score=MagicMock(module_type='any_block_type')
) )
else: elif score_db_table == ScoreDatabaseTableEnum.courseware_student_module:
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(modified=modified_datetime) mock_score=MagicMock(modified=modified_datetime)
) )
else:
with patch(
'lms.djangoapps.grades.tasks.GradesService',
return_value=MockGradesService(mocked_return_value=MagicMock(modified=modified_datetime))
):
recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs)
self._assert_retry_called(mock_retry) self._assert_retry_called(mock_retry)
self.assertIn( self.assertIn(
...@@ -293,7 +310,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -293,7 +310,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
@ddt.data( @ddt.data(
*itertools.product( *itertools.product(
(True, False), (True, False),
(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions), (ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions,
ScoreDatabaseTableEnum.overrides),
) )
) )
@ddt.unpack @ddt.unpack
...@@ -310,6 +328,11 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -310,6 +328,11 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade( self._apply_recalculate_subsection_grade(
mock_score=MagicMock(module_type='any_block_type') mock_score=MagicMock(module_type='any_block_type')
) )
elif score_db_table == ScoreDatabaseTableEnum.overrides:
with patch('lms.djangoapps.grades.tasks.GradesService',
return_value=MockGradesService(mocked_return_value=None)) as mock_service:
mock_service.get_subsection_grade_override.return_value = None
recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs)
else: else:
self._apply_recalculate_subsection_grade(mock_score=None) self._apply_recalculate_subsection_grade(mock_score=None)
......
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