Commit 4f6d5d9c by J. Cliff Dyer

Estimate creation time for subsections grades based on timestamp of

incoming scores.

TNL-6697
parent 529df7c6
...@@ -2258,7 +2258,7 @@ class TestComponentTemplates(CourseTestCase): ...@@ -2258,7 +2258,7 @@ class TestComponentTemplates(CourseTestCase):
def verify_openassessment_present(support_level): def verify_openassessment_present(support_level):
""" Helper method to verify that openassessment template is present """ """ Helper method to verify that openassessment template is present """
openassessment = get_xblock_problem('Peer Assessment') openassessment = get_xblock_problem('Open Response Assessment')
self.assertIsNotNone(openassessment) self.assertIsNotNone(openassessment)
self.assertEqual(openassessment.get('category'), 'openassessment') self.assertEqual(openassessment.get('category'), 'openassessment')
self.assertEqual(openassessment.get('support_level'), support_level) self.assertEqual(openassessment.get('support_level'), support_level)
......
...@@ -6,11 +6,13 @@ from __future__ import division ...@@ -6,11 +6,13 @@ from __future__ import division
import abc import abc
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime # Used by pycontracts. pylint: disable=unused-import
import inspect import inspect
import logging import logging
import random import random
import sys import sys
from contracts import contract
log = logging.getLogger("edx.courseware") log = logging.getLogger("edx.courseware")
...@@ -18,15 +20,22 @@ log = logging.getLogger("edx.courseware") ...@@ -18,15 +20,22 @@ log = logging.getLogger("edx.courseware")
class ScoreBase(object): class ScoreBase(object):
""" """
Abstract base class for encapsulating fields of values scores. Abstract base class for encapsulating fields of values scores.
Field common to all scores include:
graded (boolean) - whether or not this module is graded
attempted (boolean) - whether the module was attempted
""" """
__metaclass__ = abc.ABCMeta __metaclass__ = abc.ABCMeta
def __init__(self, graded, attempted): @contract(graded="bool", first_attempted="datetime|None")
def __init__(self, graded, first_attempted):
"""
Fields common to all scores include:
:param graded: Whether or not this module is graded
:type graded: bool
:param first_attempted: When the module was first attempted, or None
:type first_attempted: datetime|None
"""
self.graded = graded self.graded = graded
self.attempted = attempted self.first_attempted = first_attempted
def __eq__(self, other): def __eq__(self, other):
if type(other) is type(self): if type(other) is type(self):
...@@ -43,14 +52,27 @@ class ScoreBase(object): ...@@ -43,14 +52,27 @@ class ScoreBase(object):
class ProblemScore(ScoreBase): class ProblemScore(ScoreBase):
""" """
Encapsulates the fields of a Problem's score. Encapsulates the fields of a Problem's score.
In addition to the fields in ScoreBase, also includes:
raw_earned (float) - raw points earned on this problem
raw_possible (float) - raw points possible to earn on this problem
weighted_earned = earned (float) - weighted value of the points earned
weighted_possible = possible (float) - weighted possible points on this problem
weight (float) - weight of this problem
""" """
@contract
def __init__(self, raw_earned, raw_possible, weighted_earned, weighted_possible, weight, *args, **kwargs): def __init__(self, raw_earned, raw_possible, weighted_earned, weighted_possible, weight, *args, **kwargs):
"""
In addition to the fields in ScoreBase, arguments include:
:param raw_earned: Raw points earned on this problem
:type raw_earned: int|float|None
:param raw_possible: Raw points possible to earn on this problem
:type raw_possible: int|float|None
:param weighted_earned: Weighted value of the points earned
:type weighted_earned: int|float|None
:param weighted_possible: Weighted possible points on this problem
:type weighted_possible: int|float|None
:param weight: Weight of this problem
:type weight: int|float|None
"""
super(ProblemScore, self).__init__(*args, **kwargs) super(ProblemScore, self).__init__(*args, **kwargs)
self.raw_earned = float(raw_earned) if raw_earned is not None else None self.raw_earned = float(raw_earned) if raw_earned is not None else None
self.raw_possible = float(raw_possible) if raw_possible is not None else None self.raw_possible = float(raw_possible) if raw_possible is not None else None
...@@ -62,11 +84,18 @@ class ProblemScore(ScoreBase): ...@@ -62,11 +84,18 @@ class ProblemScore(ScoreBase):
class AggregatedScore(ScoreBase): class AggregatedScore(ScoreBase):
""" """
Encapsulates the fields of a Subsection's score. Encapsulates the fields of a Subsection's score.
In addition to the fields in ScoreBase, also includes:
tw_earned = earned - total aggregated sum of all weighted earned values
tw_possible = possible - total aggregated sum of all weighted possible values
""" """
@contract
def __init__(self, tw_earned, tw_possible, *args, **kwargs): def __init__(self, tw_earned, tw_possible, *args, **kwargs):
"""
In addition to the fields in ScoreBase, also includes:
:param tw_earned: Total aggregated sum of all weighted earned values
:type tw_earned: int|float|None
:param tw_possible: Total aggregated sum of all weighted possible values
:type tw_possible: int|float|None
"""
super(AggregatedScore, self).__init__(*args, **kwargs) super(AggregatedScore, self).__init__(*args, **kwargs)
self.earned = float(tw_earned) if tw_earned is not None else None self.earned = float(tw_earned) if tw_earned is not None else None
self.possible = float(tw_possible) if tw_possible is not None else None self.possible = float(tw_possible) if tw_possible is not None else None
...@@ -81,25 +110,32 @@ def float_sum(iterable): ...@@ -81,25 +110,32 @@ def float_sum(iterable):
def aggregate_scores(scores): def aggregate_scores(scores):
""" """
scores: A list of ScoreBase objects scores: A list of ProblemScore objects
returns: A tuple (all_total, graded_total). returns: A tuple (all_total, graded_total).
all_total: A ScoreBase representing the total score summed over all input scores all_total: An AggregatedScore representing the total score summed over all input scores
graded_total: A ScoreBase representing the score summed over all graded input scores graded_total: An AggregatedScore representing the score summed over all graded input scores
""" """
total_correct_graded = float_sum(score.earned for score in scores if score.graded) total_correct_graded = float_sum(score.earned for score in _iter_graded(scores))
total_possible_graded = float_sum(score.possible for score in scores if score.graded) total_possible_graded = float_sum(score.possible for score in _iter_graded(scores))
any_attempted_graded = any(score.attempted for score in scores if score.graded) first_attempted_graded = _min_or_none(
score.first_attempted for score in _iter_graded(scores) if score.first_attempted
)
total_correct = float_sum(score.earned for score in scores) total_correct = float_sum(score.earned for score in scores)
total_possible = float_sum(score.possible for score in scores) total_possible = float_sum(score.possible for score in scores)
any_attempted = any(score.attempted for score in scores) first_attempted = _min_or_none(
score.first_attempted for score in scores if score.first_attempted
)
# regardless of whether it is graded # regardless of whether it is graded
all_total = AggregatedScore(total_correct, total_possible, False, any_attempted) all_total = AggregatedScore(total_correct, total_possible, False, first_attempted=first_attempted)
# selecting only graded things # selecting only graded things
graded_total = AggregatedScore( graded_total = AggregatedScore(
total_correct_graded, total_possible_graded, True, any_attempted_graded, total_correct_graded,
total_possible_graded,
True,
first_attempted=first_attempted_graded
) )
return all_total, graded_total return all_total, graded_total
...@@ -407,3 +443,22 @@ class AssignmentFormatGrader(CourseGrader): ...@@ -407,3 +443,22 @@ class AssignmentFormatGrader(CourseGrader):
'section_breakdown': breakdown, 'section_breakdown': breakdown,
# No grade_breakdown here # No grade_breakdown here
} }
def _iter_graded(scores):
"""
Yield the scores that belong to explicitly graded blocks
"""
return (score for score in scores if score.graded)
def _min_or_none(itr):
"""
Return the lowest value in itr, or None if itr is empty.
In python 3, this is just min(itr, default=None)
"""
try:
return min(itr)
except ValueError:
return None
"""Grading tests""" """
Grading tests
"""
from datetime import datetime
import ddt import ddt
import unittest import unittest
...@@ -13,8 +18,8 @@ class GradesheetTest(unittest.TestCase): ...@@ -13,8 +18,8 @@ class GradesheetTest(unittest.TestCase):
def test_weighted_grading(self): def test_weighted_grading(self):
scores = [] scores = []
agg_fields = dict(attempted=False) agg_fields = dict(first_attempted=None)
prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, attempted=False) prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, first_attempted=None)
# No scores # No scores
all_total, graded_total = aggregate_scores(scores) all_total, graded_total = aggregate_scores(scores)
...@@ -40,8 +45,9 @@ class GradesheetTest(unittest.TestCase): ...@@ -40,8 +45,9 @@ class GradesheetTest(unittest.TestCase):
) )
# (0/5 non-graded) + (3/5 graded) = 3/10 total, 3/5 graded # (0/5 non-graded) + (3/5 graded) = 3/10 total, 3/5 graded
prob_fields['attempted'] = True now = datetime.now()
agg_fields['attempted'] = True prob_fields['first_attempted'] = now
agg_fields['first_attempted'] = now
scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields)) scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields))
all_total, graded_total = aggregate_scores(scores) all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual( self.assertAlmostEqual(
...@@ -89,7 +95,7 @@ class GraderTest(unittest.TestCase): ...@@ -89,7 +95,7 @@ class GraderTest(unittest.TestCase):
self.graded_total = graded_total self.graded_total = graded_total
self.display_name = display_name self.display_name = display_name
common_fields = dict(graded=True, attempted=True) common_fields = dict(graded=True, first_attempted=datetime.now())
test_gradesheet = { test_gradesheet = {
'Homework': { 'Homework': {
'hw1': MockGrade(AggregatedScore(tw_earned=2, tw_possible=20.0, **common_fields), display_name='hw1'), 'hw1': MockGrade(AggregatedScore(tw_earned=2, tw_possible=20.0, **common_fields), display_name='hw1'),
......
...@@ -942,7 +942,7 @@ class ScoresClient(object): ...@@ -942,7 +942,7 @@ class ScoresClient(object):
Eventually, this should read and write scores, but at the moment it only Eventually, this should read and write scores, but at the moment it only
handles the read side of things. handles the read side of things.
""" """
Score = namedtuple('Score', 'correct total') Score = namedtuple('Score', 'correct total created')
def __init__(self, course_key, user_id): def __init__(self, course_key, user_id):
self.course_key = course_key self.course_key = course_key
...@@ -965,9 +965,9 @@ class ScoresClient(object): ...@@ -965,9 +965,9 @@ class ScoresClient(object):
# attached to them (since old mongo identifiers don't include runs). # attached to them (since old mongo identifiers don't include runs).
# So we have to add that info back in before we put it into our lookup. # So we have to add that info back in before we put it into our lookup.
self._locations_to_scores.update({ self._locations_to_scores.update({
UsageKey.from_string(location).map_into_course(self.course_key): self.Score(correct, total) UsageKey.from_string(location).map_into_course(self.course_key): self.Score(correct, total, created)
for location, correct, total for location, correct, total, created
in scores_qset.values_list('module_state_key', 'grade', 'max_grade') in scores_qset.values_list('module_state_key', 'grade', 'max_grade', 'created')
}) })
self._has_fetched = True self._has_fetched = True
......
...@@ -2,16 +2,20 @@ ...@@ -2,16 +2,20 @@
""" """
Integration tests for submitting problem responses and getting grades. Integration tests for submitting problem responses and getting grades.
""" """
import ddt
# pylint: disable=attribute-defined-outside-init
import json import json
import os import os
from textwrap import dedent from textwrap import dedent
import ddt
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.utils.timezone import now
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -386,7 +390,6 @@ class TestCourseGrader(TestSubmittingProblems): ...@@ -386,7 +390,6 @@ class TestCourseGrader(TestSubmittingProblems):
""" """
Set up a simple course for testing weighted grading functionality. Set up a simple course for testing weighted grading functionality.
""" """
# pylint: disable=attribute-defined-outside-init
self.set_weighted_policy(hw_weight, final_weight) self.set_weighted_policy(hw_weight, final_weight)
...@@ -449,12 +452,13 @@ class TestCourseGrader(TestSubmittingProblems): ...@@ -449,12 +452,13 @@ class TestCourseGrader(TestSubmittingProblems):
self.hw3_names = ['h3p1', 'h3p2'] self.hw3_names = ['h3p1', 'h3p2']
self.homework1 = self.add_graded_section_to_course('homework1') self.homework1 = self.add_graded_section_to_course('homework1')
self.homework2 = self.add_graded_section_to_course('homework2')
self.homework3 = self.add_graded_section_to_course('homework3')
self.add_dropdown_to_section(self.homework1.location, self.hw1_names[0], 1) self.add_dropdown_to_section(self.homework1.location, self.hw1_names[0], 1)
self.add_dropdown_to_section(self.homework1.location, self.hw1_names[1], 1) self.add_dropdown_to_section(self.homework1.location, self.hw1_names[1], 1)
self.homework2 = self.add_graded_section_to_course('homework2')
self.add_dropdown_to_section(self.homework2.location, self.hw2_names[0], 1) self.add_dropdown_to_section(self.homework2.location, self.hw2_names[0], 1)
self.add_dropdown_to_section(self.homework2.location, self.hw2_names[1], 1) self.add_dropdown_to_section(self.homework2.location, self.hw2_names[1], 1)
self.homework3 = self.add_graded_section_to_course('homework3')
self.add_dropdown_to_section(self.homework3.location, self.hw3_names[0], 1) self.add_dropdown_to_section(self.homework3.location, self.hw3_names[0], 1)
self.add_dropdown_to_section(self.homework3.location, self.hw3_names[1], 1) self.add_dropdown_to_section(self.homework3.location, self.hw3_names[1], 1)
...@@ -619,7 +623,11 @@ class TestCourseGrader(TestSubmittingProblems): ...@@ -619,7 +623,11 @@ class TestCourseGrader(TestSubmittingProblems):
with patch('submissions.api.get_scores') as mock_get_scores: with patch('submissions.api.get_scores') as mock_get_scores:
mock_get_scores.return_value = { mock_get_scores.return_value = {
self.problem_location('p3').to_deprecated_string(): (1, 1) self.problem_location('p3').to_deprecated_string(): {
'points_earned': 1,
'points_possible': 1,
'created_at': now(),
},
} }
self.get_course_grade() self.get_course_grade()
......
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from lms.djangoapps.grades.config.waffle import waffle, ASSUME_ZERO_GRADE_IF_ABSENT from lms.djangoapps.grades.config.waffle import waffle as waffle_func, ASSUME_ZERO_GRADE_IF_ABSENT
def assume_zero_if_absent(course_key): def assume_zero_if_absent(course_key):
""" """
Returns whether an absent grade should be assumed to be zero. Returns whether an absent grade should be assumed to be zero.
""" """
return should_persist_grades(course_key) and waffle().is_enabled(ASSUME_ZERO_GRADE_IF_ABSENT) return should_persist_grades(course_key) and waffle_func().is_enabled(ASSUME_ZERO_GRADE_IF_ABSENT)
def should_persist_grades(course_key): def should_persist_grades(course_key):
......
...@@ -11,6 +11,7 @@ WAFFLE_NAMESPACE = u'grades' ...@@ -11,6 +11,7 @@ WAFFLE_NAMESPACE = u'grades'
# Switches # Switches
WRITE_ONLY_IF_ENGAGED = u'write_only_if_engaged' WRITE_ONLY_IF_ENGAGED = u'write_only_if_engaged'
ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent' ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent'
ESTIMATE_FIRST_ATTEMPTED = u'estimate_first_attempted'
def waffle(): def waffle():
......
...@@ -81,7 +81,7 @@ class TestResetGrades(TestCase): ...@@ -81,7 +81,7 @@ class TestResetGrades(TestCase):
"earned_graded": 6.0, "earned_graded": 6.0,
"possible_graded": 8.0, "possible_graded": 8.0,
"visible_blocks": MagicMock(), "visible_blocks": MagicMock(),
"attempted": True, "first_attempted": datetime.now(),
} }
for course_key in courses_keys: for course_key in courses_keys:
......
...@@ -26,6 +26,7 @@ from coursewarehistoryextended.fields import UnsignedBigIntAutoField ...@@ -26,6 +26,7 @@ from coursewarehistoryextended.fields import UnsignedBigIntAutoField
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField
from .config import waffle
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -361,9 +362,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -361,9 +362,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
""" """
cls._prepare_params_and_visible_blocks(params) cls._prepare_params_and_visible_blocks(params)
first_attempted = params.pop('first_attempted')
user_id = params.pop('user_id') user_id = params.pop('user_id')
usage_key = params.pop('usage_key') usage_key = params.pop('usage_key')
attempted = params.pop('attempted')
grade, _ = cls.objects.update_or_create( grade, _ = cls.objects.update_or_create(
user_id=user_id, user_id=user_id,
...@@ -371,20 +372,33 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -371,20 +372,33 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
usage_key=usage_key, usage_key=usage_key,
defaults=params, defaults=params,
) )
if first_attempted is not None and grade.first_attempted is None:
if attempted and not grade.first_attempted: if waffle.waffle().is_enabled(waffle.ESTIMATE_FIRST_ATTEMPTED):
grade.first_attempted = now() grade.first_attempted = first_attempted
else:
grade.first_attempted = now()
grade.save() grade.save()
cls._emit_grade_calculated_event(grade) cls._emit_grade_calculated_event(grade)
return grade return grade
@classmethod @classmethod
def _prepare_first_attempted_for_create(cls, params):
"""
Update the value of 'first_attempted' to now() if we aren't
using score-based estimates.
"""
if params['first_attempted'] is not None and not waffle.waffle().is_enabled(waffle.ESTIMATE_FIRST_ATTEMPTED):
params['first_attempted'] = now()
@classmethod
def create_grade(cls, **params): def create_grade(cls, **params):
""" """
Wrapper for objects.create. Wrapper for objects.create.
""" """
cls._prepare_params_and_visible_blocks(params) cls._prepare_params_and_visible_blocks(params)
cls._prepare_attempted_for_create(params, now()) cls._prepare_first_attempted_for_create(params)
grade = cls.objects.create(**params) grade = cls.objects.create(**params)
cls._emit_grade_calculated_event(grade) cls._emit_grade_calculated_event(grade)
return grade return grade
...@@ -400,9 +414,7 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -400,9 +414,7 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
map(cls._prepare_params, grade_params_iter) map(cls._prepare_params, grade_params_iter)
VisibleBlocks.bulk_get_or_create([params['visible_blocks'] for params in grade_params_iter], course_key) VisibleBlocks.bulk_get_or_create([params['visible_blocks'] for params in grade_params_iter], course_key)
map(cls._prepare_params_visible_blocks_id, grade_params_iter) map(cls._prepare_params_visible_blocks_id, grade_params_iter)
first_attempt_timestamp = now() map(cls._prepare_first_attempted_for_create, grade_params_iter)
for params in grade_params_iter:
cls._prepare_attempted_for_create(params, first_attempt_timestamp)
grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter] grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter]
grades = cls.objects.bulk_create(grades) grades = cls.objects.bulk_create(grades)
for grade in grades: for grade in grades:
...@@ -429,15 +441,6 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -429,15 +441,6 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
params['visible_blocks'] = BlockRecordList.from_list(params['visible_blocks'], params['course_id']) params['visible_blocks'] = BlockRecordList.from_list(params['visible_blocks'], params['course_id'])
@classmethod @classmethod
def _prepare_attempted_for_create(cls, params, timestamp):
"""
When creating objects, an attempted subsection gets its timestamp set
unconditionally.
"""
if params.pop('attempted'):
params['first_attempted'] = timestamp
@classmethod
def _prepare_params_visible_blocks_id(cls, params): def _prepare_params_visible_blocks_id(cls, params):
""" """
Prepares the visible_blocks_id field for the grade record, Prepares the visible_blocks_id field for the grade record,
......
...@@ -24,10 +24,13 @@ class CourseGradeBase(object): ...@@ -24,10 +24,13 @@ class CourseGradeBase(object):
self.passed = passed self.passed = passed
def __unicode__(self): def __unicode__(self):
return u'Course Grade: percent: %s, letter_grade: %s, passed: %s'.format( return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format(
unicode(self.percent), self.letter_grade, self.passed, unicode(self.percent),
self.letter_grade,
self.passed,
) )
@property
def attempted(self): def attempted(self):
""" """
Returns whether at least one problem was attempted Returns whether at least one problem was attempted
...@@ -210,7 +213,7 @@ class CourseGrade(CourseGradeBase): ...@@ -210,7 +213,7 @@ class CourseGrade(CourseGradeBase):
""" """
for chapter in self.chapter_grades.itervalues(): for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']: for subsection_grade in chapter['sections']:
if subsection_grade.attempted: if subsection_grade.all_total.first_attempted:
return True return True
return False return False
......
...@@ -41,28 +41,16 @@ class SubsectionGradeBase(object): ...@@ -41,28 +41,16 @@ class SubsectionGradeBase(object):
""" """
return self.locations_to_scores.values() return self.locations_to_scores.values()
@property
def attempted(self):
"""
Returns whether any problem in this subsection
was attempted by the student.
"""
assert self.all_total is not None, (
"SubsectionGrade not fully populated yet. Call init_from_structure or init_from_model "
"before use."
)
return self.all_total.attempted
class ZeroSubsectionGrade(SubsectionGradeBase): class ZeroSubsectionGrade(SubsectionGradeBase):
""" """
Class for Subsection Grades with Zero values. Class for Subsection Grades with Zero values.
""" """
def __init__(self, subsection, course_data): def __init__(self, subsection, course_data):
super(ZeroSubsectionGrade, self).__init__(subsection) super(ZeroSubsectionGrade, self).__init__(subsection)
self.graded_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=False, attempted=False) self.graded_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=False, first_attempted=None)
self.all_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=self.graded, attempted=False) self.all_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=self.graded, first_attempted=None)
self.course_data = course_data self.course_data = course_data
@lazy @lazy
...@@ -118,13 +106,13 @@ class SubsectionGrade(SubsectionGradeBase): ...@@ -118,13 +106,13 @@ class SubsectionGrade(SubsectionGradeBase):
tw_earned=model.earned_graded, tw_earned=model.earned_graded,
tw_possible=model.possible_graded, tw_possible=model.possible_graded,
graded=True, graded=True,
attempted=model.first_attempted is not None, first_attempted=model.first_attempted,
) )
self.all_total = AggregatedScore( self.all_total = AggregatedScore(
tw_earned=model.earned_all, tw_earned=model.earned_all,
tw_possible=model.possible_all, tw_possible=model.possible_all,
graded=False, graded=False,
attempted=model.first_attempted is not None, first_attempted=model.first_attempted,
) )
self._log_event(log.debug, u"init_from_model", student) self._log_event(log.debug, u"init_from_model", student)
return self return self
...@@ -162,7 +150,7 @@ class SubsectionGrade(SubsectionGradeBase): ...@@ -162,7 +150,7 @@ class SubsectionGrade(SubsectionGradeBase):
Returns whether the SubsectionGrade's model should be Returns whether the SubsectionGrade's model should be
persisted based on settings and attempted status. persisted based on settings and attempted status.
""" """
return not waffle().is_enabled(WRITE_ONLY_IF_ENGAGED) or self.attempted return not waffle().is_enabled(WRITE_ONLY_IF_ENGAGED) or self.all_total.first_attempted is not None
def _compute_block_score( def _compute_block_score(
self, self,
...@@ -209,7 +197,7 @@ class SubsectionGrade(SubsectionGradeBase): ...@@ -209,7 +197,7 @@ class SubsectionGrade(SubsectionGradeBase):
earned_graded=self.graded_total.earned, earned_graded=self.graded_total.earned,
possible_graded=self.graded_total.possible, possible_graded=self.graded_total.possible,
visible_blocks=self._get_visible_blocks, visible_blocks=self._get_visible_blocks,
attempted=self.attempted first_attempted=self.all_total.first_attempted,
) )
@property @property
......
...@@ -101,7 +101,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): ...@@ -101,7 +101,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block):
# Priority order for retrieving the scores: # Priority order for retrieving the scores:
# submissions API -> CSM -> grades persisted block -> latest block content # submissions API -> CSM -> grades persisted block -> latest block content
raw_earned, raw_possible, weighted_earned, weighted_possible, attempted = ( raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted = (
_get_score_from_submissions(submissions_scores, block) or _get_score_from_submissions(submissions_scores, block) or
_get_score_from_csm(csm_scores, block, weight) or _get_score_from_csm(csm_scores, block, weight) or
_get_score_from_persisted_or_latest_block(persisted_block, block, weight) _get_score_from_persisted_or_latest_block(persisted_block, block, weight)
...@@ -121,7 +121,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): ...@@ -121,7 +121,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block):
weighted_possible, weighted_possible,
weight, weight,
graded, graded,
attempted=attempted, first_attempted=first_attempted,
) )
...@@ -149,10 +149,11 @@ def _get_score_from_submissions(submissions_scores, block): ...@@ -149,10 +149,11 @@ def _get_score_from_submissions(submissions_scores, block):
if submissions_scores: if submissions_scores:
submission_value = submissions_scores.get(unicode(block.location)) submission_value = submissions_scores.get(unicode(block.location))
if submission_value: if submission_value:
attempted = True first_attempted = submission_value['created_at']
weighted_earned, weighted_possible = submission_value weighted_earned = submission_value['points_earned']
weighted_possible = submission_value['points_possible']
assert weighted_earned >= 0.0 and weighted_possible > 0.0 # per contract from submissions API assert weighted_earned >= 0.0 and weighted_possible > 0.0 # per contract from submissions API
return (None, None) + (weighted_earned, weighted_possible) + (attempted,) return (None, None) + (weighted_earned, weighted_possible) + (first_attempted,)
def _get_score_from_csm(csm_scores, block, weight): def _get_score_from_csm(csm_scores, block, weight):
...@@ -175,13 +176,14 @@ def _get_score_from_csm(csm_scores, block, weight): ...@@ -175,13 +176,14 @@ def _get_score_from_csm(csm_scores, block, weight):
has_valid_score = score and score.total is not None has_valid_score = score and score.total is not None
if has_valid_score: if has_valid_score:
if score.correct is not None: if score.correct is not None:
attempted = True first_attempted = score.created
raw_earned = score.correct raw_earned = score.correct
else: else:
attempted = False first_attempted = None
raw_earned = 0.0 raw_earned = 0.0
raw_possible = score.total raw_possible = score.total
return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (attempted,) return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (first_attempted,)
def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
...@@ -192,7 +194,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): ...@@ -192,7 +194,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
the latest block content. the latest block content.
""" """
raw_earned = 0.0 raw_earned = 0.0
attempted = False first_attempted = None
if persisted_block: if persisted_block:
raw_possible = persisted_block.raw_possible raw_possible = persisted_block.raw_possible
...@@ -205,7 +207,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): ...@@ -205,7 +207,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
else: else:
weighted_scores = weighted_score(raw_earned, raw_possible, weight) weighted_scores = weighted_score(raw_earned, raw_possible, weight)
return (raw_earned, raw_possible) + weighted_scores + (attempted,) return (raw_earned, raw_possible) + weighted_scores + (first_attempted,)
def _get_weight_from_block(persisted_block, block): def _get_weight_from_block(persisted_block, block):
......
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
Test grade calculation. Test grade calculation.
""" """
import ddt import datetime
import itertools import itertools
import ddt
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -202,6 +204,8 @@ class TestWeightedProblems(SharedModuleStoreTestCase): ...@@ -202,6 +204,8 @@ class TestWeightedProblems(SharedModuleStoreTestCase):
# verify all problem grades # verify all problem grades
for problem in self.problems: for problem in self.problems:
problem_score = subsection_grade.locations_to_scores[problem.location] problem_score = subsection_grade.locations_to_scores[problem.location]
self.assertEqual(type(expected_score.first_attempted), type(problem_score.first_attempted))
expected_score.first_attempted = problem_score.first_attempted
self.assertEquals(problem_score, expected_score) self.assertEquals(problem_score, expected_score)
# verify subsection grades # verify subsection grades
...@@ -235,7 +239,7 @@ class TestWeightedProblems(SharedModuleStoreTestCase): ...@@ -235,7 +239,7 @@ class TestWeightedProblems(SharedModuleStoreTestCase):
weighted_possible=expected_w_possible, weighted_possible=expected_w_possible,
weight=weight, weight=weight,
graded=expected_graded, graded=expected_graded,
attempted=True, first_attempted=datetime.datetime(2010, 1, 1),
) )
self._verify_grades(raw_earned, raw_possible, weight, expected_score) self._verify_grades(raw_earned, raw_possible, weight, expected_score)
......
...@@ -14,8 +14,10 @@ from django.test import TestCase ...@@ -14,8 +14,10 @@ from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
from freezegun import freeze_time from freezegun import freeze_time
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
import pytz
from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
from lms.djangoapps.grades.config import waffle
from lms.djangoapps.grades.models import ( from lms.djangoapps.grades.models import (
BlockRecord, BlockRecord,
BlockRecordList, BlockRecordList,
...@@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
"earned_graded": 6.0, "earned_graded": 6.0,
"possible_graded": 8.0, "possible_graded": 8.0,
"visible_blocks": self.block_records, "visible_blocks": self.block_records,
"attempted": True, "first_attempted": datetime(2000, 1, 1, 12, 30, 45, tzinfo=pytz.UTC),
} }
def test_create(self): def test_create(self):
...@@ -242,8 +244,8 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -242,8 +244,8 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
("possible_all", IntegrityError), ("possible_all", IntegrityError),
("earned_graded", IntegrityError), ("earned_graded", IntegrityError),
("possible_graded", IntegrityError), ("possible_graded", IntegrityError),
("first_attempted", KeyError),
("visible_blocks", KeyError), ("visible_blocks", KeyError),
("attempted", KeyError),
) )
@ddt.unpack @ddt.unpack
def test_non_optional_fields(self, field, error): def test_non_optional_fields(self, field, error):
...@@ -262,12 +264,21 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -262,12 +264,21 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
self.assertEqual(created_grade.id, updated_grade.id) self.assertEqual(created_grade.id, updated_grade.id)
self.assertEqual(created_grade.earned_all, 6) self.assertEqual(created_grade.earned_all, 6)
def test_update_or_create_attempted(self): @ddt.unpack
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) @ddt.data(
self.assertIsInstance(grade.first_attempted, datetime) (True, datetime(2000, 1, 1, 12, 30, 45, tzinfo=pytz.UTC)),
(False, None), # Use as now(). Freeze time needs this calculation to happen at test time.
)
@freeze_time(now())
def test_update_or_create_attempted(self, is_active, expected_first_attempted):
if expected_first_attempted is None:
expected_first_attempted = now()
with waffle.waffle().override(waffle.ESTIMATE_FIRST_ATTEMPTED, active=is_active):
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertEqual(grade.first_attempted, expected_first_attempted)
def test_unattempted(self): def test_unattempted(self):
self.params['attempted'] = False self.params['first_attempted'] = None
self.params['earned_all'] = 0.0 self.params['earned_all'] = 0.0
self.params['earned_graded'] = 0.0 self.params['earned_graded'] = 0.0
grade = PersistentSubsectionGrade.create_grade(**self.params) grade = PersistentSubsectionGrade.create_grade(**self.params)
...@@ -283,7 +294,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -283,7 +294,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
def test_unattempted_save_does_not_remove_attempt(self): def test_unattempted_save_does_not_remove_attempt(self):
PersistentSubsectionGrade.create_grade(**self.params) PersistentSubsectionGrade.create_grade(**self.params)
self.params['attempted'] = False self.params['first_attempted'] = None
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime) self.assertIsInstance(grade.first_attempted, datetime)
self.assertEqual(grade.earned_all, 6.0) self.assertEqual(grade.earned_all, 6.0)
......
...@@ -181,7 +181,7 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -181,7 +181,7 @@ class TestCourseGradeFactory(GradeTestBase):
with self.assertNumQueries(12), mock_get_score(1, 2): with self.assertNumQueries(12), mock_get_score(1, 2):
_assert_create(expected_pass=True) _assert_create(expected_pass=True)
with self.assertNumQueries(14), mock_get_score(1, 2): with self.assertNumQueries(15), mock_get_score(1, 2):
grade_factory.update(self.request.user, self.course) grade_factory.update(self.request.user, self.course)
with self.assertNumQueries(1): with self.assertNumQueries(1):
...@@ -294,7 +294,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): ...@@ -294,7 +294,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
self.assertFalse(mock_create_grade.called) self.assertFalse(mock_create_grade.called)
self.assertEqual(grade_a.url_name, grade_b.url_name) self.assertEqual(grade_a.url_name, grade_b.url_name)
grade_b.all_total.attempted = False # TODO TNL-5930 grade_b.all_total.first_attempted = None
self.assertEqual(grade_a.all_total, grade_b.all_total) self.assertEqual(grade_a.all_total, grade_b.all_total)
def test_update(self): def test_update(self):
...@@ -360,7 +360,7 @@ class ZeroGradeTest(GradeTestBase): ...@@ -360,7 +360,7 @@ class ZeroGradeTest(GradeTestBase):
for section in chapter_grades[chapter]['sections']: for section in chapter_grades[chapter]['sections']:
for score in section.locations_to_scores.itervalues(): for score in section.locations_to_scores.itervalues():
self.assertEqual(score.earned, 0) self.assertEqual(score.earned, 0)
self.assertEqual(score.attempted, False) self.assertEqual(score.first_attempted, None)
self.assertEqual(section.all_total.earned, 0) self.assertEqual(section.all_total.earned, 0)
...@@ -403,7 +403,7 @@ class SubsectionGradeTest(GradeTestBase): ...@@ -403,7 +403,7 @@ class SubsectionGradeTest(GradeTestBase):
) )
self.assertEqual(input_grade.url_name, loaded_grade.url_name) self.assertEqual(input_grade.url_name, loaded_grade.url_name)
loaded_grade.all_total.attempted = False # TODO TNL-5930 loaded_grade.all_total.first_attempted = None
self.assertEqual(input_grade.all_total, loaded_grade.all_total) self.assertEqual(input_grade.all_total, loaded_grade.all_total)
...@@ -471,7 +471,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): ...@@ -471,7 +471,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
# Configure one block to return no possible score, the rest to return 3.0 earned / 7.0 possible # 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 block_count = self.SCORED_BLOCK_COUNT - 1
mock_score.side_effect = itertools.chain( mock_score.side_effect = itertools.chain(
[(earned_per_block, None, earned_per_block, None, True)], [(earned_per_block, None, earned_per_block, None, datetime.datetime(2000, 1, 1))],
itertools.repeat(mock_score.return_value) itertools.repeat(mock_score.return_value)
) )
score = subsection_factory.update(self.seq1) score = subsection_factory.update(self.seq1)
......
...@@ -5,6 +5,7 @@ Tests for grades.scores module. ...@@ -5,6 +5,7 @@ Tests for grades.scores module.
from collections import namedtuple from collections import namedtuple
import ddt import ddt
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import now
import itertools import itertools
from lms.djangoapps.grades.models import BlockRecord from lms.djangoapps.grades.models import BlockRecord
...@@ -15,6 +16,9 @@ from openedx.core.djangoapps.content.block_structure.block_structure import Bloc ...@@ -15,6 +16,9 @@ from openedx.core.djangoapps.content.block_structure.block_structure import Bloc
from xmodule.graders import ProblemScore from xmodule.graders import ProblemScore
NOW = now()
class TestScoredBlockTypes(TestCase): class TestScoredBlockTypes(TestCase):
""" """
Tests for the possibly_scored function. Tests for the possibly_scored function.
...@@ -47,12 +51,13 @@ class TestGetScore(TestCase): ...@@ -47,12 +51,13 @@ class TestGetScore(TestCase):
display_name = 'test_name' display_name = 'test_name'
location = 'test_location' location = 'test_location'
SubmissionValue = namedtuple('SubmissionValue', 'exists, weighted_earned, weighted_possible') SubmissionValue = namedtuple('SubmissionValue', 'exists, points_earned, points_possible, created_at')
CSMValue = namedtuple('CSMValue', 'exists, raw_earned, raw_possible') CSMValue = namedtuple('CSMValue', 'exists, raw_earned, raw_possible, created')
PersistedBlockValue = namedtuple('PersistedBlockValue', 'exists, raw_possible, weight, graded') PersistedBlockValue = namedtuple('PersistedBlockValue', 'exists, raw_possible, weight, graded')
ContentBlockValue = namedtuple('ContentBlockValue', 'raw_possible, weight, explicit_graded') ContentBlockValue = namedtuple('ContentBlockValue', 'raw_possible, weight, explicit_graded')
ExpectedResult = namedtuple( ExpectedResult = namedtuple(
'ExpectedResult', 'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded, attempted' 'ExpectedResult',
'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded, first_attempted'
) )
def _create_submissions_scores(self, submission_value): def _create_submissions_scores(self, submission_value):
...@@ -60,7 +65,7 @@ class TestGetScore(TestCase): ...@@ -60,7 +65,7 @@ class TestGetScore(TestCase):
Creates a stub result from the submissions API for the given values. Creates a stub result from the submissions API for the given values.
""" """
if submission_value.exists: if submission_value.exists:
return {self.location: (submission_value.weighted_earned, submission_value.weighted_possible)} return {self.location: submission_value._asdict()}
else: else:
return {} return {}
...@@ -69,8 +74,14 @@ class TestGetScore(TestCase): ...@@ -69,8 +74,14 @@ class TestGetScore(TestCase):
Creates a stub result from courseware student module for the given values. Creates a stub result from courseware student module for the given values.
""" """
if csm_value.exists: if csm_value.exists:
stub_csm_record = namedtuple('stub_csm_record', 'correct, total') stub_csm_record = namedtuple('stub_csm_record', 'correct, total, created')
return {self.location: stub_csm_record(correct=csm_value.raw_earned, total=csm_value.raw_possible)} return {
self.location: stub_csm_record(
correct=csm_value.raw_earned,
total=csm_value.raw_possible,
created=csm_value.created
)
}
else: else:
return {} return {}
...@@ -106,64 +117,66 @@ class TestGetScore(TestCase): ...@@ -106,64 +117,66 @@ class TestGetScore(TestCase):
return block return block
@ddt.data( @ddt.data(
# submissions _trumps_ other values; weighted and graded from persisted-block _trumps_ latest content values # The value from Submissions trumps other values; The persisted value
# from persisted-block trumps latest content values
( (
SubmissionValue(exists=True, weighted_earned=50, weighted_possible=100), SubmissionValue(exists=True, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=10, raw_possible=40), CSMValue(exists=True, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult( ExpectedResult(
raw_earned=None, raw_possible=None, raw_earned=None, raw_possible=None,
weighted_earned=50, weighted_possible=100, weighted_earned=50, weighted_possible=100,
weight=40, graded=True, attempted=True, weight=40, graded=True, first_attempted=NOW
), ),
), ),
# same as above, except submissions doesn't exist; CSM values used # same as above, except Submissions doesn't exist; CSM values used
( (
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100), SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=10, raw_possible=40), CSMValue(exists=True, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult( ExpectedResult(
raw_earned=10, raw_possible=40, raw_earned=10, raw_possible=40,
weighted_earned=10, weighted_possible=40, weighted_earned=10, weighted_possible=40,
weight=40, graded=True, attempted=True, weight=40, graded=True, first_attempted=NOW,
), ),
), ),
# CSM values exist, but with NULL earned score treated as not-attempted # CSM values exist, but with NULL earned score treated as not-attempted
( (
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100), SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=None, raw_possible=40), CSMValue(exists=True, raw_earned=None, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult( ExpectedResult(
raw_earned=0, raw_possible=40, raw_earned=0, raw_possible=40,
weighted_earned=0, weighted_possible=40, weighted_earned=0, weighted_possible=40,
weight=40, graded=True, attempted=False, weight=40, graded=True, first_attempted=None
), ),
), ),
# neither submissions nor CSM exist; Persisted values used # neither submissions nor CSM exist; Persisted values used
( (
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100), SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=False, raw_earned=10, raw_possible=40), CSMValue(exists=False, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True), PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult( ExpectedResult(
raw_earned=0, raw_possible=5, raw_earned=0, raw_possible=5,
weighted_earned=0, weighted_possible=40, weighted_earned=0, weighted_possible=40,
weight=40, graded=True, attempted=False, weight=40, graded=True, first_attempted=None
), ),
), ),
# none of submissions, CSM, or persisted exist; Latest content values used # none of submissions, CSM, or persisted exist; Latest content values used
( (
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100), SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=False, raw_earned=10, raw_possible=40), CSMValue(exists=False, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=False, raw_possible=5, weight=40, graded=True), PersistedBlockValue(exists=False, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False), ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult( ExpectedResult(
raw_earned=0, raw_possible=1, raw_earned=0, raw_possible=1,
weighted_earned=0, weighted_possible=20, weighted_earned=0, weighted_possible=20,
weight=20, graded=False, attempted=False, weight=20, graded=False,
first_attempted=None
), ),
), ),
) )
...@@ -278,7 +291,7 @@ class TestInternalGetScoreFromBlock(TestCase): ...@@ -278,7 +291,7 @@ class TestInternalGetScoreFromBlock(TestCase):
""" """
# pylint: disable=unbalanced-tuple-unpacking # pylint: disable=unbalanced-tuple-unpacking
( (
raw_earned, raw_possible, weighted_earned, weighted_possible, attempted raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted
) = scores._get_score_from_persisted_or_latest_block(persisted_block, block, weight) ) = scores._get_score_from_persisted_or_latest_block(persisted_block, block, weight)
self.assertEquals(raw_earned, 0.0) self.assertEquals(raw_earned, 0.0)
...@@ -288,7 +301,7 @@ class TestInternalGetScoreFromBlock(TestCase): ...@@ -288,7 +301,7 @@ class TestInternalGetScoreFromBlock(TestCase):
self.assertEquals(weighted_possible, expected_r_possible) self.assertEquals(weighted_possible, expected_r_possible)
else: else:
self.assertEquals(weighted_possible, weight) self.assertEquals(weighted_possible, weight)
self.assertFalse(attempted) self.assertIsNone(first_attempted)
@ddt.data( @ddt.data(
*itertools.product((0, 1, 5), (None, 0, 1, 5)) *itertools.product((0, 1, 5), (None, 0, 1, 5))
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
Utilities for grades related tests Utilities for grades related tests
""" """
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime
from mock import patch from mock import patch
from courseware.module_render import get_module from courseware.module_render import get_module
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
...@@ -33,18 +34,18 @@ def mock_get_score(earned=0, possible=1): ...@@ -33,18 +34,18 @@ def mock_get_score(earned=0, possible=1):
weighted_possible=possible, weighted_possible=possible,
weight=1, weight=1,
graded=True, graded=True,
attempted=True, first_attempted=datetime(2000, 1, 1, 0, 0, 0)
) )
yield mock_score yield mock_score
@contextmanager @contextmanager
def mock_get_submissions_score(earned=0, possible=1, attempted=True): def mock_get_submissions_score(earned=0, possible=1, first_attempted=datetime(2000, 1, 1, 0, 0, 0)):
""" """
Mocks the _get_submissions_score function to return the specified values Mocks the _get_submissions_score function to return the specified values
""" """
with patch('lms.djangoapps.grades.scores._get_score_from_submissions') as mock_score: with patch('lms.djangoapps.grades.scores._get_score_from_submissions') as mock_score:
mock_score.return_value = (earned, possible, earned, possible, attempted) mock_score.return_value = (earned, possible, earned, possible, first_attempted)
yield mock_score yield mock_score
......
...@@ -848,7 +848,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input, ...@@ -848,7 +848,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
except KeyError: except KeyError:
grade_results.append([u'Not Available']) grade_results.append([u'Not Available'])
else: else:
if subsection_grade.graded_total.attempted: if subsection_grade.graded_total.first_attempted is not None:
grade_results.append( grade_results.append(
[subsection_grade.graded_total.earned / subsection_grade.graded_total.possible] [subsection_grade.graded_total.earned / subsection_grade.graded_total.possible]
) )
...@@ -1028,7 +1028,7 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t ...@@ -1028,7 +1028,7 @@ def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _t
except KeyError: except KeyError:
earned_possible_values.append([u'Not Available', u'Not Available']) earned_possible_values.append([u'Not Available', u'Not Available'])
else: else:
if problem_score.attempted: if problem_score.first_attempted:
earned_possible_values.append([problem_score.earned, problem_score.possible]) earned_possible_values.append([problem_score.earned, problem_score.possible])
else: else:
earned_possible_values.append([u'Not Attempted', problem_score.possible]) earned_possible_values.append([u'Not Attempted', problem_score.possible])
......
...@@ -75,8 +75,8 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002 ...@@ -75,8 +75,8 @@ git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002
-e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1 -e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1
-e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2 -e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2
-e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock -e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
git+https://github.com/edx/edx-ora2.git@1.3.2#egg=ora2==1.3.2 git+https://github.com/edx/edx-ora2.git@1.3.3#egg=ora2==1.3.3
-e git+https://github.com/edx/edx-submissions.git@1.2.0#egg=edx-submissions==1.2.0 -e git+https://github.com/edx/edx-submissions.git@2.0.0#egg=edx-submissions==2.0.0
git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3 git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3
git+https://github.com/edx/edx-val.git@0.0.13#egg=edxval==0.0.13 git+https://github.com/edx/edx-val.git@0.0.13#egg=edxval==0.0.13
git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2 git+https://github.com/pmitros/RecommenderXBlock.git@v1.2#egg=recommender-xblock==1.2
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment