test_new.py 32.1 KB
Newer Older
1 2 3
"""
Test saved subsection grade functionality.
"""
4 5 6
# pylint: disable=protected-access

import datetime
7
import itertools
8

9
import ddt
10
import pytz
11
from django.conf import settings
12 13 14
from mock import patch

from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
15
from courseware.access import has_access
16
from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
17
from lms.djangoapps.course_blocks.api import get_course_blocks
18
from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags
19
from openedx.core.djangolib.testing.utils import get_mock_request
20 21
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
22
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
23
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
24 25
from xmodule.modulestore.tests.utils import TEST_DATA_DIR
from xmodule.modulestore.xml_importer import import_course_from_xml
26

27
from ..config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT, WRITE_ONLY_IF_ENGAGED, waffle
28
from ..models import PersistentSubsectionGrade
29
from ..new.course_data import CourseData
30
from ..new.course_grade import CourseGrade, ZeroCourseGrade
31
from ..new.course_grade_factory import CourseGradeFactory
32
from ..new.subsection_grade import SubsectionGrade, ZeroSubsectionGrade
33
from ..new.subsection_grade_factory import SubsectionGradeFactory
34
from .utils import mock_get_score, mock_get_submissions_score
35 36 37 38 39 40 41 42 43 44


class GradeTestBase(SharedModuleStoreTestCase):
    """
    Base class for Course- and SubsectionGradeFactory tests.
    """
    @classmethod
    def setUpClass(cls):
        super(GradeTestBase, cls).setUpClass()
        cls.course = CourseFactory.create()
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
        with cls.store.bulk_operations(cls.course.id):
            cls.chapter = ItemFactory.create(
                parent=cls.course,
                category="chapter",
                display_name="Test Chapter"
            )
            cls.sequence = ItemFactory.create(
                parent=cls.chapter,
                category='sequential',
                display_name="Test Sequential 1",
                graded=True,
                format="Homework"
            )
            cls.vertical = ItemFactory.create(
                parent=cls.sequence,
                category='vertical',
                display_name='Test Vertical 1'
            )
            problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
                question_text='The correct answer is Choice 3',
                choices=[False, False, True, False],
                choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
            )
            cls.problem = ItemFactory.create(
                parent=cls.vertical,
                category="problem",
                display_name="Test Problem",
                data=problem_xml
            )
            cls.sequence2 = ItemFactory.create(
                parent=cls.chapter,
                category='sequential',
                display_name="Test Sequential 2",
                graded=True,
                format="Homework"
            )
            cls.problem2 = ItemFactory.create(
                parent=cls.sequence2,
                category="problem",
                display_name="Test Problem",
                data=problem_xml
            )
87 88 89 90 91 92 93 94 95
            # AED 2017-06-19: make cls.sequence belong to multiple parents,
            # so we can test that DAGs with this shape are handled correctly.
            cls.chapter_2 = ItemFactory.create(
                parent=cls.course,
                category='chapter',
                display_name='Test Chapter 2'
            )
            cls.chapter_2.children.append(cls.sequence.location)
            cls.store.update_item(cls.chapter_2, UserFactory().id)
96 97 98

    def setUp(self):
        super(GradeTestBase, self).setUp()
99
        self.request = get_mock_request(UserFactory())
100
        self.client.login(username=self.request.user.username, password="test")
101
        self._update_grading_policy()
102
        self.course_structure = get_course_blocks(self.request.user, self.course.location)
103
        self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
104 105
        CourseEnrollment.enroll(self.request.user, self.course.id)

106 107 108 109 110
    def _update_grading_policy(self, passing=0.5):
        """
        Updates the course's grading policy.
        """
        self.grading_policy = {
111 112 113 114 115 116 117 118 119 120
            "GRADER": [
                {
                    "type": "Homework",
                    "min_count": 1,
                    "drop_count": 0,
                    "short_label": "HW",
                    "weight": 1.0,
                },
            ],
            "GRADE_CUTOFFS": {
121
                "Pass": passing,
122 123
            },
        }
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
        self.course.set_grading_policy(self.grading_policy)
        self.store.update_item(self.course, 0)


