test_tasks.py 19.9 KB
Newer Older
1 2 3 4
"""
Tests for the functionality and infrastructure of grades tasks.
"""

5
import itertools
6
from collections import OrderedDict
7
from contextlib import contextmanager
8
from datetime import datetime, timedelta
9

10
import ddt
11
import pytz
12
import six
13 14 15
from django.conf import settings
from django.db.utils import IntegrityError
from mock import MagicMock, patch
16 17

from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
18
from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum
19
from lms.djangoapps.grades.models import PersistentCourseGrade, PersistentSubsectionGrade
20
from lms.djangoapps.grades.services import GradesService
21
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
22
from lms.djangoapps.grades.tasks import (
23
    RECALCULATE_GRADE_DELAY_SECONDS,
24
    _course_task_args,
25
    compute_grades_for_course_v2,
26
    recalculate_subsection_grade_v3
27
)
28 29 30 31 32 33 34 35 36
from openedx.core.djangoapps.content.block_structure.exceptions import BlockStructureNotFound
from student.models import CourseEnrollment, anonymous_id_for_user
from student.tests.factories import UserFactory
from track.event_transaction_utils import create_new_event_transaction_id, get_event_transaction_id
from util.date_utils import to_timestamp
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
37

38 39
from .utils import mock_get_score

40

41 42 43 44 45 46 47 48 49
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


50
class HasCourseWithProblemsMixin(object):
51
    """
52
    Mixin to provide tests with a sample course with graded subsections
53
    """
54
    def set_up_course(self, enable_persistent_grades=True, create_multiple_subsections=False):
55 56 57 58 59 60 61 62 63
        """
        Configures the course for this test.
        """
        # pylint: disable=attribute-defined-outside-init,no-member
        self.course = CourseFactory.create(
            org='edx',
            name='course',
            run='run',
        )
64
        if not enable_persistent_grades:
65 66 67
            PersistentGradesEnabledFlag.objects.create(enabled=False)

        self.chapter = ItemFactory.create(parent=self.course, category="chapter", display_name="Chapter")
68 69
        self.sequential = ItemFactory.create(parent=self.chapter, category='sequential', display_name="Sequential1")
        self.problem = ItemFactory.create(parent=self.sequential, category='problem', display_name='Problem')
70

71 72 73 74
        if create_multiple_subsections:
            seq2 = ItemFactory.create(parent=self.chapter, category='sequential')
            ItemFactory.create(parent=seq2, category='problem')

75 76 77
        self.frozen_now_datetime = datetime.now().replace(tzinfo=pytz.UTC)
        self.frozen_now_timestamp = to_timestamp(self.frozen_now_datetime)

78 79 80
        self.problem_weighted_score_changed_kwargs = OrderedDict([
            ('weighted_earned', 1.0),
            ('weighted_possible', 2.0),
81
            ('user_id', self.user.id),
82
            ('anonymous_user_id', 5),
83 84 85
            ('course_id', unicode(self.course.id)),
            ('usage_id', unicode(self.problem.location)),
            ('only_if_higher', None),
86
            ('modified', self.frozen_now_datetime),
87
            ('score_db_table', ScoreDatabaseTableEnum.courseware_student_module),
88
        ])
89

90 91
        create_new_event_transaction_id()

92 93 94 95
        self.recalculate_subsection_grade_kwargs = OrderedDict([
            ('user_id', self.user.id),
            ('course_id', unicode(self.course.id)),
            ('usage_id', unicode(self.problem.location)),
96
            ('anonymous_user_id', 5),
97
            ('only_if_higher', None),
98
            ('expected_modified_time', self.frozen_now_timestamp),
99
            ('score_deleted', False),
100 101
            ('event_transaction_id', unicode(get_event_transaction_id())),
            ('event_transaction_type', u'edx.grades.problem.submitted'),
102
            ('score_db_table', ScoreDatabaseTableEnum.courseware_student_module),
103 104
        ])

105 106 107 108
        # 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)
        # pylint: enable=attribute-defined-outside-init,no-member

109 110 111 112 113 114 115 116 117 118 119 120 121 122

@patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
@ddt.ddt
class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTestCase):
    """
    Ensures that the recalculate subsection grade task functions as expected when run.
    """
    ENABLED_SIGNALS = ['course_published', 'pre_publish']

    def setUp(self):
        super(RecalculateSubsectionGradeTest, self).setUp()
        self.user = UserFactory()
        PersistentGradesEnabledFlag.objects.create(enabled_for_all_courses=True, enabled=True)

