diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 09caefe878..d0792ab7a8 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -21,13 +21,15 @@ class ScoreBase(object): display_name (string) - the display name of the module module_id (UsageKey) - the location of the module graded (boolean) - whether or not this module is graded + attempted (boolean) - whether the module was attempted """ __metaclass__ = abc.ABCMeta - def __init__(self, graded, display_name, module_id): + def __init__(self, graded, display_name, module_id, attempted): self.graded = graded self.display_name = display_name self.module_id = module_id + self.attempted = attempted def __eq__(self, other): if type(other) is type(self): @@ -91,15 +93,19 @@ def aggregate_scores(scores, display_name="summary", location=None): """ total_correct_graded = float_sum(score.earned for score in scores if score.graded) total_possible_graded = float_sum(score.possible for score in scores if score.graded) + any_attempted_graded = any(score.attempted for score in scores if score.graded) total_correct = float_sum(score.earned for score in scores) total_possible = float_sum(score.possible for score in scores) + any_attempted = any(score.attempted for score in scores) - #regardless of whether it is graded - all_total = AggregatedScore(total_correct, total_possible, False, display_name, location) + # regardless of whether it is graded + all_total = AggregatedScore(total_correct, total_possible, False, display_name, location, any_attempted) - #selecting only graded things - graded_total = AggregatedScore(total_correct_graded, total_possible_graded, True, display_name, location) + # selecting only graded things + graded_total = AggregatedScore( + total_correct_graded, total_possible_graded, True, display_name, location, any_attempted_graded, + ) return all_total, graded_total diff --git a/common/lib/xmodule/xmodule/tests/test_graders.py b/common/lib/xmodule/xmodule/tests/test_graders.py index 341146dc23..9838f2a9fe 100644 --- a/common/lib/xmodule/xmodule/tests/test_graders.py +++ b/common/lib/xmodule/xmodule/tests/test_graders.py @@ -12,9 +12,12 @@ class GradesheetTest(unittest.TestCase): def test_weighted_grading(self): scores = [] - agg_fields = dict(display_name="aggregated_score", module_id=None) - prob_fields = dict(display_name="problem_score", module_id=None, raw_earned=0, raw_possible=0, weight=0) + agg_fields = dict(display_name="aggregated_score", module_id=None, attempted=False) + prob_fields = dict( + display_name="problem_score", module_id=None, raw_earned=0, raw_possible=0, weight=0, attempted=False, + ) + # No scores all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name']) self.assertEqual( all_total, @@ -25,6 +28,7 @@ class GradesheetTest(unittest.TestCase): AggregatedScore(tw_earned=0, tw_possible=0, graded=True, **agg_fields), ) + # (0/5 non-graded) scores.append(ProblemScore(weighted_earned=0, weighted_possible=5, graded=False, **prob_fields)) all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name']) self.assertEqual( @@ -36,6 +40,9 @@ class GradesheetTest(unittest.TestCase): AggregatedScore(tw_earned=0, tw_possible=0, graded=True, **agg_fields), ) + # (0/5 non-graded) + (3/5 graded) = 3/10 total, 3/5 graded + prob_fields['attempted'] = True + agg_fields['attempted'] = True scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields)) all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name']) self.assertAlmostEqual( @@ -47,6 +54,7 @@ class GradesheetTest(unittest.TestCase): AggregatedScore(tw_earned=3, tw_possible=5, graded=True, **agg_fields), ) + # (0/5 non-graded) + (3/5 graded) + (2/5 graded) = 5/15 total, 5/10 graded scores.append(ProblemScore(weighted_earned=2, weighted_possible=5, graded=True, **prob_fields)) all_total, graded_total = aggregate_scores(scores, display_name=agg_fields['display_name']) self.assertAlmostEqual( @@ -73,25 +81,26 @@ class GraderTest(unittest.TestCase): 'Midterm': [], } + common_fields = dict(graded=True, module_id=None, attempted=True) test_gradesheet = { 'Homework': [ - AggregatedScore(tw_earned=2, tw_possible=20.0, graded=True, display_name='hw1', module_id=None), - AggregatedScore(tw_earned=16, tw_possible=16.0, graded=True, display_name='hw2', module_id=None) + AggregatedScore(tw_earned=2, tw_possible=20.0, display_name='hw1', **common_fields), + AggregatedScore(tw_earned=16, tw_possible=16.0, display_name='hw2', **common_fields), ], # The dropped scores should be from the assignments that don't exist yet 'Lab': [ - AggregatedScore(tw_earned=1, tw_possible=2.0, graded=True, display_name='lab1', module_id=None), # Dropped - AggregatedScore(tw_earned=1, tw_possible=1.0, graded=True, display_name='lab2', module_id=None), - AggregatedScore(tw_earned=1, tw_possible=1.0, graded=True, display_name='lab3', module_id=None), - AggregatedScore(tw_earned=5, tw_possible=25.0, graded=True, display_name='lab4', module_id=None), # Dropped - AggregatedScore(tw_earned=3, tw_possible=4.0, graded=True, display_name='lab5', module_id=None), # Dropped - AggregatedScore(tw_earned=6, tw_possible=7.0, graded=True, display_name='lab6', module_id=None), - AggregatedScore(tw_earned=5, tw_possible=6.0, graded=True, display_name='lab7', module_id=None), + AggregatedScore(tw_earned=1, tw_possible=2.0, display_name='lab1', **common_fields), # Dropped + AggregatedScore(tw_earned=1, tw_possible=1.0, display_name='lab2', **common_fields), + AggregatedScore(tw_earned=1, tw_possible=1.0, display_name='lab3', **common_fields), + AggregatedScore(tw_earned=5, tw_possible=25.0, display_name='lab4', **common_fields), # Dropped + AggregatedScore(tw_earned=3, tw_possible=4.0, display_name='lab5', **common_fields), # Dropped + AggregatedScore(tw_earned=6, tw_possible=7.0, display_name='lab6', **common_fields), + AggregatedScore(tw_earned=5, tw_possible=6.0, display_name='lab7', **common_fields), ], 'Midterm': [ - AggregatedScore(tw_earned=50.5, tw_possible=100, graded=True, display_name="Midterm Exam", module_id=None), + AggregatedScore(tw_earned=50.5, tw_possible=100, display_name="Midterm Exam", **common_fields), ], } diff --git a/lms/djangoapps/grades/new/subsection_grade.py b/lms/djangoapps/grades/new/subsection_grade.py index 1ab85d3ccf..7d25ee3f20 100644 --- a/lms/djangoapps/grades/new/subsection_grade.py +++ b/lms/djangoapps/grades/new/subsection_grade.py @@ -63,6 +63,14 @@ class SubsectionGrade(object): """ return self.locations_to_scores.values() + @property + def attempted(self): + """ + Returns whether any problem in this subsection + was attempted by the student. + """ + return self.all_total.attempted + def init_from_structure(self, student, course_structure, submissions_scores, csm_scores): """ Compute the grade of this subsection for the given student and course. @@ -90,6 +98,7 @@ class SubsectionGrade(object): graded=True, display_name=self.display_name, module_id=self.location, + attempted=True, # TODO TNL-5930 ) self.all_total = AggregatedScore( tw_earned=model.earned_all, @@ -97,6 +106,7 @@ class SubsectionGrade(object): graded=False, display_name=self.display_name, module_id=self.location, + attempted=True, # TODO TNL-5930 ) self._log_event(log.debug, u"init_from_model", student) return self diff --git a/lms/djangoapps/grades/scores.py b/lms/djangoapps/grades/scores.py index 22e378bda6..ca1d4865a4 100644 --- a/lms/djangoapps/grades/scores.py +++ b/lms/djangoapps/grades/scores.py @@ -102,7 +102,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): # Priority order for retrieving the scores: # submissions API -> CSM -> grades persisted block -> latest block content - raw_earned, raw_possible, weighted_earned, weighted_possible = ( + raw_earned, raw_possible, weighted_earned, weighted_possible, attempted = ( _get_score_from_submissions(submissions_scores, block) or _get_score_from_csm(csm_scores, block, weight) or _get_score_from_persisted_or_latest_block(persisted_block, block, weight) @@ -124,6 +124,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): graded, display_name=display_name_with_default_escaped(block), module_id=block.location, + attempted=attempted, ) @@ -151,9 +152,10 @@ def _get_score_from_submissions(submissions_scores, block): if submissions_scores: submission_value = submissions_scores.get(unicode(block.location)) if submission_value: + attempted = True weighted_earned, weighted_possible = submission_value assert weighted_earned >= 0.0 and weighted_possible > 0.0 # per contract from submissions API - return (None, None) + (weighted_earned, weighted_possible) + return (None, None) + (weighted_earned, weighted_possible) + (attempted,) def _get_score_from_csm(csm_scores, block, weight): @@ -175,9 +177,14 @@ def _get_score_from_csm(csm_scores, block, weight): score = csm_scores.get(block.location) has_valid_score = score and score.total is not None if has_valid_score: - raw_earned = score.correct if score.correct is not None else 0.0 + if score.correct is not None: + attempted = True + raw_earned = score.correct + else: + attempted = False + raw_earned = 0.0 raw_possible = score.total - return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (attempted,) def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): @@ -188,16 +195,20 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): the latest block content. """ raw_earned = 0.0 + attempted = False if persisted_block: raw_possible = persisted_block.raw_possible else: raw_possible = block.transformer_data[GradesTransformer].max_score + # TODO TNL-5982 remove defensive code for scorables without max_score if raw_possible is None: - return (raw_earned, raw_possible) + (None, None) + weighted_scores = (None, None) else: - return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + weighted_scores = weighted_score(raw_earned, raw_possible, weight) + + return (raw_earned, raw_possible) + weighted_scores + (attempted,) def _get_weight_from_block(persisted_block, block): diff --git a/lms/djangoapps/grades/tests/test_grades.py b/lms/djangoapps/grades/tests/test_grades.py index 4357f91939..162fb0bee4 100644 --- a/lms/djangoapps/grades/tests/test_grades.py +++ b/lms/djangoapps/grades/tests/test_grades.py @@ -248,6 +248,7 @@ class TestWeightedProblems(SharedModuleStoreTestCase): graded=expected_graded, display_name=None, # problem-specific, filled in by _verify_grades module_id=None, # problem-specific, filled in by _verify_grades + attempted=True, ) self._verify_grades(raw_earned, raw_possible, weight, expected_score) diff --git a/lms/djangoapps/grades/tests/test_new.py b/lms/djangoapps/grades/tests/test_new.py index 2804dc132a..f250e0c7c8 100644 --- a/lms/djangoapps/grades/tests/test_new.py +++ b/lms/djangoapps/grades/tests/test_new.py @@ -228,6 +228,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): self.assertFalse(mock_create_grade.called) self.assertEqual(grade_a.url_name, grade_b.url_name) + grade_b.all_total.attempted = False # TODO TNL-5930 self.assertEqual(grade_a.all_total, grade_b.all_total) def test_update(self): @@ -342,6 +343,7 @@ class SubsectionGradeTest(GradeTestBase): ) self.assertEqual(input_grade.url_name, loaded_grade.url_name) + loaded_grade.all_total.attempted = False # TODO TNL-5930 self.assertEqual(input_grade.all_total, loaded_grade.all_total) @@ -409,7 +411,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): # 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( - [(earned_per_block, None, earned_per_block, None)], + [(earned_per_block, None, earned_per_block, None, True)], itertools.repeat(mock_score.return_value) ) score = subsection_factory.update(self.seq1) diff --git a/lms/djangoapps/grades/tests/test_scores.py b/lms/djangoapps/grades/tests/test_scores.py index f7d6e34055..18b10e18b3 100644 --- a/lms/djangoapps/grades/tests/test_scores.py +++ b/lms/djangoapps/grades/tests/test_scores.py @@ -52,7 +52,7 @@ class TestGetScore(TestCase): PersistedBlockValue = namedtuple('PersistedBlockValue', 'exists, raw_possible, weight, graded') ContentBlockValue = namedtuple('ContentBlockValue', 'raw_possible, weight, explicit_graded') ExpectedResult = namedtuple( - 'ExpectedResult', 'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded' + 'ExpectedResult', 'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded, attempted' ) def _create_submissions_scores(self, submission_value): @@ -113,7 +113,9 @@ class TestGetScore(TestCase): PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ExpectedResult( - raw_earned=None, raw_possible=None, weighted_earned=50, weighted_possible=100, weight=40, graded=True + raw_earned=None, raw_possible=None, + weighted_earned=50, weighted_possible=100, + weight=40, graded=True, attempted=True, ), ), # same as above, except submissions doesn't exist; CSM values used @@ -123,7 +125,21 @@ class TestGetScore(TestCase): PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ExpectedResult( - raw_earned=10, raw_possible=40, weighted_earned=10, weighted_possible=40, weight=40, graded=True + raw_earned=10, raw_possible=40, + weighted_earned=10, weighted_possible=40, + weight=40, graded=True, attempted=True, + ), + ), + # CSM values exist, but with NULL earned score treated as not-attempted + ( + SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100), + CSMValue(exists=True, raw_earned=None, raw_possible=40), + PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), + ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), + ExpectedResult( + raw_earned=0, raw_possible=40, + weighted_earned=0, weighted_possible=40, + weight=40, graded=True, attempted=False, ), ), # neither submissions nor CSM exist; Persisted values used @@ -133,7 +149,9 @@ class TestGetScore(TestCase): PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ExpectedResult( - raw_earned=0, raw_possible=5, weighted_earned=0, weighted_possible=40, weight=40, graded=True + raw_earned=0, raw_possible=5, + weighted_earned=0, weighted_possible=40, + weight=40, graded=True, attempted=False, ), ), # none of submissions, CSM, or persisted exist; Latest content values used @@ -143,7 +161,9 @@ class TestGetScore(TestCase): PersistedBlockValue(exists=False, raw_possible=5, weight=40, graded=True), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ExpectedResult( - raw_earned=0, raw_possible=1, weighted_earned=0, weighted_possible=20, weight=20, graded=False + raw_earned=0, raw_possible=1, + weighted_earned=0, weighted_possible=20, + weight=20, graded=False, attempted=False, ), ), ) @@ -259,9 +279,10 @@ class TestInternalGetScoreFromBlock(TestCase): Verifies the result of _get_score_from_persisted_or_latest_block is as expected. """ # pylint: disable=unbalanced-tuple-unpacking - raw_earned, raw_possible, weighted_earned, weighted_possible = scores._get_score_from_persisted_or_latest_block( - persisted_block, block, weight, - ) + ( + raw_earned, raw_possible, weighted_earned, weighted_possible, attempted + ) = scores._get_score_from_persisted_or_latest_block(persisted_block, block, weight) + self.assertEquals(raw_earned, 0.0) self.assertEquals(raw_possible, expected_r_possible) self.assertEquals(weighted_earned, 0.0) @@ -269,6 +290,7 @@ class TestInternalGetScoreFromBlock(TestCase): self.assertEquals(weighted_possible, expected_r_possible) else: self.assertEquals(weighted_possible, weight) + self.assertFalse(attempted) @ddt.data( *itertools.product((0, 1, 5), (None, 0, 1, 5)) diff --git a/lms/djangoapps/grades/tests/utils.py b/lms/djangoapps/grades/tests/utils.py index 2bc038f5c8..33845ec267 100644 --- a/lms/djangoapps/grades/tests/utils.py +++ b/lms/djangoapps/grades/tests/utils.py @@ -24,17 +24,27 @@ def mock_get_score(earned=0, possible=1): Mocks the get_score function to return a valid grade. """ with patch('lms.djangoapps.grades.new.subsection_grade.get_score') as mock_score: - mock_score.return_value = ProblemScore(earned, possible, earned, possible, 1, True, None, None) + mock_score.return_value = ProblemScore( + raw_earned=earned, + raw_possible=possible, + weighted_earned=earned, + weighted_possible=possible, + weight=1, + graded=True, + display_name=None, + module_id=None, + attempted=True, + ) yield mock_score @contextmanager -def mock_get_submissions_score(earned=0, possible=1): +def mock_get_submissions_score(earned=0, possible=1, attempted=True): """ Mocks the _get_submissions_score function to return the specified values """ with patch('lms.djangoapps.grades.scores._get_score_from_submissions') as mock_score: - mock_score.return_value = (earned, possible, earned, possible) + mock_score.return_value = (earned, possible, earned, possible, attempted) yield mock_score