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
Handles the PROBLEM_SCORE_CHANGED signal by enqueueing a subsection update operation to occur asynchronously.
"""
recalculate_subsection_grade.apply_async(
args=(
kwargs['user_id'],
kwargs['course_id'],
kwargs['usage_id'],
kwargs.get('only_if_higher'),
kwargs.get('points_earned'),
kwargs.get('points_possible'),
kwargs=dict(
user_id=kwargs['user_id'],
course_id=kwargs['course_id'],
usage_id=kwargs['usage_id'],
only_if_higher=kwargs.get('only_if_higher'),
raw_earned=kwargs.get('points_earned'),
raw_possible=kwargs.get('points_possible'),
score_deleted=kwargs.get('score_deleted', False),
)
)
......
......@@ -23,50 +23,40 @@ log = getLogger(__name__)
@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.
This method expects the following parameters:
- user_id: serialized id of applicable User object
- course_id: Unicode string identifying the course
- usage_id: Unicode string identifying the course block
- only_if_higher: boolean indicating whether grades should
be updated only if the new raw_earned is higher than the previous
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
Arguments:
user_id (int): id of applicable User object
course_id (string): identifying the course
usage_id (string): identifying the course block
only_if_higher (boolean): indicating whether grades should
be updated only if the new raw_earned is higher than the
previous value.
raw_earned (float): the raw points the learner earned on the
problem that triggered the update.
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)
if not PersistentGradesEnabledFlag.feature_enabled(course_key):
return
score_deleted = kwargs['score_deleted']
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:
# Verify the database has been updated with 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.
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(
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):
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,
)
_update_subsection_grades(
......@@ -78,6 +68,28 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher, r
usage_id,
raw_earned,
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(
usage_id,
raw_earned,
raw_possible,
score_deleted,
):
"""
A helper function to update subsection grades in the database
......@@ -124,23 +137,26 @@ def _update_subsection_grades(
except IntegrityError as exc:
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
given inputs.
"""
recalculate_subsection_grade.retry(
args=[
user_id,
course_id,
usage_id,
only_if_higher,
grade,
max_grade,
],
exc=exc
kwargs=dict(
user_id=user_id,
course_id=course_id,
usage_id=usage_id,
only_if_higher=only_if_higher,
raw_earned=raw_earned,
raw_possible=raw_possible,
score_deleted=score_deleted,
),
exc=exc,
)
......@@ -8,19 +8,16 @@ import ddt
from django.conf import settings
from django.db.utils import IntegrityError
from mock import patch
from uuid import uuid4
from unittest import skip
from opaque_keys.edx.locator import CourseLocator
from student.models import anonymous_id_for_user
from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
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
......@@ -66,8 +63,9 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
('course_id', unicode(self.course.id)),
('usage_id', unicode(self.problem.location)),
('only_if_higher', None),
('grade', 1.0),
('max_grade', 2.0),
('raw_earned', 1.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
......@@ -83,30 +81,18 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with patch("lms.djangoapps.grades.tasks.get_score", return_value=score):
yield
@ddt.data(
('lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async', PROBLEM_SCORE_CHANGED),
)
@ddt.unpack
def test_signal_queues_task(self, enqueue_op, test_signal):
def test_problem_score_changed_queues_task(self):
"""
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()
if test_signal == PROBLEM_SCORE_CHANGED:
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']
)
send_args = self.problem_score_changed_kwargs
with self.mock_get_score() and patch(
enqueue_op,
'lms.djangoapps.grades.tasks.recalculate_subsection_grade.apply_async',
return_value=None
) as mock_task_apply:
test_signal.send(sender=None, **send_args)
mock_task_apply.assert_called_once_with(args=expected_args)
PROBLEM_SCORE_CHANGED.send(sender=None, **send_args)
mock_task_apply.assert_called_once_with(kwargs=self.recalculate_subsection_grade_kwargs)
@patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
def test_subsection_update_triggers_course_update(self, mock_course_signal):
......@@ -174,7 +160,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with self.store.default_store(default_store):
self.set_up_course()
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.new.subsection_grade.SubsectionGradeFactory.update')
......@@ -185,26 +171,43 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.set_up_course()
mock_update.side_effect = IntegrityError("WHAMMY")
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')
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)
self._apply_recalculate_subsection_grade(mock_score=(0.5, 3.0))
self._assert_retry_called(mock_retry)
@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)
self._apply_recalculate_subsection_grade(mock_score=None)
self._assert_retry_called(mock_retry)
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
mocking in place.
"""
with self.mock_get_score():
recalculate_subsection_grade.apply(args=tuple(self.recalculate_subsection_grade_kwargs.values()))
with self.mock_get_score(mock_score):
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.
Does not include any access control, be sure to check access before calling.
"""
import crum
import json
import logging
from django.contrib.auth.models import User
......@@ -15,8 +14,6 @@ from django.utils.translation import override as override_language
from course_modes.models import CourseMode
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 lms.djangoapps.grades.scores import weighted_score
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):
noted below.
"""
if block and block.has_score:
cache = FieldDataCache.cache_for_descriptor_descendents(
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()
max_score = block.max_score()
if max_score is None:
return
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:
points_earned, points_possible = 0, 0
PROBLEM_SCORE_CHANGED.send(
sender=None,
points_possible=points_possible,
points_earned=points_earned,
user_id=student.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