@ddt.ddt
class TestCourseGradeFactory(GradeTestBase):
    """
    Test that CourseGrades are calculated properly
    """
    def _assert_zero_grade(self, course_grade, expected_grade_class):
        """
        Asserts whether the given course_grade is as expected with
        zero values.
        """
        self.assertIsInstance(course_grade, expected_grade_class)
        self.assertIsNone(course_grade.letter_grade)
        self.assertEqual(course_grade.percent, 0.0)
        self.assertIsNotNone(course_grade.chapter_grades)
142

143 144 145 146 147 148 149 150 151 152 153 154 155
    def test_course_grade_no_access(self):
        """
        Test to ensure a grade can ba calculated for a student in a course, even if they themselves do not have access.
        """
        invisible_course = CourseFactory.create(visible_to_staff_only=True)
        access = has_access(self.request.user, 'load', invisible_course)
        self.assertEqual(access.has_access, False)
        self.assertEqual(access.error_code, 'not_visible_to_user')

        # with self.assertNoExceptionRaised: <- this isn't a real method, it's an implicit assumption
        grade = CourseGradeFactory().create(self.request.user, invisible_course)
        self.assertEqual(grade.percent, 0)

156
    @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
157 158 159 160 161 162 163 164 165 166
    @ddt.data(
        (True, True),
        (True, False),
        (False, True),
        (False, False),
    )
    @ddt.unpack
    def test_course_grade_feature_gating(self, feature_flag, course_setting):
        # Grades are only saved if the feature flag and the advanced setting are
        # both set to True.
167
        grade_factory = CourseGradeFactory()
168 169 170 171 172 173
        with persistent_grades_feature_flags(
            global_flag=feature_flag,
            enabled_for_all_courses=False,
            course_id=self.course.id,
            enabled_for_course=course_setting
        ):
174
            with patch('lms.djangoapps.grades.models.PersistentCourseGrade.read') as mock_read_grade:
175
                grade_factory.create(self.request.user, self.course)
176
        self.assertEqual(mock_read_grade.called, feature_flag and course_setting)
177

178
    def test_create(self):
179
        grade_factory = CourseGradeFactory()
180 181 182 183 184

        def _assert_create(expected_pass):
            """
            Creates the grade, ensuring it is as expected.
            """
185
            course_grade = grade_factory.create(self.request.user, self.course)
186 187 188
            self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
            self.assertEqual(course_grade.percent, 0.5)

189
        with self.assertNumQueries(13), mock_get_score(1, 2):
190 191
            _assert_create(expected_pass=True)

192
        with self.assertNumQueries(13), mock_get_score(1, 2):
193 194 195 196 197 198 199
            grade_factory.update(self.request.user, self.course)

        with self.assertNumQueries(1):
            _assert_create(expected_pass=True)

        self._update_grading_policy(passing=0.9)

Tyler Hallada committed
200
        with self.assertNumQueries(8):
201
            _assert_create(expected_pass=False)
202

203
    @ddt.data(True, False)
204
    def test_create_zero(self, assume_zero_enabled):
205 206
        with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
            grade_factory = CourseGradeFactory()
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221
            course_grade = grade_factory.create(self.request.user, self.course)
            self._assert_zero_grade(course_grade, ZeroCourseGrade if assume_zero_enabled else CourseGrade)

    def test_create_zero_subs_grade_for_nonzero_course_grade(self):
        with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT), waffle().override(WRITE_ONLY_IF_ENGAGED):
            subsection = self.course_structure[self.sequence.location]
            with mock_get_score(1, 2):
                self.subsection_grade_factory.update(subsection)
            course_grade = CourseGradeFactory().update(self.request.user, self.course)
            subsection1_grade = course_grade.subsection_grades[self.sequence.location]
            subsection2_grade = course_grade.subsection_grades[self.sequence2.location]
            self.assertIsInstance(subsection1_grade, SubsectionGrade)
            self.assertIsInstance(subsection2_grade, ZeroSubsectionGrade)

    def test_read(self):
222
        grade_factory = CourseGradeFactory()
223
        with mock_get_score(1, 2):
224
            grade_factory.update(self.request.user, self.course)
225

