diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 862da791c0a4..b1f9612d1cf5 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -10,7 +10,11 @@ # This is a tuple for holding scores, either from problems or sections. # Section either indicates the name of the problem or the name of the section -Score = namedtuple("Score", "earned possible graded section") +ScoreT = namedtuple("Score", "earned possible graded section attempted") + + +def Score(earned, possible, graded, section, attempted=True): + return ScoreT(earned, possible, graded, section, attempted) def aggregate_scores(scores, section_name="summary"): @@ -26,16 +30,25 @@ def aggregate_scores(scores, section_name="summary"): total_correct = sum(score.earned for score in scores) total_possible = sum(score.possible for score in scores) + any_attempted = True in (score.attempted for score in scores) + any_attempted_graded = True in (score.attempted for score in scores if score.graded) + #regardless of whether or not it is graded - all_total = Score(total_correct, - total_possible, - False, - section_name) + all_total = Score( + total_correct, + total_possible, + False, + section_name, + any_attempted + ) #selecting only graded things - graded_total = Score(total_correct_graded, - total_possible_graded, - True, - section_name) + graded_total = Score( + total_correct_graded, + total_possible_graded, + True, + section_name, + any_attempted_graded + ) return all_total, graded_total @@ -176,22 +189,46 @@ def __init__(self, sections): def grade(self, grade_sheet, generate_random_scores=False): total_percent = 0.0 + total_weight = 0.0 section_breakdown = [] grade_breakdown = [] + total_projected_percent = 0.0 + for subgrader, category, weight in self.sections: subgrade_result = subgrader.grade(grade_sheet, generate_random_scores) weighted_percent = subgrade_result['percent'] * weight section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight) + if category in grade_sheet: + attempted_overall = True in (score.attempted for score in grade_sheet[category]) + if attempted_overall: + total_weight += weight + + weighted_projected_percent = subgrade_result['projected_percent'] * weight + total_projected_percent += weighted_projected_percent + total_percent += weighted_percent section_breakdown += subgrade_result['section_breakdown'] - grade_breakdown.append({'percent': weighted_percent, 'detail': section_detail, 'category': category}) + grade_breakdown.append({ + 'percent': weighted_percent, + 'detail': section_detail, + 'category': category, + 'projected_percent': weighted_projected_percent, + }) + + if total_weight == 0: + projected_percent = 0 + else: + projected_percent = total_projected_percent / total_weight - return {'percent': total_percent, - 'section_breakdown': section_breakdown, - 'grade_breakdown': grade_breakdown} + return { + 'percent': total_percent, + 'section_breakdown': section_breakdown, + 'grade_breakdown': grade_breakdown, + 'projected_percent': projected_percent, + } class SingleSectionGrader(CourseGrader): @@ -219,27 +256,44 @@ def grade(self, grade_sheet, generate_random_scores=False): if generate_random_scores: # for debugging! earned = random.randint(2, 15) possible = random.randint(earned, 15) + attempted = True else: # We found the score earned = found_score.earned possible = found_score.possible + attempted = found_score.attempted percent = earned / float(possible) - detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name, - percent=percent, - earned=float(earned), - possible=float(possible)) + detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format( + name=self.name, + percent=percent, + earned=float(earned), + possible=float(possible) + ) + if attempted: + projected_percent = percent + else: + projected_percent = None else: percent = 0.0 detail = "{name} - 0% (?/?)".format(name=self.name) + projected_percent = None - breakdown = [{'percent': percent, 'label': self.short_label, - 'detail': detail, 'category': self.category, 'prominent': True}] + breakdown = [{ + 'percent': percent, + 'label': self.short_label, + 'detail': detail, + 'category': self.category, + 'prominent': True, + 'projected_percent': projected_percent, + }] - return {'percent': percent, - 'section_breakdown': breakdown, - #No grade_breakdown here - } + return { + 'percent': percent, + 'section_breakdown': breakdown, + #No grade_breakdown here + 'projected_percent': projected_percent, + } class AssignmentFormatGrader(CourseGrader): @@ -310,17 +364,20 @@ def total_with_drops(breakdown, drop_count): #Figure the homework scores scores = grade_sheet.get(self.type, []) breakdown = [] + projected_breakdown = [] for i in range(max(self.min_count, len(scores))): if i < len(scores) or generate_random_scores: if generate_random_scores: # for debugging! earned = random.randint(2, 15) possible = random.randint(earned, 15) section_name = "Generated" + attempted = True else: earned = scores[i].earned possible = scores[i].possible section_name = scores[i].section + attempted = scores[i].attempted percentage = earned / float(possible) summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})" @@ -330,16 +387,31 @@ def total_with_drops(breakdown, drop_count): percent=percentage, earned=float(earned), possible=float(possible)) + if attempted: + projected_percentage = percentage + else: + #TODO -- if the deadline has passed, then projected_percentage should be 0 and not None; else None + projected_percentage = None else: percentage = 0 - summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, - section_type=self.section_type) + summary = "{section_type} {index} Unreleased - 0% (?/?)".format( + index=i + self.starting_index, + section_type=self.section_type + ) + projected_percentage = None short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label) breakdown.append({'percent': percentage, 'label': short_label, - 'detail': summary, 'category': self.category}) + 'detail': summary, 'category': self.category, }) + if projected_percentage is not None: + projected_breakdown.append({'percent': projected_percentage, 'label': short_label, + 'detail': summary, 'category': self.category, }) + + drop_for_projected = max(len(projected_breakdown) - len(breakdown) + self.drop_count, 0) + + projected_total_percent, _ = total_with_drops(projected_breakdown, drop_for_projected) total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count) @@ -353,12 +425,15 @@ def total_with_drops(breakdown, drop_count): # SingleSectionGrader. total_detail = "{section_type} = {percent:.0%}".format(percent=total_percent, section_type=self.section_type) + projected_detail = total_detail total_label = "{short_label}".format(short_label=self.short_label) breakdown = [{'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True}, ] else: total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type) + projected_detail = "Projected {section_type} Average = {percent:.0%}".format(percent=projected_total_percent, + section_type=self.section_type) total_label = "{short_label} Avg".format(short_label=self.short_label) if self.show_only_average: @@ -366,9 +441,13 @@ def total_with_drops(breakdown, drop_count): if not self.hide_average: breakdown.append({'percent': total_percent, 'label': total_label, - 'detail': total_detail, 'category': self.category, 'prominent': True}) - - return {'percent': total_percent, - 'section_breakdown': breakdown, - #No grade_breakdown here - } + 'detail': total_detail, 'projected_detail': projected_detail, + 'category': self.category, 'prominent': True, + 'projected_percent': projected_total_percent}) + + return { + 'percent': total_percent, + 'section_breakdown': breakdown, + #No grade_breakdown here + 'projected_percent': projected_total_percent, + } diff --git a/common/lib/xmodule/xmodule/tests/test_graders.py b/common/lib/xmodule/xmodule/tests/test_graders.py index 1a9ba50dc44f..8e3d38a72548 100644 --- a/common/lib/xmodule/xmodule/tests/test_graders.py +++ b/common/lib/xmodule/xmodule/tests/test_graders.py @@ -13,20 +13,20 @@ def test_weighted_grading(self): Score.__sub__ = lambda me, other: (me.earned - other.earned) + (me.possible - other.possible) all_total, graded_total = aggregate_scores(scores) - self.assertEqual(all_total, Score(earned=0, possible=0, graded=False, section="summary")) - self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary")) + self.assertEqual(all_total, Score(earned=0, possible=0, graded=False, section="summary", attempted=False)) + self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary", attempted=False)) - scores.append(Score(earned=0, possible=5, graded=False, section="summary")) + scores.append(Score(earned=0, possible=5, graded=False, section="summary", attempted=False)) all_total, graded_total = aggregate_scores(scores) - self.assertEqual(all_total, Score(earned=0, possible=5, graded=False, section="summary")) - self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary")) + self.assertEqual(all_total, Score(earned=0, possible=5, graded=False, section="summary", attempted=False)) + self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary", attempted=False)) - scores.append(Score(earned=3, possible=5, graded=True, section="summary")) + scores.append(Score(earned=3, possible=5, graded=True, section="summary", attempted=True)) all_total, graded_total = aggregate_scores(scores) self.assertAlmostEqual(all_total, Score(earned=3, possible=10, graded=False, section="summary")) self.assertAlmostEqual(graded_total, Score(earned=3, possible=5, graded=True, section="summary")) - scores.append(Score(earned=2, possible=5, graded=True, section="summary")) + scores.append(Score(earned=2, possible=5, graded=True, section="summary", attempted=True)) all_total, graded_total = aggregate_scores(scores) self.assertAlmostEqual(all_total, Score(earned=5, possible=15, graded=False, section="summary")) self.assertAlmostEqual(graded_total, Score(earned=5, possible=10, graded=True, section="summary")) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index e3c40079c3fd..325883588628 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -120,6 +120,7 @@ def answer_distributions(request, course): def grade(student, request, course, model_data_cache=None, keep_raw_scores=False): + """ This grades a student as quickly as possible. It returns the output from the course grader, augmented with the final letter @@ -150,70 +151,18 @@ def grade(student, request, course, model_data_cache=None, keep_raw_scores=False for section_format, sections in grading_context['graded_sections'].iteritems(): format_scores = [] for section in sections: - section_descriptor = section['section_descriptor'] - section_name = section_descriptor.display_name_with_default - - should_grade_section = False - # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% - for moduledescriptor in section['xmoduledescriptors']: - # some problems have state that is updated independently of interaction - # with the LMS, so they need to always be scored. (E.g. foldit.) - if moduledescriptor.always_recalculate_grades: - should_grade_section = True - break - - # Create a fake key to pull out a StudentModule object from the ModelDataCache - - key = LmsKeyValueStore.Key( - Scope.user_state, - student.id, - moduledescriptor.location, - None - ) - if model_data_cache.find(key): - should_grade_section = True - break - - if should_grade_section: - scores = [] - def create_module(descriptor): - '''creates an XModule instance given a descriptor''' - # TODO: We need the request to pass into here. If we could forego that, our arguments - # would be simpler - return get_module_for_descriptor(student, request, descriptor, model_data_cache, course.id) + graded_total, add_raw_scores = compute_graded_total(section, student, course.id, model_data_cache, request) - for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module): - - (correct, total) = get_score(course.id, student, module_descriptor, create_module, model_data_cache) - if correct is None and total is None: - continue - - if settings.GENERATE_PROFILE_SCORES: # for debugging! - if total > 1: - correct = random.randrange(max(total - 2, 1), total + 1) - else: - correct = total - - graded = module_descriptor.lms.graded - if not total > 0: - #We simply cannot grade a problem that is 12/0, because we might need it as a percentage - graded = False - - scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default)) - - _, graded_total = graders.aggregate_scores(scores, section_name) - if keep_raw_scores: - raw_scores += scores - else: - graded_total = Score(0.0, 1.0, True, section_name) + if keep_raw_scores: + raw_scores += add_raw_scores #Add the graded total to totaled_scores if graded_total.possible > 0: format_scores.append(graded_total) else: log.exception("Unable to grade a section with a total possible score of zero. " + - str(section_descriptor.location)) + str(section['section_descriptor'].location)) totaled_scores[section_format] = format_scores @@ -232,6 +181,127 @@ def create_module(descriptor): return grade_summary +def compute_graded_total(section, student, course_id, model_data_cache, request): + + """ + Computes a total grade for a section. + + @return a tuple: (graded_total, add_raw_scores) + - graded_total: a Score -- either the output of graders.aggregate_scores, or 0/1 if not should_grade_section + - add_raw_scores: a list of Score objects to be added to raw_scores within grade() + """ + + section_descriptor = section['section_descriptor'] + section_name = section_descriptor.display_name_with_default + + raw_scores = [] + + #Moved to helper method + should_grade_section = find_should_grade_section(section['xmoduledescriptors'], model_data_cache, student.id) + + if should_grade_section: + scores = [] + + def create_module(descriptor): + '''creates an XModule instance given a descriptor''' + # TODO: We need the request to pass into here. If we could forego that, our arguments + # would be simpler + return get_module_for_descriptor(student, request, descriptor, model_data_cache, course_id) + + for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module): + + (correct, total) = get_score(course_id, student, module_descriptor, create_module, model_data_cache) + + if correct is None and total is None: + continue + + graded = module_descriptor.lms.graded + if not total > 0: + #We simply cannot grade a problem that is 12/0, because we might need it as a percentage + graded = False + + attempted = find_attempted(module_descriptor, model_data_cache, student.id) + + if settings.GENERATE_PROFILE_SCORES: # for debugging! + if total > 1: + correct = random.randrange(max(total - 2, 1), total + 1) + else: + correct = total + + scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default, attempted)) + + _, graded_total = graders.aggregate_scores(scores, section_name) + + raw_scores += scores + else: + graded_total = Score(0.0, 1.0, True, section_name, False) + + return graded_total, raw_scores + + +def find_should_grade_section(xmoduledescriptors, model_data_cache, student_id): + + """ + Determines whether a section should be graded or not. + + If the moduledescriptor is found in the model data cache, it should be graded. + Also, if any moduledescriptor in a section should be graded, the entire section should be. + + @return True or False + """ + + should_grade_section = False + + # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% + for moduledescriptor in xmoduledescriptors: + + # some problems have state that is updated independently of interaction + # with the LMS, so they need to always be scored. (E.g. foldit.) + if moduledescriptor.always_recalculate_grades: + should_grade_section = True + break + + # Create a fake key to pull out a StudentModule object from the ModelDataCache + + key = LmsKeyValueStore.Key( + Scope.user_state, + student_id, + moduledescriptor.location, + None + ) + if model_data_cache.find(key): + should_grade_section = True + break + + return should_grade_section + + +def find_attempted(module_descriptor, model_data_cache, student_id): + + """ + Determines whether a section has been attempted yet. + + If the section is in the model data cache, AND if the grade is not null (an attempted problem + will have a grade of 0.0 or more), then it has been attempted. + This is for purposes of calculating the projected grade. + + @return True or False + """ + + key = LmsKeyValueStore.Key( + Scope.user_state, + student_id, + module_descriptor.location, + None + ) + attempted = False + if model_data_cache.find(key): + if model_data_cache.find(key).grade is not None: + attempted = True + + return attempted + + def grade_for_percentage(grade_cutoffs, percentage): """ Returns a letter grade as defined in grading_policy (e.g. 'A' 'B' 'C' for 6.002x) or None. @@ -278,7 +348,6 @@ def progress_summary(student, request, course, model_data_cache): will return None. """ - # TODO: We need the request to pass into here. If we could forego that, our arguments # would be simpler course_module = get_module(student, request, course.location, model_data_cache, course.id, depth=None) @@ -329,12 +398,13 @@ def progress_summary(student, request, course, model_data_cache): 'graded': graded, }) - chapters.append({'course': course.display_name_with_default, - 'display_name': chapter_module.display_name_with_default, - 'url_name': chapter_module.url_name, - 'sections': sections}) - - return chapters + chapters.append({ + 'course': course.display_name_with_default, + 'display_name': chapter_module.display_name_with_default, + 'url_name': chapter_module.url_name, + 'sections': sections + }) + return chapters def get_score(course_id, user, problem_descriptor, module_creator, model_data_cache): diff --git a/lms/djangoapps/courseware/tests/test_grades.py b/lms/djangoapps/courseware/tests/test_grades.py new file mode 100644 index 000000000000..169e2dc6d106 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_grades.py @@ -0,0 +1,493 @@ +""" +This is the start of a test for grades. +It is very incomplete - we're only testing one function +right now. +""" +from mock import MagicMock + +import unittest +import courseware.grades as grades +import courseware.module_render as module_render +from courseware.model_data import LmsKeyValueStore + +from xmodule.graders import Score + + +class FakeChildFactory(object): + """ + Makes fake child descriptors. + """ + @classmethod + def create(cls, name): + """ + Creates a new fake child with the given name. + """ + out = MagicMock() + out.has_dynamic_children = lambda: False + out.get_children = lambda: [] + out.name = name + return out + + +class TestGrades(unittest.TestCase): + """ + Test the grader. + """ + + def test_yield_dynamic_descriptor_descendents(self): + """ + Make sure that yield_dynamic_descriptor_descendents instantiates + modules to get children. + """ + + child_a = FakeChildFactory.create('a') + child_b = FakeChildFactory.create('b') + child_c = FakeChildFactory.create('c') + + fake_parent = MagicMock() + fake_parent.has_dynamic_children = lambda: True + # These are the wrong children. + fake_parent.get_children = lambda: [child_a, child_b, child_c] + + def fake_module_creator(descriptor): + """ + A mock of the module creator. Returns a set of children only + if called with our fake_parent. + """ + if descriptor == fake_parent: + fake_module = MagicMock() + fake_module.get_child_descriptors = lambda: [child_a, child_b] + return fake_module + else: + return None + + # Test with dynamic children + dynamic_children = list(grades.yield_dynamic_descriptor_descendents(fake_parent, fake_module_creator)) + self.assertTrue(child_a in dynamic_children) + self.assertTrue(child_b in dynamic_children) + self.assertTrue(child_c not in dynamic_children) + + # Test without dynamic children + fake_parent.has_dynamic_children = lambda: False + regular_children = list(grades.yield_dynamic_descriptor_descendents(fake_parent, fake_module_creator)) + self.assertTrue(child_a in regular_children) + self.assertTrue(child_b in regular_children) + self.assertTrue(child_c in regular_children) + + def test_compute_graded_total(self): + """ + Tests grading for a single section. + compute_graded_total(section, student, course_id, m_d_c, request) + - section['section_descriptor'] returns the descriptor for this section. + - student needs an id + - All other paramenters are touched through helper functions only. + + Functions to mock: + grades.should_grade_section(descriptor, m_d_c, student_id) -> True/False + .module_render.get_module_for_descriptor(student, request, descriptor, m_d_c, course_id) -> Xmodule + grades.yield_dynamic_descriptor_descendents(descriptor, create_module) -> iter through descriptors + grades.get_score(course_id, student, descriptor, create_module, m_d_c) -> (correct, total) + grades.find_attempted(descriptor, m_d_c, student_id) -> True/False + """ + + section = {'section_descriptor': MagicMock(), 'xmoduledescriptors': MagicMock()} + student = MagicMock() + student.id = 'my id' + course_id = 'course id' + m_d_c = MagicMock() # We will never query the mdc directly. + request = MagicMock() # Same as above. + + # Monkey patching! + def fake_should_grade_section(descriptor, m_d_c, student_id): + """ Always grade :) """ + return True + grades.find_should_grade_section = fake_should_grade_section + + def fake_get_module_for_descriptor(student, request, descriptor, m_d_c, course_id): + """Don't even return anything; this output is not directly used.""" + return None + module_render.get_module_for_descriptor = fake_get_module_for_descriptor + + def fake_yield_dynamic_descriptor_descendents(descriptor, create_module): + """ + Return a bunch of fake descriptors, in iterator form. Makes 4 descriptors: + 0: not graded. + 1: graded, but total is 0. + 2, 3: normal + """ + for i in xrange(4): + out = MagicMock() + out.display_name_with_default = str(i) + out.lms = MagicMock() + out.lms.graded = False if (i == 0) else True + yield out + grades.yield_dynamic_descriptor_descendents = fake_yield_dynamic_descriptor_descendents + + def fake_get_score(course_id, student, descriptor, create_module, m_d_c): + """ + Returns a score, based on the descriptor passed in. + 0: None / None + 1: 5 / 0 + 2: 2 / 4 + 3: 0 / 5 + """ + number = int(descriptor.display_name_with_default) + if number == 0: + return (None, None) + elif number == 1: + return (5, 0) + elif number == 2: + return (2, 4) + elif number == 3: + return (0, 5) + else: + raise Exception('get_score called with unexpected input.') + grades.get_score = fake_get_score + + def fake_find_attempted(descriptor, m_d_c, student_id): + """Always return True. """ + return True + grades.find_attempted = fake_find_attempted + + # Actually do the test. + graded_total, raw_scores = grades.compute_graded_total(section, student, course_id, m_d_c, request) + # Reset all of the monkey patching. + # Do this before assertions, because if an assertion fails, the remaining code is not run. + reload(grades) + reload(module_render) + + self.assertTrue(graded_total.earned == 2) + self.assertTrue(graded_total.possible == 9) + self.assertTrue(graded_total.graded) + + print len(raw_scores) + self.assertTrue(len(raw_scores) == 3) + + def test_grade(self): + """ + Test the grade function. + grade(student, request, course, model_data_cache=None, keep_raw_scores=False) + student - not used directly. + request - not used directly. + course: + .grading_context['graded_sections'] + .id + .grader.grade - return a grade summary + model_data_cache - not used directly, but can't be None. + keep_raw_scores - True/False + + Things to mock: + compute_graded_total(section, student, course_id, m_d_c, request) + grade_for_percentage(cutoffs, percent_summary) + """ + student = MagicMock() + request = MagicMock() + course = MagicMock() + course.grading_context = { + 'graded_sections': { + 'HW': ['HW1', 'HW2'], + 'Quiz': ['Quiz1'], + }, + } + course.id = 'my id' + course.grader = MagicMock() + + def fake_grade(totaled_scores, generate_random_scores=False): + """A fake course.grader.grade""" + return { + 'percent': 64.5, + } + course.grader.grade = fake_grade + m_d_c = MagicMock() + + def fake_compute_graded_total(section, student, course_id, m_d_c, request): + """ + A fake compute_graded_total. Expects a string for section, instead of + a real section. + """ + if section == 'HW1': + return ( + Score(4.0, 10.0, True, 'HW1', attempted=True), + ['RS1'] + ) + elif section == 'HW2': + return ( + Score(0.0, 10.0, True, 'HW2', attempted=False), + ['RS2'] + ) + elif section == 'Quiz1': + return ( + Score(85.0, 100.0, True, 'Quiz1', attempted=True), + ['RS3'] + ) + grades.compute_graded_total = fake_compute_graded_total + + def fake_grade_for_percentage(cutoffs, percent_summary): + """A mock of grade_for_percentage""" + return 'A' + grades.grade_for_percentage = fake_grade_for_percentage + + grade_summary = grades.grade(student, request, course, model_data_cache=m_d_c, keep_raw_scores=True) + reload(grades) + + print grade_summary['totaled_scores'] + self.assertTrue(grade_summary['percent'] == 64.5) + self.assertTrue('HW' in grade_summary['totaled_scores']) + self.assertTrue('HW' in grade_summary['totaled_scores']) + self.assertTrue('Quiz' in grade_summary['totaled_scores']) + self.assertTrue(grade_summary['raw_scores'] == ['RS1', 'RS2', 'RS3']) + self.assertTrue(grade_summary['grade'] == 'A') + + +class TestFindShouldGradeSection(unittest.TestCase): + """ + Test find_should_grade_section. + + find_should_grade_section should: + return True when at least one problem in the section has been seen in cache + return True when a module's grades should always be recalculated + otherwise return False when no problem has been seen in cache + """ + + def setUp(self): + + def fake_find_key(key): + self.assertIsInstance(key, LmsKeyValueStore.Key) + if key.block_scope_id: + fake_found = MagicMock() + fake_found.grade = key.student_id + return fake_found + else: + return None + + self.fake_model_data_cache = MagicMock() + self.fake_model_data_cache.find = fake_find_key + + def fake_module(self, is_in_cache, recalculate): + output = MagicMock() + output.location = is_in_cache + output.always_recalculate_grades = recalculate + return output + + def test_not_in_cache(self): + #Test returning false when not always-recalculating-grades and when no problem has been seen in cache + fake_xmoduledescriptors = [self.fake_module(False, False) for i in range(5)] + result = grades.find_should_grade_section(fake_xmoduledescriptors, self.fake_model_data_cache, 42) + self.assertFalse(result) + + def test_first_in_cache(self): + #Test returning true when the first problem has been seen in cache + fake_xmoduledescriptors = [self.fake_module(True, False)] + [self.fake_module(False, False) for i in range(7)] + result = grades.find_should_grade_section(fake_xmoduledescriptors, self.fake_model_data_cache, 42) + self.assertTrue(result) + + def test_last_in_cache(self): + #Test returning true when the last problem has been seen in cache + fake_xmoduledescriptors = [self.fake_module(False, False) for i in range(3)] + [self.fake_module(True, False)] + result = grades.find_should_grade_section(fake_xmoduledescriptors, self.fake_model_data_cache, 42) + self.assertTrue(result) + + def test_all_in_cache(self): + #Test returning true when all problems have been seen in cache + fake_xmoduledescriptors = [self.fake_module(True, False) for i in range(9)] + result = grades.find_should_grade_section(fake_xmoduledescriptors, self.fake_model_data_cache, 42) + self.assertTrue(result) + + def test_always_recalculate(self): + #Test returning true when a module's grades should always be recalculated, even if False otherwise + fake_xmoduledescriptors = [self.fake_module(False, True) for i in range(2)] + result = grades.find_should_grade_section(fake_xmoduledescriptors, self.fake_model_data_cache, 42) + self.assertTrue(result) + + def test_empty_list(self): + #Test returning false when the list of xmodule descriptors is empty + result = grades.find_should_grade_section([], self.fake_model_data_cache, 42) + self.assertFalse(result) + + +class TestFindAttempted(unittest.TestCase): + + """ + Test the find_attempted method. + """ + + def setUp(self): + + def fake_find_key(key): + self.assertIsInstance(key, LmsKeyValueStore.Key) + if key.block_scope_id: + fake_found = MagicMock() + fake_found.grade = key.student_id + return fake_found + else: + return None + + self.fake_model_data_cache = MagicMock() + self.fake_model_data_cache.find = fake_find_key + + def fake_module(self, is_in_cache): + output = MagicMock() + output.location = is_in_cache + return output + + def test_not_attempted(self): + #Test returning false when student has not attempted problem + fake_module = self.fake_module(False) + result = grades.find_attempted(fake_module, self.fake_model_data_cache, None) + self.assertFalse(result) + + def test_no_grade(self): + #Test returning false when student has attempted problem, but grade is None + fake_module = self.fake_module(True) + result = grades.find_attempted(fake_module, self.fake_model_data_cache, None) + self.assertFalse(result) + + def test_has_grade(self): + #Test returning true when student has attempted problem and has a grade + fake_module = self.fake_module(True) + result = grades.find_attempted(fake_module, self.fake_model_data_cache, 3.0) + self.assertTrue(result) + + +class TestGetScore(unittest.TestCase): + + """ + Tests the get_score method. + + get_score should: + return (None, None): + if the problem doesn't have a score + if the problem couldn't be loaded + if the user is not authenticated + return (correct, total) otherwise + reweight the problem correctly if specified + not reweight a problem with zero total points + """ + + def setUp(self): + + def fake_find_key(key): + self.assertIsInstance(key, LmsKeyValueStore.Key) + if key.block_scope_id: + fake_found = MagicMock() + fake_found.grade = key.student_id[0] + fake_found.max_grade = key.student_id[1] + return fake_found + else: + return None + + self.fake_model_data_cache = MagicMock() + self.fake_model_data_cache.find = fake_find_key + + def module_creator(descriptor): + #Returns a problem mock + output = MagicMock() + output.get_score = lambda: {'score': 8.0, 'total': 9.0} + output.max_score = lambda: 9.0 + return output + + self.module_creator = module_creator + + self.course_id = None + + self.user = MagicMock() + self.user.id = (5.0, 7.0) # fed into fake_find_key(key)'s output + self.user.is_authenticated = lambda: True + + self.problem_descriptor = MagicMock() + self.problem_descriptor.always_recalculate_grades = False + self.problem_descriptor.has_score = True + # if .location is not None, problem descriptor is "found" by fake_find_key + self.problem_descriptor.location = "problem location" + self.problem_descriptor.weight = None + + self.call_result = lambda: grades.get_score( + self.course_id, self.user, self.problem_descriptor, self.module_creator, self.fake_model_data_cache + ) + + def test_simple(self): + + model_data_cache = self.fake_model_data_cache + + self.assertEquals(self.call_result(), (5.0, 7.0)) + + def test_not_in_cache(self): + + self.problem_descriptor.location = None + + #0/9 instead of 8/9 because if the problem is not in the cache, we assume it is ungraded. + self.assertEquals(self.call_result(), (0.0, 9.0)) + + def test_always_recalculate(self): + + self.problem_descriptor.always_recalculate_grades = True + + self.assertEquals(self.call_result(), (8.0, 9.0)) + + def test_reweight(self): + + self.problem_descriptor.weight = 14.0 + + self.assertEquals(self.call_result(), (10.0, 14.0)) + + def test_failed_reweight(self): + + self.user.id = (0.0, 0.0) + self.problem_descriptor.weight = 14.0 + + self.assertEquals(self.call_result(), (0.0, 0.0)) + + def test_unauthenticated(self): + + self.user.is_authenticated = lambda: False + + self.assertEquals(self.call_result(), (None, None)) + + def test_not_has_score(self): + + self.problem_descriptor.has_score = False + + self.assertEquals(self.call_result(), (None, None)) + + def test_student_module_grade_is_none(self): + + def fake_find_key(key): + return None + self.fake_model_data_cache.find = fake_find_key + + self.assertEquals(self.call_result(), (0.0, 9.0)) + + def test_always_recalculate_get_score_is_none(self): + + def module_creator(descriptor): + #Returns a problem mock + output = MagicMock() + output.get_score = lambda: None + output.max_score = lambda: 9.0 + return output + self.module_creator = module_creator + self.problem_descriptor.always_recalculate_grades = True + + self.assertEquals(self.call_result(), (None, None)) + + def test_not_in_cache_and_module_creator_returns_none(self): + + def module_creator(descriptor): + return None + self.module_creator = module_creator + self.problem_descriptor.location = None + + self.assertEquals(self.call_result(), (None, None)) + + def test_not_in_cache_and_total_is_none(self): + + def module_creator(descriptor): + #Returns a problem mock + output = MagicMock() + output.get_score = lambda: {'score': 8.0, 'total': None} + output.max_score = lambda: None + return output + self.module_creator = module_creator + self.problem_descriptor.location = None + + self.assertEquals(self.call_result(), (None, None)) diff --git a/lms/templates/courseware/progress_graph.js b/lms/templates/courseware/progress_graph.js index 449cad766f2a..4b14391c56f6 100644 --- a/lms/templates/courseware/progress_graph.js +++ b/lms/templates/courseware/progress_graph.js @@ -20,7 +20,7 @@ $(function () { } /* -------------------------------- Grade detail bars -------------------------------- */ - + <% colors = ["#b72121", "#600101", "#666666", "#333333"] categories = {} @@ -29,49 +29,77 @@ $(function () { sectionSpacer = 0.25 sectionIndex = 0 + inProgressData = [] + ticks = [] #These are the indices and x-axis labels for the data bottomTicks = [] #Labels on the bottom detail_tooltips = {} #This an dictionary mapping from 'section' -> array of detail_tooltips + detail_tooltips['Projected'] = [] droppedScores = [] #These are the datapoints to indicate assignments which are not factored into the total score dropped_score_tooltips = [] for section in grade_summary['section_breakdown']: if section.get('prominent', False): tickIndex += sectionSpacer - + if section['category'] not in categories: colorIndex = len(categories) % len(colors) - categories[ section['category'] ] = {'label' : section['category'], - 'data' : [], + categories[ section['category'] ] = {'label' : section['category'], + 'data' : [], 'color' : colors[colorIndex]} - + categoryData = categories[ section['category'] ] - + categoryData['data'].append( [tickIndex, section['percent']] ) + if 'projected_percent' in section: + inProgressData.append([ + tickIndex, + section['projected_percent'] + ]) + # Make tooltips for projected bars. + try: + detail_tooltips['Projected'].append(section['projected_detail']) + except KeyError: + detail_tooltips['Projected'].append('') + ticks.append( [tickIndex, section['label'] ] ) - + if section['category'] in detail_tooltips: detail_tooltips[ section['category'] ].append( section['detail'] ) else: detail_tooltips[ section['category'] ] = [ section['detail'], ] - + if 'mark' in section: droppedScores.append( [tickIndex, 0.05] ) dropped_score_tooltips.append( section['mark']['detail'] ) - + tickIndex += 1 - + if section.get('prominent', False): tickIndex += sectionSpacer - + ## ----------------------------- Grade overviewew bar ------------------------- ## tickIndex += sectionSpacer - - series = categories.values() + # Add projected bar for the total grade. + inProgressData.append([tickIndex, grade_summary['projected_percent']]) + detail_tooltips['Projected'].append('Projected Total Grade: {percent:.0%}'.format( + percent=grade_summary['projected_percent'])) + + series = [] + # Add the list of projected scores to the series. + series.append({ + 'label': 'Projected', + 'color': '#AAAAAA', + 'data': inProgressData, + 'stack': False, + }) + + series += categories.values() overviewBarX = tickIndex extraColorIndex = len(categories) #Keeping track of the next color to use for categories not in categories[] - - if show_grade_breakdown: + + + if show_grade_breakdown: for section in grade_summary['grade_breakdown']: if section['percent'] > 0: if section['category'] in categories: @@ -79,24 +107,25 @@ $(function () { else: color = colors[ extraColorIndex % len(colors) ] extraColorIndex += 1 - + series.append({ 'label' : section['category'] + "-grade_breakdown", 'data' : [ [overviewBarX, section['percent']] ], - 'color' : color + 'color' : color, }) - + detail_tooltips[section['category'] + "-grade_breakdown"] = [ section['detail'] ] - + ticks += [ [overviewBarX, "Total"] ] tickIndex += 1 + sectionSpacer - + totalScore = grade_summary['percent'] detail_tooltips['Dropped Scores'] = dropped_score_tooltips - - + + + ## ----------------------------- Grade cutoffs ------------------------- ## - + grade_cutoff_ticks = [ [1, "100%"], [0, "0%"] ] if show_grade_cutoffs: grade_cutoff_ticks = [ [1, "100%"], [0, "0%"] ] @@ -107,17 +136,17 @@ $(function () { else: grade_cutoff_ticks = [ ] %> - + var series = ${ json.dumps( series ) }; var ticks = ${ json.dumps(ticks) }; var bottomTicks = ${ json.dumps(bottomTicks) }; var detail_tooltips = ${ json.dumps(detail_tooltips) }; var droppedScores = ${ json.dumps(droppedScores) }; var grade_cutoff_ticks = ${ json.dumps(grade_cutoff_ticks) } - + //Always be sure that one series has the xaxis set to 2, or the second xaxis labels won't show up series.push( {label: 'Dropped Scores', data: droppedScores, points: {symbol: "cross", show: true, radius: 3}, bars: {show: false}, color: "#333"} ); - + // Allow for arbitrary grade markers e.g. ['A', 'B', 'C'], ['Pass'], etc. var ascending_grades = grade_cutoff_ticks.map(function (el) { return el[0]; }); // Percentage point (in decimal) of each grade cutoff ascending_grades.sort(); @@ -136,17 +165,17 @@ $(function () { grid: { hoverable: true, clickable: true, borderWidth: 1, markings: markings }, legend: {show: false}, }; - + var $grade_detail_graph = $("#${graph_div_id}"); if ($grade_detail_graph.length > 0) { var plot = $.plot($grade_detail_graph, series, options); - + %if show_grade_breakdown: var o = plot.pointOffset({x: ${overviewBarX} , y: ${totalScore}}); $grade_detail_graph.append('