123
    @contextmanager
124
    def mock_csm_get_score(self, score=MagicMock(grade=1.0, max_grade=2.0)):
125 126 127 128 129 130 131
        """
        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

132
    def test_triggered_by_problem_weighted_score_change(self):
133
        """
134
        Ensures that the PROBLEM_WEIGHTED_SCORE_CHANGED signal enqueues the correct task.
135 136
        """
        self.set_up_course()
137
        send_args = self.problem_weighted_score_changed_kwargs
138 139
        local_task_args = self.recalculate_subsection_grade_kwargs.copy()
        local_task_args['event_transaction_type'] = u'edx.grades.problem.submitted'
140
        with self.mock_csm_get_score() and patch(
141
            'lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.apply_async',
142 143
            return_value=None
        ) as mock_task_apply:
144
            PROBLEM_WEIGHTED_SCORE_CHANGED.send(sender=None, **send_args)
145
            mock_task_apply.assert_called_once_with(countdown=RECALCULATE_GRADE_DELAY_SECONDS, kwargs=local_task_args)
146

147
    @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
148
    def test_triggers_subsection_score_signal(self, mock_subsection_signal):
149
        """
150
        Ensures that a subsection grade recalculation triggers a signal.
151 152
        """
        self.set_up_course()
153
        self._apply_recalculate_subsection_grade()
154
        self.assertTrue(mock_subsection_signal.called)
155

156 157 158 159
    def test_block_structure_created_only_once(self):
        self.set_up_course()
        self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
        with patch(
160
            'openedx.core.djangoapps.content.block_structure.factory.BlockStructureFactory.create_from_store',
161
            side_effect=BlockStructureNotFound(self.course.location),
162 163 164 165
        ) as mock_block_structure_create:
            self._apply_recalculate_subsection_grade()
            self.assertEquals(mock_block_structure_create.call_count, 1)

166
    @ddt.data(
167 168 169 170
        (ModuleStoreEnum.Type.mongo, 1, 23, True),
        (ModuleStoreEnum.Type.mongo, 1, 23, False),
        (ModuleStoreEnum.Type.split, 3, 23, True),
        (ModuleStoreEnum.Type.split, 3, 23, False),
171 172
    )
    @ddt.unpack
173
    def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
174
        with self.store.default_store(default_store):
175
            self.set_up_course(create_multiple_subsections=create_multiple_subsections)
176
            self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
177 178 179
            with check_mongo_calls(num_mongo_calls):
                with self.assertNumQueries(num_sql_calls):
                    self._apply_recalculate_subsection_grade()
180

181
    @ddt.data(
182 183
        (ModuleStoreEnum.Type.mongo, 1, 23),
        (ModuleStoreEnum.Type.split, 3, 23),
184 185 186 187
    )
    @ddt.unpack
    def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
        with self.store.default_store(default_store):
188
            self.set_up_course(create_multiple_subsections=True)
189 190 191 192 193 194 195 196 197 198 199 200 201 202
            self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))

            num_problems = 10
            for _ in range(num_problems):
                ItemFactory.create(parent=self.sequential, category='problem')

            num_sequentials = 10
            for _ in range(num_sequentials):
                ItemFactory.create(parent=self.chapter, category='sequential')

            with check_mongo_calls(num_mongo_calls):
                with self.assertNumQueries(num_sql_calls):
                    self._apply_recalculate_subsection_grade()

203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226
    @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
    def test_other_inaccessible_subsection(self, mock_subsection_signal):
        self.set_up_course()
        accessible_seq = ItemFactory.create(parent=self.chapter, category='sequential')
        inaccessible_seq = ItemFactory.create(parent=self.chapter, category='sequential', visible_to_staff_only=True)

        # Update problem to have 2 additional sequential parents.
        # So in total, 3 sequential parents, with one inaccessible.
        for sequential in (accessible_seq, inaccessible_seq):
            sequential.children = [self.problem.location]
            modulestore().update_item(sequential, self.user.id)  # pylint: disable=no-member

        # Make sure the signal is sent for only the 2 accessible sequentials.
        self._apply_recalculate_subsection_grade()
        self.assertEquals(mock_subsection_signal.call_count, 2)
        sequentials_signalled = {
            args[1]['subsection_grade'].location
            for args in mock_subsection_signal.call_args_list
        }
        self.assertSetEqual(
            sequentials_signalled,
            {self.sequential.location, accessible_seq.location},
        )

227
    @ddt.data(
228
        (ModuleStoreEnum.Type.mongo, 1, 11),
Eric Fischer committed
229
        (ModuleStoreEnum.Type.split, 3, 11),
230 231 232
    )
    @ddt.unpack
    def test_persistent_grades_not_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
233
        with self.store.default_store(default_store):
234
            self.set_up_course(enable_persistent_grades=False)
235 236 237 238
            with check_mongo_calls(num_mongo_queries):
                with self.assertNumQueries(num_sql_queries):
                    self._apply_recalculate_subsection_grade()
            with self.assertRaises(PersistentCourseGrade.DoesNotExist):
239
                PersistentCourseGrade.read(self.user.id, self.course.id)
240 241 242
            self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)

    @ddt.data(
243 244
        (ModuleStoreEnum.Type.mongo, 1, 24),
        (ModuleStoreEnum.Type.split, 3, 24),
245 246 247 248 249 250 251
    )
    @ddt.unpack
    def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
        with self.store.default_store(default_store):
            self.set_up_course(enable_persistent_grades=True)
            with check_mongo_calls(num_mongo_queries):
                with self.assertNumQueries(num_sql_queries):
252
                    self._apply_recalculate_subsection_grade()
253
            self.assertIsNotNone(PersistentCourseGrade.read(self.user.id, self.course.id))
254
            self.assertGreater(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
255

256
    @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
257
    @patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update')
258 259 260 261 262 263 264 265
    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)
266

267
    @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
268
    @patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update')
269
    def test_retry_on_integrity_error(self, mock_update, mock_retry):
270 271 272 273 274
        """
        Ensures that tasks will be retried if IntegrityErrors are encountered.
        """
        self.set_up_course()
        mock_update.side_effect = IntegrityError("WHAMMY")
275
        self._apply_recalculate_subsection_grade()
276
        self._assert_retry_called(mock_retry)
277

278 279
    @ddt.data(ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions,
              ScoreDatabaseTableEnum.overrides)
280
    @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
281 282
    @patch('lms.djangoapps.grades.tasks.log')
    def test_retry_when_db_not_updated(self, score_db_table, mock_log, mock_retry):
283
        self.set_up_course()
284 285 286 287 288 289 290 291 292 293
        self.recalculate_subsection_grade_kwargs['score_db_table'] = score_db_table
        modified_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=1)
        if score_db_table == ScoreDatabaseTableEnum.submissions:
            with patch('lms.djangoapps.grades.tasks.sub_api.get_score') as mock_sub_score:
                mock_sub_score.return_value = {
                    'created_at': modified_datetime
                }
                self._apply_recalculate_subsection_grade(
                    mock_score=MagicMock(module_type='any_block_type')
                )
294
        elif score_db_table == ScoreDatabaseTableEnum.courseware_student_module:
295
            self._apply_recalculate_subsection_grade(
296
                mock_score=MagicMock(modified=modified_datetime)
297
            )
298 299 300 301 302 303
        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)
304

305
        self._assert_retry_called(mock_retry)
306
        self.assertIn(
307
            u"Grades: tasks._has_database_updated_with_new_score is False.",
308 309
            mock_log.info.call_args_list[0][0][0]
        )
310

311 312 313
    @ddt.data(
        *itertools.product(
            (True, False),
314 315
            (ScoreDatabaseTableEnum.courseware_student_module, ScoreDatabaseTableEnum.submissions,
             ScoreDatabaseTableEnum.overrides),
316 317 318 319
        )
    )
    @ddt.unpack
    @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
320 321
    @patch('lms.djangoapps.grades.tasks.log')
    def test_when_no_score_found(self, score_deleted, score_db_table, mock_log, mock_retry):
322
        self.set_up_course()
323 324 325 326 327 328 329 330 331
        self.recalculate_subsection_grade_kwargs['score_deleted'] = score_deleted
        self.recalculate_subsection_grade_kwargs['score_db_table'] = score_db_table

        if score_db_table == ScoreDatabaseTableEnum.submissions:
            with patch('lms.djangoapps.grades.tasks.sub_api.get_score') as mock_sub_score:
                mock_sub_score.return_value = None
                self._apply_recalculate_subsection_grade(
                    mock_score=MagicMock(module_type='any_block_type')
                )
332 333 334 335 336
        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)
337 338 339 340 341 342 343
        else:
            self._apply_recalculate_subsection_grade(mock_score=None)

        if score_deleted:
            self._assert_retry_not_called(mock_retry)
        else:
            self._assert_retry_called(mock_retry)
344
            self.assertIn(
345
                u"Grades: tasks._has_database_updated_with_new_score is False.",
346 347
                mock_log.info.call_args_list[0][0][0]
            )
348

349
    @patch('lms.djangoapps.grades.tasks.log')
350
    @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
351
    @patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update')
352 353 354 355 356 357 358 359 360 361 362
    def test_log_unknown_error(self, mock_update, mock_retry, mock_log):
        """
        Ensures that unknown errors are logged before a retry.
        """
        self.set_up_course()
        mock_update.side_effect = Exception("General exception with no further detail!")
        self._apply_recalculate_subsection_grade()
        self.assertIn("General exception with no further detail!", mock_log.info.call_args[0][0])
        self._assert_retry_called(mock_retry)

    @patch('lms.djangoapps.grades.tasks.log')
363
    @patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
364
    @patch('lms.djangoapps.grades.subsection_grade_factory.SubsectionGradeFactory.update')
365 366 367 368 369 370 371 372 373 374
    def test_no_log_known_error(self, mock_update, mock_retry, mock_log):
        """
        Ensures that known errors are not logged before a retry.
        """
        self.set_up_course()
        mock_update.side_effect = IntegrityError("race condition oh noes")
        self._apply_recalculate_subsection_grade()
        self.assertFalse(mock_log.info.called)
        self._assert_retry_called(mock_retry)

375 376
    def _apply_recalculate_subsection_grade(
            self,
377 378 379 380 381
            mock_score=MagicMock(
                modified=datetime.utcnow().replace(tzinfo=pytz.UTC) + timedelta(days=1),
                grade=1.0,
                max_grade=2.0,
            )
382
    ):
383 384 385 386
        """
        Calls the recalculate_subsection_grade task with necessary
        mocking in place.
        """
387 388 389
        with self.mock_csm_get_score(mock_score):
            with mock_get_score(1, 2):
                recalculate_subsection_grade_v3.apply(kwargs=self.recalculate_subsection_grade_kwargs)
390 391 392 393 394 395 396 397

    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))
398 399 400 401 402 403

    def _assert_retry_not_called(self, mock_retry):
        """
        Verifies the task was not retried.
        """
        self.assertFalse(mock_retry.called)
404 405 406 407 408


@ddt.ddt
class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase):
    """