226 227 228 229 230 231 232 233
        def _assert_read():
            """
            Reads the grade, ensuring it is as expected and requires just one query
            """
            with self.assertNumQueries(1):
                course_grade = grade_factory.read(self.request.user, self.course)
            self.assertEqual(course_grade.letter_grade, u'Pass')
            self.assertEqual(course_grade.percent, 0.5)
234

235 236 237
        _assert_read()
        self._update_grading_policy(passing=0.9)
        _assert_read()
238

239 240 241 242 243 244 245 246 247
    @ddt.data(True, False)
    def test_read_zero(self, assume_zero_enabled):
        with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
            grade_factory = CourseGradeFactory()
            course_grade = grade_factory.read(self.request.user, course_key=self.course.id)
            if assume_zero_enabled:
                self._assert_zero_grade(course_grade, ZeroCourseGrade)
            else:
                self.assertIsNone(course_grade)
248

249 250 251 252 253 254 255 256 257 258 259 260 261 262
    @ddt.data(True, False)
    def test_iter_force_update(self, force_update):
        base_string = 'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.{}'
        desired_method_name = base_string.format('update' if force_update else 'create')
        undesired_method_name = base_string.format('create' if force_update else 'update')
        with patch(desired_method_name) as desired_call:
            with patch(undesired_method_name) as undesired_call:
                set(CourseGradeFactory().iter(
                    users=[self.request.user], course=self.course, force_update=force_update
                ))

        self.assertTrue(desired_call.called)
        self.assertFalse(undesired_call.called)

263 264

@ddt.ddt
265
class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
266 267 268 269 270 271 272 273
    """
    Tests for SubsectionGradeFactory functionality.

    Ensures that SubsectionGrades are created and updated properly, that
    persistent grades are functioning as expected, and that the flag to
    enable saving subsection grades blocks/enables that feature as expected.
    """

274 275 276 277 278 279 280 281 282
    def assert_grade(self, grade, expected_earned, expected_possible):
        """
        Asserts that the given grade object has the expected score.
        """
        self.assertEqual(
            (grade.all_total.earned, grade.all_total.possible),
            (expected_earned, expected_possible),
        )

283 284
    def test_create(self):
        """
285 286 287 288 289 290 291 292 293 294 295
        Assuming the underlying score reporting methods work,
        test that the score is calculated properly.
        """
        with mock_get_score(1, 2):
            grade = self.subsection_grade_factory.create(self.sequence)
        self.assert_grade(grade, 1, 2)

    def test_create_internals(self):
        """
        Tests to ensure that a persistent subsection grade is
        created, saved, then fetched on re-request.
296
        """
297
        with patch(
298 299 300
            'lms.djangoapps.grades.new.subsection_grade.PersistentSubsectionGrade.create_grade',
            wraps=PersistentSubsectionGrade.create_grade
        ) as mock_create_grade:
301
            with patch(
302
                'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory._get_bulk_cached_grade',
303
                wraps=self.subsection_grade_factory._get_bulk_cached_grade
304
            ) as mock_get_bulk_cached_grade:
305
                with self.assertNumQueries(14):
306
                    grade_a = self.subsection_grade_factory.create(self.sequence)
307
                self.assertTrue(mock_get_bulk_cached_grade.called)
308
                self.assertTrue(mock_create_grade.called)
309

310
                mock_get_bulk_cached_grade.reset_mock()
311 312
                mock_create_grade.reset_mock()

Tyler Hallada committed
313
                with self.assertNumQueries(1):
314
                    grade_b = self.subsection_grade_factory.create(self.sequence)
315
                self.assertTrue(mock_get_bulk_cached_grade.called)
316
                self.assertFalse(mock_create_grade.called)
317 318

        self.assertEqual(grade_a.url_name, grade_b.url_name)
319
        grade_b.all_total.first_attempted = None
320 321
        self.assertEqual(grade_a.all_total, grade_b.all_total)

322 323 324 325 326 327 328 329 330
    def test_update(self):
        """
        Assuming the underlying score reporting methods work,
        test that the score is calculated properly.
        """
        with mock_get_score(1, 2):
            grade = self.subsection_grade_factory.update(self.sequence)
        self.assert_grade(grade, 1, 2)

