Commit fda2ceac by Nimisha Asthagiri

Fix Delete-Student-State CSM entry deletion

parent f1f5a8f1
...@@ -124,13 +124,14 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum ...@@ -124,13 +124,14 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum
Handles the PROBLEM_SCORE_CHANGED signal by enqueueing a subsection update operation to occur asynchronously. Handles the PROBLEM_SCORE_CHANGED signal by enqueueing a subsection update operation to occur asynchronously.
""" """
recalculate_subsection_grade.apply_async( recalculate_subsection_grade.apply_async(
args=( kwargs=dict(
kwargs['user_id'], user_id=kwargs['user_id'],
kwargs['course_id'], course_id=kwargs['course_id'],
kwargs['usage_id'], usage_id=kwargs['usage_id'],
kwargs.get('only_if_higher'), only_if_higher=kwargs.get('only_if_higher'),
kwargs.get('points_earned'), raw_earned=kwargs.get('points_earned'),
kwargs.get('points_possible'), raw_possible=kwargs.get('points_possible'),
score_deleted=kwargs.get('score_deleted', False),
) )
) )
......
...@@ -23,50 +23,40 @@ log = getLogger(__name__) ...@@ -23,50 +23,40 @@ 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, raw_earned, raw_possible): def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, **kwargs):
""" """
Updates a saved subsection grade. Updates a saved subsection grade.
This method expects the following parameters:
- user_id: serialized id of applicable User object Arguments:
- course_id: Unicode string identifying the course user_id (int): id of applicable User object
- usage_id: Unicode string identifying the course block course_id (string): identifying the course
- only_if_higher: boolean indicating whether grades should usage_id (string): identifying the course block
be updated only if the new raw_earned is higher than the previous only_if_higher (boolean): indicating whether grades should
value. be updated only if the new raw_earned is higher than the
- raw_earned: the raw points the learner earned on the problem that previous value.
triggered the update raw_earned (float): the raw points the learner earned on the
- raw_possible: the max raw points the leaner could have earned problem that triggered the update.
on the problem raw_possible (float): the max raw points the leaner could have
earned on the problem.
score_deleted (boolean): indicating whether the grade change is
a result of the problem's score being deleted.
""" """
course_key = CourseLocator.from_string(course_id) course_key = CourseLocator.from_string(course_id)
if not PersistentGradesEnabledFlag.feature_enabled(course_key): if not PersistentGradesEnabledFlag.feature_enabled(course_key):
return return
score_deleted = kwargs['score_deleted']
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 # Verify the database has been updated with the scores when the task was
# and we need to retry until it has been saved. # created. This race condition occurs if the transaction in the task
if score is None: # creator's process hasn't committed before the task initiates in the worker
# process.
if not _has_database_updated_with_new_score(
user_id, scored_block_usage_key, raw_earned, raw_possible, score_deleted,
):
raise _retry_recalculate_subsection_grade( raise _retry_recalculate_subsection_grade(
user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, score_deleted,
)
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):
raise _retry_recalculate_subsection_grade(
user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible,
) )
_update_subsection_grades( _update_subsection_grades(
...@@ -78,6 +68,28 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, r ...@@ -78,6 +68,28 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, r
usage_id, usage_id,
raw_earned, raw_earned,
raw_possible, raw_possible,
score_deleted,
)
def _has_database_updated_with_new_score(
user_id, scored_block_usage_key, expected_raw_earned, expected_raw_possible, score_deleted,
):
"""
Returns whether the database has been updated with the
expected new score values for the given problem and user.
"""
score = get_score(user_id, scored_block_usage_key)
if score is None:
# score should be None only if it was deleted.
# Otherwise, it hasn't yet been saved.
return score_deleted
found_raw_earned, found_raw_possible = score # pylint: disable=unpacking-non-sequence
return (
found_raw_earned == expected_raw_earned and
found_raw_possible == expected_raw_possible
) )
...@@ -90,6 +102,7 @@ def _update_subsection_grades( ...@@ -90,6 +102,7 @@ def _update_subsection_grades(
usage_id, usage_id,
raw_earned, raw_earned,
raw_possible, raw_possible,
score_deleted,
): ):
""" """
A helper function to update subsection grades in the database A helper function to update subsection grades in the database
...@@ -124,23 +137,26 @@ def _update_subsection_grades( ...@@ -124,23 +137,26 @@ def _update_subsection_grades(
except IntegrityError as exc: except IntegrityError as exc:
raise _retry_recalculate_subsection_grade( raise _retry_recalculate_subsection_grade(
user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, exc, user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, score_deleted, exc,
) )
def _retry_recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, grade, max_grade, exc=None): def _retry_recalculate_subsection_grade(
user_id, course_id, usage_id, only_if_higher, raw_earned, raw_possible, score_deleted, exc=None,
):
""" """
Calls retry for the recalculate_subsection_grade task with the Calls retry for the recalculate_subsection_grade task with the
given inputs. given inputs.
""" """
recalculate_subsection_grade.retry( recalculate_subsection_grade.retry(
args=[ kwargs=dict(
user_id, user_id=user_id,
course_id, course_id=course_id,
usage_id, usage_id=usage_id,
only_if_higher, only_if_higher=only_if_higher,
grade, raw_earned=raw_earned,
max_grade, raw_possible=raw_possible,
], score_deleted=score_deleted,
exc=exc ),
exc=exc,
) )
...@@ -8,19 +8,16 @@ import ddt ...@@ -8,19 +8,16 @@ import ddt
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from mock import patch from mock import patch
from uuid import uuid4
from unittest import skip from unittest import skip
from opaque_keys.edx.locator import CourseLocator
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase 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
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED, SUBSECTION_SCORE_CHANGED from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED
from lms.djangoapps.grades.tasks import recalculate_subsection_grade from lms.djangoapps.grades.tasks import recalculate_subsection_grade
...@@ -66,8 +63,9 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -66,8 +63,9 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
('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),
('grade', 1.0), ('raw_earned', 1.0),
('max_grade', 2.0), ('raw_possible', 2.0),
('score_deleted', False),
]) ])
# 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
...@@ -83,30 +81,18 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -83,30 +81,18 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with patch("lms.djangoapps.grades.tasks.get_score", return_value=score): with patch("lms.djangoapps.grades.tasks.get_score", return_value=score):
yield yield
@ddt.data( def test_problem_score_changed_queues_task(self):
('lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async', PROBLEM_SCORE_CHANGED),
)
@ddt.unpack
def test_signal_queues_task(self, enqueue_op, test_signal):
""" """
Ensures that the PROBLEM_SCORE_CHANGED and SUBSECTION_SCORE_CHANGED signals enqueue the correct tasks. Ensures that the PROBLEM_SCORE_CHANGED signal enqueues the correct task.
""" """
self.set_up_course() self.set_up_course()
if test_signal == PROBLEM_SCORE_CHANGED: send_args = self.problem_score_changed_kwargs
send_args = self.problem_score_changed_kwargs
expected_args = tuple(self.recalculate_subsection_grade_kwargs.values())
else:
send_args = {'user': self.user, 'course': self.course}
expected_args = (
self.problem_score_changed_kwargs['user_id'],
self.problem_score_changed_kwargs['course_id']
)
with self.mock_get_score() and patch( with self.mock_get_score() and patch(
enqueue_op, 'lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async',
return_value=None return_value=None
) as mock_task_apply: ) as mock_task_apply:
test_signal.send(sender=None, **send_args) PROBLEM_SCORE_CHANGED.send(sender=None, **send_args)
mock_task_apply.assert_called_once_with(args=expected_args) mock_task_apply.assert_called_once_with(kwargs=self.recalculate_subsection_grade_kwargs)
@patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send') @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
def test_subsection_update_triggers_course_update(self, mock_course_signal): def test_subsection_update_triggers_course_update(self, mock_course_signal):
...@@ -174,7 +160,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -174,7 +160,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.recalculate_subsection_grade_kwargs.values())) recalculate_subsection_grade.apply(kwargs=self.recalculate_subsection_grade_kwargs)
@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')
...@@ -185,26 +171,43 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -185,26 +171,43 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course() self.set_up_course()
mock_update.side_effect = IntegrityError("WHAMMY") mock_update.side_effect = IntegrityError("WHAMMY")
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
self.assertTrue(mock_retry.called) self._assert_retry_called(mock_retry)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry') @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
def test_retry_subsection_grade_on_update_not_complete(self, mock_retry): def test_retry_subsection_grade_on_update_not_complete(self, mock_retry):
self.set_up_course() self.set_up_course()
with self.mock_get_score(score=(0.5, 3.0)): self._apply_recalculate_subsection_grade(mock_score=(0.5, 3.0))
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values())) self._assert_retry_called(mock_retry)
self.assertTrue(mock_retry.called)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry') @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade.retry')
def test_retry_subsection_grade_on_no_score(self, mock_retry): def test_retry_subsection_grade_on_no_score(self, mock_retry):
self.set_up_course() self.set_up_course()
with self.mock_get_score(score=None): self._apply_recalculate_subsection_grade(mock_score=None)
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values())) self._assert_retry_called(mock_retry)
self.assertTrue(mock_retry.called)
def _apply_recalculate_subsection_grade(self): @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
def test_retry_first_time_only(self, mock_update, mock_course_signal):
"""
Ensures that a task retry completes after a one-time failure.
"""
self.set_up_course()
mock_update.side_effect = [IntegrityError("WHAMMY"), None]
self._apply_recalculate_subsection_grade()
self.assertEquals(mock_course_signal.call_count, 1)
def _apply_recalculate_subsection_grade(self, mock_score=(1.0, 2.0)):
""" """
Calls the recalculate_subsection_grade task with necessary Calls the recalculate_subsection_grade task with necessary
mocking in place. mocking in place.
""" """
with self.mock_get_score(): with self.mock_get_score(mock_score):
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values())) recalculate_subsection_grade.apply(kwargs=self.recalculate_subsection_grade_kwargs)
def _assert_retry_called(self, mock_retry):
"""
Verifies the task was retried and with the correct
number of arguments.
"""
self.assertTrue(mock_retry.called)
self.assertEquals(len(mock_retry.call_args[1]['kwargs']), len(self.recalculate_subsection_grade_kwargs))
...@@ -4,7 +4,6 @@ Enrollment operations for use by instructor APIs. ...@@ -4,7 +4,6 @@ Enrollment operations for use by instructor APIs.
Does not include any access control, be sure to check access before calling. Does not include any access control, be sure to check access before calling.
""" """
import crum
import json import json
import logging import logging
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -15,8 +14,6 @@ from django.utils.translation import override as override_language ...@@ -15,8 +14,6 @@ from django.utils.translation import override as override_language
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware.models import StudentModule from courseware.models import StudentModule
from courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
from edxmako.shortcuts import render_to_string from edxmako.shortcuts import render_to_string
from lms.djangoapps.grades.scores import weighted_score from lms.djangoapps.grades.scores import weighted_score
from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED
...@@ -298,37 +295,22 @@ def _fire_score_changed_for_block(course_id, student, block, module_state_key): ...@@ -298,37 +295,22 @@ def _fire_score_changed_for_block(course_id, student, block, module_state_key):
noted below. noted below.
""" """
if block and block.has_score: if block and block.has_score:
cache = FieldDataCache.cache_for_descriptor_descendents( max_score = block.max_score()
course_id=course_id,
user=student,
descriptor=block,
depth=0
)
# For implementation reasons, we need to pull the max_score from the XModule,
# even though the data is not user-specific. Here we bind the data to the
# current user.
request = crum.get_current_request()
module = get_module_for_descriptor(
user=student,
request=request,
descriptor=block,
field_data_cache=cache,
course_key=course_id
)
max_score = module.max_score()
if max_score is None: if max_score is None:
return return
else: else:
points_earned, points_possible = weighted_score(0, max_score, getattr(module, 'weight', None)) points_earned, points_possible = weighted_score(0, max_score, getattr(block, 'weight', None))
else: else:
points_earned, points_possible = 0, 0 points_earned, points_possible = 0, 0
PROBLEM_SCORE_CHANGED.send( PROBLEM_SCORE_CHANGED.send(
sender=None, sender=None,
points_possible=points_possible, points_possible=points_possible,
points_earned=points_earned, points_earned=points_earned,
user_id=student.id, user_id=student.id,
course_id=unicode(course_id), course_id=unicode(course_id),
usage_id=unicode(module_state_key) usage_id=unicode(module_state_key),
score_deleted=True,
) )
......
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