409
    Test compute_grades_for_course_v2 task.
410 411 412 413 414 415 416 417 418 419 420 421 422
    """

    ENABLED_SIGNALS = ['course_published', 'pre_publish']

    def setUp(self):
        super(ComputeGradesForCourseTest, self).setUp()
        self.users = [UserFactory.create() for _ in xrange(12)]
        self.set_up_course()
        for user in self.users:
            CourseEnrollment.enroll(user, self.course.id)

    @ddt.data(*xrange(0, 12, 3))
    def test_behavior(self, batch_size):
423 424 425 426 427 428
        with mock_get_score(1, 2):
            result = compute_grades_for_course_v2.delay(
                course_key=six.text_type(self.course.id),
                batch_size=batch_size,
                offset=4,
            )
429 430 431 432 433 434 435 436 437 438 439
        self.assertTrue(result.successful)
        self.assertEqual(
            PersistentCourseGrade.objects.filter(course_id=self.course.id).count(),
            min(batch_size, 8)  # No more than 8 due to offset
        )
        self.assertEqual(
            PersistentSubsectionGrade.objects.filter(course_id=self.course.id).count(),
            min(batch_size, 8)  # No more than 8 due to offset
        )

    @ddt.data(*xrange(1, 12, 3))
440 441 442 443 444 445 446 447 448
    def test_course_task_args(self, test_batch_size):
        offset_expected = 0
        for course_key, offset, batch_size in _course_task_args(
            batch_size=test_batch_size, course_key=self.course.id, from_settings=False
        ):
            self.assertEqual(course_key, six.text_type(self.course.id))
            self.assertEqual(batch_size, test_batch_size)
            self.assertEqual(offset, offset_expected)
            offset_expected += test_batch_size