331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
    def test_write_only_if_engaged(self):
        """
        Test that scores are not persisted when a learner has
        never attempted a problem, but are persisted if the
        learner's state has been deleted.
        """
        with waffle().override(WRITE_ONLY_IF_ENGAGED):
            with mock_get_score(0, 0, None):
                self.subsection_grade_factory.update(self.sequence)
        # ensure no grades have been persisted
        self.assertEqual(0, len(PersistentSubsectionGrade.objects.all()))

        with waffle().override(WRITE_ONLY_IF_ENGAGED):
            with mock_get_score(0, 0, None):
                self.subsection_grade_factory.update(self.sequence, score_deleted=True)
        # ensure a grade has been persisted
        self.assertEqual(1, len(PersistentSubsectionGrade.objects.all()))

349 350 351 352 353 354 355 356 357 358 359
    def test_update_if_higher(self):
        def verify_update_if_higher(mock_score, expected_grade):
            """
            Updates the subsection grade and verifies the
            resulting grade is as expected.
            """
            with mock_get_score(*mock_score):
                grade = self.subsection_grade_factory.update(self.sequence, only_if_higher=True)
                self.assert_grade(grade, *expected_grade)

        verify_update_if_higher((1, 2), (1, 2))  # previous value was non-existent
360 361
        verify_update_if_higher((2, 4), (2, 4))  # previous value was equivalent
        verify_update_if_higher((1, 4), (2, 4))  # previous value was greater
362 363
        verify_update_if_higher((3, 4), (3, 4))  # previous value was less

364
    @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
365 366 367 368 369 370 371 372 373 374 375
    @ddt.data(
        (True, True),
        (True, False),
        (False, True),
        (False, False),
    )
    @ddt.unpack
    def test_subsection_grade_feature_gating(self, feature_flag, course_setting):
        # Grades are only saved if the feature flag and the advanced setting are
        # both set to True.
        with patch(
376
            'lms.djangoapps.grades.models.PersistentSubsectionGrade.bulk_read_grades'
377
        ) as mock_read_saved_grade:
378 379 380 381 382 383
            with persistent_grades_feature_flags(
                global_flag=feature_flag,
                enabled_for_all_courses=False,
                course_id=self.course.id,
                enabled_for_course=course_setting
            ):
384
                self.subsection_grade_factory.create(self.sequence)
385 386 387
        self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)


388
@ddt.ddt
389 390 391 392 393
class ZeroGradeTest(GradeTestBase):
    """
    Tests ZeroCourseGrade (and, implicitly, ZeroSubsectionGrade)
    functionality.
    """
394 395
    @ddt.data(True, False)
    def test_zero(self, assume_zero_enabled):
396 397 398
        """
        Creates a ZeroCourseGrade and ensures it's empty.
        """
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
        with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
            course_data = CourseData(self.request.user, structure=self.course_structure)
            chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
            for chapter in chapter_grades:
                for section in chapter_grades[chapter]['sections']:
                    for score in section.problem_scores.itervalues():
                        self.assertEqual(score.earned, 0)
                        self.assertEqual(score.first_attempted, None)
                    self.assertEqual(section.all_total.earned, 0)

    @ddt.data(True, False)
    def test_zero_null_scores(self, assume_zero_enabled):
        """
        Creates a zero course grade and ensures that null scores aren't included in the section problem scores.
        """
        with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
            with patch('lms.djangoapps.grades.new.subsection_grade.get_score', return_value=None):
                course_data = CourseData(self.request.user, structure=self.course_structure)
                chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
                for chapter in chapter_grades:
                    self.assertNotEqual({}, chapter_grades[chapter]['sections'])
                    for section in chapter_grades[chapter]['sections']:
                        self.assertEqual({}, section.problem_scores)
422 423


424 425 426 427 428 429 430
class SubsectionGradeTest(GradeTestBase):
    """
    Tests SubsectionGrade functionality.
    """

    def test_save_and_load(self):
        """
431 432
        Test that grades are persisted to the database properly,
        and that loading saved grades returns the same data.
433 434
        """
        # Create a grade that *isn't* saved to the database
435
        input_grade = SubsectionGrade(self.sequence)
436
        input_grade.init_from_structure(
437 438
            self.request.user,
            self.course_structure,
439 440
            self.subsection_grade_factory._submissions_scores,
            self.subsection_grade_factory._csm_scores,
441 442 443 444
        )
        self.assertEqual(PersistentSubsectionGrade.objects.count(), 0)

        # save to db, and verify object is in database
445
        input_grade.create_model(self.request.user)
446 447 448
        self.assertEqual(PersistentSubsectionGrade.objects.count(), 1)

        # load from db, and ensure output matches input
449
        loaded_grade = SubsectionGrade(self.sequence)
450 451 452 453
        saved_model = PersistentSubsectionGrade.read_grade(
            user_id=self.request.user.id,
            usage_key=self.sequence.location,
        )
454 455
        loaded_grade.init_from_model(
            self.request.user,
456 457
            saved_model,
            self.course_structure,
458 459
            self.subsection_grade_factory._submissions_scores,
            self.subsection_grade_factory._csm_scores,
460 461 462
        )

        self.assertEqual(input_grade.url_name, loaded_grade.url_name)
463
        loaded_grade.all_total.first_attempted = None
464
        self.assertEqual(input_grade.all_total, loaded_grade.all_total)
465 466 467


@ddt.ddt
468
class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
469 470 471 472
    """
    Test grading of different problem types.
    """

473
    SCORED_BLOCK_COUNT = 7
474
    ACTUAL_TOTAL_POSSIBLE = 17.0
475

476 477 478 479 480 481
    @classmethod
    def setUpClass(cls):
        super(TestMultipleProblemTypesSubsectionScores, cls).setUpClass()
        cls.load_scoreable_course()
        chapter1 = cls.course.get_children()[0]
        cls.seq1 = chapter1.get_children()[0]
482 483 484 485 486 487

    def setUp(self):
        super(TestMultipleProblemTypesSubsectionScores, self).setUp()
        password = u'test'
        self.student = UserFactory.create(is_staff=False, username=u'test_student', password=password)
        self.client.login(username=self.student.username, password=password)
488
        self.request = get_mock_request(self.student)
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517
        self.course_structure = get_course_blocks(self.student, self.course.location)

    @classmethod
    def load_scoreable_course(cls):
        """
        This test course lives at `common/test/data/scoreable`.

        For details on the contents and structure of the file, see
        `common/test/data/scoreable/README`.
        """

        course_items = import_course_from_xml(
            cls.store,
            'test_user',
            TEST_DATA_DIR,
            source_dirs=['scoreable'],
            static_content_store=None,
            target_id=cls.store.make_course_key('edX', 'scoreable', '3000'),
            raise_on_failure=True,
            create_if_not_present=True,
        )

        cls.course = course_items[0]

    def test_score_submission_for_all_problems(self):
        subsection_factory = SubsectionGradeFactory(
            self.student,
            course_structure=self.course_structure,
            course=self.course,
518
        )
519 520 521 522 523 524 525 526 527 528 529 530
        score = subsection_factory.create(self.seq1)

        self.assertEqual(score.all_total.earned, 0.0)
        self.assertEqual(score.all_total.possible, self.ACTUAL_TOTAL_POSSIBLE)

        # Choose arbitrary, non-default values for earned and possible.
        earned_per_block = 3.0
        possible_per_block = 7.0
        with mock_get_submissions_score(earned_per_block, possible_per_block) as mock_score:
            # Configure one block to return no possible score, the rest to return 3.0 earned / 7.0 possible
            block_count = self.SCORED_BLOCK_COUNT - 1
            mock_score.side_effect = itertools.chain(
531
                [(earned_per_block, None, earned_per_block, None, datetime.datetime(2000, 1, 1))],
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553
                itertools.repeat(mock_score.return_value)
            )
            score = subsection_factory.update(self.seq1)
        self.assertEqual(score.all_total.earned, earned_per_block * block_count)
        self.assertEqual(score.all_total.possible, possible_per_block * block_count)


@ddt.ddt
class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase):
    """
    Test that changing the metadata on a block has the desired effect on the
    persisted score.
    """
    default_problem_metadata = {
        u'graded': True,
        u'weight': 2.5,
        u'due': datetime.datetime(2099, 3, 15, 12, 30, 0, tzinfo=pytz.utc),
    }

    def setUp(self):
        super(TestVariedMetadata, self).setUp()
        self.course = CourseFactory.create()
