Commit 6e52fd79 by Sanford Student Committed by Sanford Student

fixes from robust grades bug bash

Update course grade query parameters and check scores when updating subsection grade

For TNL-5861
parent 08c5a358
...@@ -423,18 +423,17 @@ class PersistentCourseGrade(TimeStampedModel): ...@@ -423,18 +423,17 @@ class PersistentCourseGrade(TimeStampedModel):
return cls.objects.get(user_id=user_id, course_id=course_id) return cls.objects.get(user_id=user_id, course_id=course_id)
@classmethod @classmethod
def update_or_create_course_grade(cls, user_id, course_id, course_version=None, **kwargs): def update_or_create_course_grade(cls, user_id, course_id, **kwargs):
""" """
Creates a course grade in the database. Creates a course grade in the database.
Returns a PersistedCourseGrade object. Returns a PersistedCourseGrade object.
""" """
if course_version is None: if kwargs.get('course_version', None) is None:
course_version = "" kwargs['course_version'] = ""
grade, _ = cls.objects.update_or_create( grade, _ = cls.objects.update_or_create(
user_id=user_id, user_id=user_id,
course_id=course_id, course_id=course_id,
course_version=course_version,
defaults=kwargs defaults=kwargs
) )
return grade return grade
...@@ -106,7 +106,6 @@ def score_published_handler(sender, block, user, raw_earned, raw_possible, only_ ...@@ -106,7 +106,6 @@ def score_published_handler(sender, block, user, raw_earned, raw_possible, only_
if update_score: if update_score:
set_score(user.id, block.location, raw_earned, raw_possible) set_score(user.id, block.location, raw_earned, raw_possible)
PROBLEM_SCORE_CHANGED.send( PROBLEM_SCORE_CHANGED.send(
sender=None, sender=None,
points_earned=raw_earned, points_earned=raw_earned,
...@@ -130,6 +129,8 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum ...@@ -130,6 +129,8 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum
kwargs['course_id'], kwargs['course_id'],
kwargs['usage_id'], kwargs['usage_id'],
kwargs.get('only_if_higher'), kwargs.get('only_if_higher'),
kwargs.get('points_earned'),
kwargs.get('points_possible'),
) )
) )
......
...@@ -6,7 +6,9 @@ from celery import task ...@@ -6,7 +6,9 @@ from celery import task
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from logging import getLogger
from courseware.model_data import get_score
from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.course_blocks.api import get_course_blocks
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
...@@ -19,28 +21,120 @@ from .new.subsection_grade import SubsectionGradeFactory ...@@ -19,28 +21,120 @@ from .new.subsection_grade import SubsectionGradeFactory
from .signals.signals import SUBSECTION_SCORE_CHANGED from .signals.signals import SUBSECTION_SCORE_CHANGED
from .transformer import GradesTransformer from .transformer import GradesTransformer
log = getLogger(__name__)
@task(default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY) @task(default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher): def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible):
""" """
Updates a saved subsection grade. Updates a saved subsection grade.
This method expects the following parameters: This method expects the following parameters:
- user_id: serialized id of applicable User object - user_id: serialized id of applicable User object
- course_id: Unicode string representing the course - course_id: Unicode string identifying the course
- usage_id: Unicode string indicating the courseware instance - usage_id: Unicode string identifying the course block
- only_if_higher: boolean indicating whether grades should - only_if_higher: boolean indicating whether grades should
be updated only if the new grade is higher than the previous be updated only if the new raw_earned is higher than the previous
value. value.
- raw_earned: the raw points the learner earned on the problem that
triggered the update
- raw_possible: the max raw points the leaner could have earned
on the problem
""" """
if not PersistentGradesEnabledFlag.feature_enabled(course_id): if not PersistentGradesEnabledFlag.feature_enabled(course_id):
return return
course_key = CourseLocator.from_string(course_id) course_key = CourseLocator.from_string(course_id)
student = User.objects.get(id=user_id)
scored_block_usage_key = UsageKey.from_string(usage_id).replace(course_key=course_key) scored_block_usage_key = UsageKey.from_string(usage_id).replace(course_key=course_key)
score = get_score(user_id, scored_block_usage_key)
# If the score is None, it has not been saved at all yet
# and we need to retry until it has been saved.
if score is None:
_retry_recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible)
else:
module_raw_earned, module_raw_possible = score # pylint: disable=unpacking-non-sequence
# Validate that the retrieved scores match the scores when the task was created.
# This race condition occurs if the transaction in the task creator's process hasn't
# committed before the task initiates in the worker process.
grades_match = module_raw_earned == raw_earned and module_raw_possible == raw_possible
# We have to account for the situation where a student's state
# has been deleted- in this case, get_score returns (None, None), but
# the score change signal will contain 0 for raw_earned.
state_deleted = module_raw_earned is None and module_raw_possible is None and raw_earned == 0
if not (state_deleted or grades_match):
_retry_recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible)
_update_subsection_grades(
course_key,
scored_block_usage_key,
only_if_higher,
course_id,
user_id,
usage_id,
raw_earned,
raw_possible,
)
@task(default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_course_grade(user_id, course_id):
"""
Updates a saved course grade.
This method expects the following parameters:
- user_id: serialized id of applicable User object
- course_id: Unicode string representing the course
"""
if not PersistentGradesEnabledFlag.feature_enabled(course_id):
return
student = User.objects.get(id=user_id)
course_key = CourseLocator.from_string(course_id)
course = modulestore().get_course(course_key, depth=0)
try:
CourseGradeFactory(student).update(course)
except IntegrityError as exc:
raise recalculate_course_grade.retry(args=[user_id, course_id], exc=exc)
def _retry_recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, grade, max_grade, exc=None):
"""
Calls retry for the recalculate_subsection_grade task with the
given inputs.
"""
raise recalculate_subsection_grade.retry(
args=[
user_id,
course_id,
usage_id,
only_if_higher,
grade,
max_grade,
],
exc=exc
)
def _update_subsection_grades(
course_key,
scored_block_usage_key,
only_if_higher,
course_id,
user_id,
usage_id,
raw_earned,
raw_possible,
):
"""
A helper function to update subsection grades in the database
for each subsection containing the given block, and to signal
that those subsection grades were updated.
"""
collected_block_structure = get_course_in_cache(course_key) collected_block_structure = get_course_in_cache(course_key)
course = modulestore().get_course(course_key, depth=0) course = modulestore().get_course(course_key, depth=0)
student = User.objects.get(id=user_id)
subsection_grade_factory = SubsectionGradeFactory(student, course, collected_block_structure) subsection_grade_factory = SubsectionGradeFactory(student, course, collected_block_structure)
subsections_to_update = collected_block_structure.get_transformer_block_field( subsections_to_update = collected_block_structure.get_transformer_block_field(
scored_block_usage_key, scored_block_usage_key,
...@@ -69,24 +163,4 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher): ...@@ -69,24 +163,4 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher):
) )
except IntegrityError as exc: except IntegrityError as exc:
raise recalculate_subsection_grade.retry(args=[user_id, course_id, usage_id], exc=exc) _retry_recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, exc)
@task(default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_course_grade(user_id, course_id):
"""
Updates a saved course grade.
This method expects the following parameters:
- user_id: serialized id of applicable User object
- course_id: Unicode string representing the course
"""
if not PersistentGradesEnabledFlag.feature_enabled(course_id):
return
student = User.objects.get(id=user_id)
course_key = CourseLocator.from_string(course_id)
course = modulestore().get_course(course_key, depth=0)
try:
CourseGradeFactory(student).update(course)
except IntegrityError as exc:
raise recalculate_course_grade.retry(args=[user_id, course_id], exc=exc)
...@@ -3,6 +3,7 @@ Tests for the functionality and infrastructure of grades tasks. ...@@ -3,6 +3,7 @@ Tests for the functionality and infrastructure of grades tasks.
""" """
from collections import OrderedDict from collections import OrderedDict
from contextlib import contextmanager
import ddt import ddt
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
...@@ -51,17 +52,37 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -51,17 +52,37 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.sequential = ItemFactory.create(parent=self.chapter, category='sequential', display_name="Open Sequential") self.sequential = ItemFactory.create(parent=self.chapter, category='sequential', display_name="Open Sequential")
self.problem = ItemFactory.create(parent=self.sequential, category='problem', display_name='problem') self.problem = ItemFactory.create(parent=self.sequential, category='problem', display_name='problem')
self.score_changed_kwargs = OrderedDict([ self.problem_score_changed_kwargs = OrderedDict([
('points_earned', 1.0),
('points_possible', 2.0),
('user_id', self.user.id), ('user_id', self.user.id),
('course_id', unicode(self.course.id)), ('course_id', unicode(self.course.id)),
('usage_id', unicode(self.problem.location)), ('usage_id', unicode(self.problem.location)),
('only_if_higher', None), ('only_if_higher', None),
]) ])
self.recalculate_subsection_grade_kwargs = OrderedDict([
('user_id', self.user.id),
('course_id', unicode(self.course.id)),
('usage_id', unicode(self.problem.location)),
('only_if_higher', None),
('grade', 1.0),
('max_grade', 2.0),
])
# this call caches the anonymous id on the user object, saving 4 queries in all happy path tests # this call caches the anonymous id on the user object, saving 4 queries in all happy path tests
_ = anonymous_id_for_user(self.user, self.course.id) _ = anonymous_id_for_user(self.user, self.course.id)
# pylint: enable=attribute-defined-outside-init,no-member # pylint: enable=attribute-defined-outside-init,no-member
@contextmanager
def mock_get_score(self, score=(1.0, 2.0)):
"""
Mocks the scores needed by the SCORE_PUBLISHED signal
handler. By default, sets the returned score to 1/2.
"""
with patch("lms.djangoapps.grades.tasks.get_score", return_value=score):
yield
@ddt.data( @ddt.data(
('lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async', PROBLEM_SCORE_CHANGED), ('lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async', PROBLEM_SCORE_CHANGED),
('lms.djangoapps.grades.tasks.recalculate_course_grade.apply_async', SUBSECTION_SCORE_CHANGED) ('lms.djangoapps.grades.tasks.recalculate_course_grade.apply_async', SUBSECTION_SCORE_CHANGED)
...@@ -73,12 +94,15 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -73,12 +94,15 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
""" """
self.set_up_course() self.set_up_course()
if test_signal == PROBLEM_SCORE_CHANGED: if test_signal == PROBLEM_SCORE_CHANGED:
send_args = self.score_changed_kwargs send_args = self.problem_score_changed_kwargs
expected_args = tuple(self.score_changed_kwargs.values()) expected_args = tuple(self.recalculate_subsection_grade_kwargs.values())
else: else:
send_args = {'user': self.user, 'course': self.course} send_args = {'user': self.user, 'course': self.course}
expected_args = (self.score_changed_kwargs['user_id'], self.score_changed_kwargs['course_id']) expected_args = (
with patch( self.problem_score_changed_kwargs['user_id'],
self.problem_score_changed_kwargs['course_id']
)
with self.mock_get_score() and patch(
enqueue_op, enqueue_op,
return_value=None return_value=None
) as mock_task_apply: ) as mock_task_apply:
...@@ -91,19 +115,20 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -91,19 +115,20 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
Ensures that the subsection update operation also updates the course grade. Ensures that the subsection update operation also updates the course grade.
""" """
self.set_up_course() self.set_up_course()
mock_return = uuid4() mock_update_return = uuid4()
course_key = CourseLocator.from_string(unicode(self.course.id)) course_key = CourseLocator.from_string(unicode(self.course.id))
course = modulestore().get_course(course_key, depth=0) course = modulestore().get_course(course_key, depth=0)
with patch( with patch(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update', 'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update',
return_value=mock_return return_value=mock_update_return
): ):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
mock_course_signal.assert_called_once_with( mock_course_signal.assert_called_once_with(
sender=recalculate_subsection_grade, sender=recalculate_subsection_grade,
course=course, course=course,
user=self.user, user=self.user,
subsection_grade=mock_return, subsection_grade=mock_update_return,
) )
@ddt.data(True, False) @ddt.data(True, False)
...@@ -132,8 +157,8 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -132,8 +157,8 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
other_task.assert_not_called() other_task.assert_not_called()
executed_task.assert_called_once_with( executed_task.assert_called_once_with(
args=( args=(
self.score_changed_kwargs['user_id'], self.problem_score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'], self.problem_score_changed_kwargs['course_id'],
) )
) )
...@@ -147,7 +172,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -147,7 +172,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course() self.set_up_course()
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id)) self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries): with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
def test_single_call_to_create_block_structure(self): def test_single_call_to_create_block_structure(self):
self.set_up_course() self.set_up_course()
...@@ -156,7 +181,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -156,7 +181,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
'openedx.core.lib.block_structure.factory.BlockStructureFactory.create_from_cache', 'openedx.core.lib.block_structure.factory.BlockStructureFactory.create_from_cache',
return_value=None, return_value=None,
) as mock_block_structure_create: ) as mock_block_structure_create:
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
self.assertEquals(mock_block_structure_create.call_count, 2) self.assertEquals(mock_block_structure_create.call_count, 2)
@ddt.data( @ddt.data(
...@@ -171,7 +196,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -171,7 +196,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2') ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2')
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3') ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3')
with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries): with check_mongo_calls(2) and self.assertNumQueries(25 + added_queries):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_subsection_grades_not_enabled_on_course(self, default_store): def test_subsection_grades_not_enabled_on_course(self, default_store):
...@@ -179,7 +204,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -179,7 +204,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course(enable_subsection_grades=False) self.set_up_course(enable_subsection_grades=False)
self.assertFalse(PersistentGradesEnabledFlag.feature_enabled(self.course.id)) self.assertFalse(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(0): with check_mongo_calls(2) and self.assertNumQueries(0):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
@skip("Pending completion of TNL-5089") @skip("Pending completion of TNL-5089")
@ddt.data( @ddt.data(
...@@ -194,7 +219,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -194,7 +219,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with self.store.default_store(default_store): with self.store.default_store(default_store):
self.set_up_course() self.set_up_course()
with check_mongo_calls(0) and self.assertNumQueries(3 if feature_flag else 2): with check_mongo_calls(0) and self.assertNumQueries(3 if feature_flag else 2):
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values()))
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry') @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update') @patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
...@@ -204,7 +229,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -204,7 +229,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
""" """
self.set_up_course() self.set_up_course()
mock_update.side_effect = IntegrityError("WHAMMY") mock_update.side_effect = IntegrityError("WHAMMY")
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) self._apply_recalculate_subsection_grade()
self.assertTrue(mock_retry.called) self.assertTrue(mock_retry.called)
@patch('lms.djangoapps.grades.tasks.recalculate_course_grade.retry') @patch('lms.djangoapps.grades.tasks.recalculate_course_grade.retry')
...@@ -217,8 +242,30 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -217,8 +242,30 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
mock_update.side_effect = IntegrityError("WHAMMY") mock_update.side_effect = IntegrityError("WHAMMY")
recalculate_course_grade.apply( recalculate_course_grade.apply(
args=( args=(
self.score_changed_kwargs['user_id'], self.problem_score_changed_kwargs['user_id'],
self.score_changed_kwargs['course_id'], self.problem_score_changed_kwargs['course_id'],
) )
) )
self.assertTrue(mock_retry.called) self.assertTrue(mock_retry.called)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
def test_retry_subsection_grade_on_update_not_complete(self, mock_retry):
self.set_up_course()
with self.mock_get_score(score=(0.5, 3.0)):
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values()))
self.assertTrue(mock_retry.called)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
def test_retry_subsection_grade_on_no_score(self, mock_retry):
self.set_up_course()
with self.mock_get_score(score=None):
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values()))
self.assertTrue(mock_retry.called)
def _apply_recalculate_subsection_grade(self):
"""
Calls the recalculate_subsection_grade task with necessary
mocking in place.
"""
with self.mock_get_score():
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values()))
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