554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570
        with self.store.bulk_operations(self.course.id):
            self.chapter = ItemFactory.create(
                parent=self.course,
                category="chapter",
                display_name="Test Chapter"
            )
            self.sequence = ItemFactory.create(
                parent=self.chapter,
                category='sequential',
                display_name="Test Sequential 1",
                graded=True
            )
            self.vertical = ItemFactory.create(
                parent=self.sequence,
                category='vertical',
                display_name='Test Vertical 1'
            )
571 572 573 574 575 576 577 578
        self.problem_xml = u'''
            <problem url_name="capa-optionresponse">
              <optionresponse>
                <optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
                <optioninput options="('Correct', 'Incorrect')" correct="Correct"></optioninput>
              </optionresponse>
            </problem>
        '''
579
        self.request = get_mock_request(UserFactory())
580 581
        self.client.login(username=self.request.user.username, password="test")
        CourseEnrollment.enroll(self.request.user, self.course.id)
582 583 584 585 586 587 588 589 590 591

    def _get_altered_metadata(self, alterations):
        """
        Returns a copy of the default_problem_metadata dict updated with the
        specified alterations.
        """
        metadata = self.default_problem_metadata.copy()
        metadata.update(alterations)
        return metadata

592
    def _add_problem_with_alterations(self, alterations):
593
        """
594
        Add a problem to the course with the specified metadata alterations.
595 596
        """

597 598 599 600 601 602 603 604
        metadata = self._get_altered_metadata(alterations)
        ItemFactory.create(
            parent=self.vertical,
            category="problem",
            display_name="problem",
            data=self.problem_xml,
            metadata=metadata,
        )
605

606
    def _get_score(self):
607
        """
608 609
        Return the score of the test problem when one correct problem (out of
        two) is submitted.
610
        """
611 612

        self.submit_question_answer(u'problem', {u'2_1': u'Correct'})
613 614 615 616 617 618 619
        course_structure = get_course_blocks(self.request.user, self.course.location)
        subsection_factory = SubsectionGradeFactory(
            self.request.user,
            course_structure=course_structure,
            course=self.course,
        )
        return subsection_factory.create(self.sequence)
620 621 622 623 624 625 626 627 628 629

    @ddt.data(
        ({}, 1.25, 2.5),
        ({u'weight': 27}, 13.5, 27),
        ({u'weight': 1.0}, 0.5, 1.0),
        ({u'weight': 0.0}, 0.0, 0.0),
        ({u'weight': None}, 1.0, 2.0),
    )
    @ddt.unpack
    def test_weight_metadata_alterations(self, alterations, expected_earned, expected_possible):
630 631
        self._add_problem_with_alterations(alterations)
        score = self._get_score()
632 633 634 635 636 637 638 639 640
        self.assertEqual(score.all_total.earned, expected_earned)
        self.assertEqual(score.all_total.possible, expected_possible)

    @ddt.data(
        ({u'graded': True}, 1.25, 2.5),
        ({u'graded': False}, 0.0, 0.0),
    )
    @ddt.unpack
    def test_graded_metadata_alterations(self, alterations, expected_earned, expected_possible):
641 642
        self._add_problem_with_alterations(alterations)
        score = self._get_score()
643 644 645
        self.assertEqual(score.graded_total.earned, expected_earned)
        self.assertEqual(score.graded_total.possible, expected_possible)

646

647
class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCase):
648 649 650 651 652 653 654 655
    """
    Tests logging in the course grades module.
    Uses a larger course structure than other
    unit tests.
    """
    def setUp(self):
        super(TestCourseGradeLogging, self).setUp()
        self.course = CourseFactory.create()
656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717
        with self.store.bulk_operations(self.course.id):
            self.chapter = ItemFactory.create(
                parent=self.course,
                category="chapter",
                display_name="Test Chapter"
            )
            self.sequence = ItemFactory.create(
                parent=self.chapter,
                category='sequential',
                display_name="Test Sequential 1",
                graded=True
            )
            self.sequence_2 = ItemFactory.create(
                parent=self.chapter,
                category='sequential',
                display_name="Test Sequential 2",
                graded=True
            )
            self.sequence_3 = ItemFactory.create(
                parent=self.chapter,
                category='sequential',
                display_name="Test Sequential 3",
                graded=False
            )
            self.vertical = ItemFactory.create(
                parent=self.sequence,
                category='vertical',
                display_name='Test Vertical 1'
            )
            self.vertical_2 = ItemFactory.create(
                parent=self.sequence_2,
                category='vertical',
                display_name='Test Vertical 2'
            )
            self.vertical_3 = ItemFactory.create(
                parent=self.sequence_3,
                category='vertical',
                display_name='Test Vertical 3'
            )
            problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
                question_text='The correct answer is Choice 2',
                choices=[False, False, True, False],
                choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
            )
            self.problem = ItemFactory.create(
                parent=self.vertical,
                category="problem",
                display_name="test_problem_1",
                data=problem_xml
            )
            self.problem_2 = ItemFactory.create(
                parent=self.vertical_2,
                category="problem",
                display_name="test_problem_2",
                data=problem_xml
            )
            self.problem_3 = ItemFactory.create(
                parent=self.vertical_3,
                category="problem",
                display_name="test_problem_3",
                data=problem_xml
            )
718
        self.request = get_mock_request(UserFactory())
719 720 721 722 723 724 725
        self.client.login(username=self.request.user.username, password="test")
        self.course_structure = get_course_blocks(self.request.user, self.course.location)
        self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
        CourseEnrollment.enroll(self.request.user, self.course.id)

    def _create_course_grade_and_check_logging(
            self,
726
            factory_method,
727
            log_mock,
728
            log_statement,
729 730 731 732 733
    ):
        """
        Creates a course grade and asserts that the associated logging
        matches the expected totals passed in to the function.
        """
734
        factory_method(self.request.user, self.course)
735 736 737
        self.assertIn(log_statement, log_mock.call_args[0][0])
        self.assertIn(unicode(self.course.id), log_mock.call_args[0][1])
        self.assertEquals(self.request.user.id, log_mock.call_args[0][2])
738 739

    def test_course_grade_logging(self):
740
        grade_factory = CourseGradeFactory()
741 742 743 744 745 746
        with persistent_grades_feature_flags(
            global_flag=True,
            enabled_for_all_courses=False,
            course_id=self.course.id,
            enabled_for_course=True
        ):
747 748
            with patch('lms.djangoapps.grades.new.course_grade_factory.log') as log_mock:
                # read, but not persisted
749
                self._create_course_grade_and_check_logging(grade_factory.create, log_mock.info, u'Update')
750 751

                # update and persist
752
                self._create_course_grade_and_check_logging(grade_factory.update, log_mock.info, u'Update')
753 754

                # read from persistence, using create
755
                self._create_course_grade_and_check_logging(grade_factory.create, log_mock.debug, u'Read')
756 757

                # read from persistence, using read
758
                self._create_course_grade_and_check_logging(grade_factory.read, log_mock.debug, u'Read')
759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803


class TestCourseGradeFactory(GradeTestBase):
    def test_course_grade_summary(self):
        with mock_get_score(1, 2):
            self.subsection_grade_factory.update(self.course_structure[self.sequence.location])
        course_grade = CourseGradeFactory().update(self.request.user, self.course)

        actual_summary = course_grade.summary

        # We should have had a zero subsection grade for sequential 2, since we never
        # gave it a mock score above.
        expected_summary = {
            'grade': None,
            'grade_breakdown': {
                'Homework': {
                    'category': 'Homework',
                    'percent': 0.25,
                    'detail': 'Homework = 25.00% of a possible 100.00%',
                }
            },
            'percent': 0.25,
            'section_breakdown': [
                {
                    'category': 'Homework',
                    'detail': u'Homework 1 - Test Sequential 1 - 50% (1/2)',
                    'label': u'HW 01',
                    'percent': 0.5
                },
                {
                    'category': 'Homework',
                    'detail': u'Homework 2 - Test Sequential 2 - 0% (0/1)',
                    'label': u'HW 02',
                    'percent': 0.0
                },
                {
                    'category': 'Homework',
                    'detail': u'Homework Average = 25%',
                    'label': u'HW Avg',
                    'percent': 0.25,
                    'prominent': True
                },
            ]
        }
        self.assertEqual(expected_summary, actual_summary)