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):
def verify_openassessment_present(support_level):
""" 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.assertEqual(openassessment.get('category'), 'openassessment')
self.assertEqual(openassessment.get('support_level'), support_level)
......
......@@ -6,11 +6,13 @@ from __future__ import division
import abc
from collections import OrderedDict
from datetime import datetime # Used by pycontracts. pylint: disable=unused-import
import inspect
import logging
import random
import sys
from contracts import contract
log = logging.getLogger("edx.courseware")
......@@ -18,15 +20,22 @@ log = logging.getLogger("edx.courseware")
class ScoreBase(object):
"""
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
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.attempted = attempted
self.first_attempted = first_attempted
def __eq__(self, other):
if type(other) is type(self):
......@@ -43,14 +52,27 @@ class ScoreBase(object):
class ProblemScore(ScoreBase):
"""
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):
"""
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)
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
......@@ -62,11 +84,18 @@ class ProblemScore(ScoreBase):
class AggregatedScore(ScoreBase):
"""
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):
"""
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)
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
......@@ -81,25 +110,32 @@ def float_sum(iterable):
def aggregate_scores(scores):
"""
scores: A list of ScoreBase objects
scores: A list of ProblemScore objects
returns: A tuple (all_total, graded_total).
all_total: A ScoreBase representing the total score summed over all input scores
graded_total: A ScoreBase representing the score summed over all graded input scores
all_total: An AggregatedScore representing the total score summed over all 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_possible_graded = float_sum(score.possible for score in scores if score.graded)
any_attempted_graded = any(score.attempted for score in scores if score.graded)
total_correct_graded = float_sum(score.earned for score in _iter_graded(scores))
total_possible_graded = float_sum(score.possible for score in _iter_graded(scores))
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_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
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
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
......@@ -407,3 +443,22 @@ class AssignmentFormatGrader(CourseGrader):
'section_breakdown': breakdown,
# 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 unittest
......@@ -13,8 +18,8 @@ class GradesheetTest(unittest.TestCase):
def test_weighted_grading(self):
scores = []
agg_fields = dict(attempted=False)
prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, attempted=False)
agg_fields = dict(first_attempted=None)
prob_fields = dict(raw_earned=0, raw_possible=0, weight=0, first_attempted=None)
# No scores
all_total, graded_total = aggregate_scores(scores)
......@@ -40,8 +45,9 @@ class GradesheetTest(unittest.TestCase):
)
# (0/5 non-graded) + (3/5 graded) = 3/10 total, 3/5 graded
prob_fields['attempted'] = True
agg_fields['attempted'] = True
now = datetime.now()
prob_fields['first_attempted'] = now
agg_fields['first_attempted'] = now
scores.append(ProblemScore(weighted_earned=3, weighted_possible=5, graded=True, **prob_fields))
all_total, graded_total = aggregate_scores(scores)
self.assertAlmostEqual(
......@@ -89,7 +95,7 @@ class GraderTest(unittest.TestCase):
self.graded_total = graded_total
self.display_name = display_name
common_fields = dict(graded=True, attempted=True)
common_fields = dict(graded=True, first_attempted=datetime.now())
test_gradesheet = {
'Homework': {
'hw1': MockGrade(AggregatedScore(tw_earned=2, tw_possible=20.0, **common_fields), display_name='hw1'),
......
......@@ -942,7 +942,7 @@ class ScoresClient(object):
Eventually, this should read and write scores, but at the moment it only
handles the read side of things.
"""
Score = namedtuple('Score', 'correct total')
Score = namedtuple('Score', 'correct total created')
def __init__(self, course_key, user_id):
self.course_key = course_key
......@@ -965,9 +965,9 @@ class ScoresClient(object):
# 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.
self._locations_to_scores.update({
UsageKey.from_string(location).map_into_course(self.course_key): self.Score(correct, total)
for location, correct, total
in scores_qset.values_list('module_state_key', 'grade', 'max_grade')
UsageKey.from_string(location).map_into_course(self.course_key): self.Score(correct, total, created)
for location, correct, total, created
in scores_qset.values_list('module_state_key', 'grade', 'max_grade', 'created')
})
self._has_fetched = True
......
......@@ -2,16 +2,20 @@
"""
Integration tests for submitting problem responses and getting grades.
"""
import ddt
# pylint: disable=attribute-defined-outside-init
import json
import os
from textwrap import dedent
import ddt
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import RequestFactory
from django.utils.timezone import now
from mock import patch
from nose.plugins.attrib import attr
......@@ -386,7 +390,6 @@ class TestCourseGrader(TestSubmittingProblems):
"""
Set up a simple course for testing weighted grading functionality.
"""
# pylint: disable=attribute-defined-outside-init
self.set_weighted_policy(hw_weight, final_weight)
......@@ -449,12 +452,13 @@ class TestCourseGrader(TestSubmittingProblems):
self.hw3_names = ['h3p1', 'h3p2']
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[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[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[1], 1)
......@@ -619,7 +623,11 @@ class TestCourseGrader(TestSubmittingProblems):
with patch('submissions.api.get_scores') as mock_get_scores:
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()
......
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):
"""
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):
......
......@@ -11,6 +11,7 @@ WAFFLE_NAMESPACE = u'grades'
# Switches
WRITE_ONLY_IF_ENGAGED = u'write_only_if_engaged'
ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent'
ESTIMATE_FIRST_ATTEMPTED = u'estimate_first_attempted'
def waffle():
......
......@@ -81,7 +81,7 @@ class TestResetGrades(TestCase):
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": MagicMock(),
"attempted": True,
"first_attempted": datetime.now(),
}
for course_key in courses_keys:
......
......@@ -26,6 +26,7 @@ from coursewarehistoryextended.fields import UnsignedBigIntAutoField
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField
from .config import waffle
log = logging.getLogger(__name__)
......@@ -361,9 +362,9 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
"""
cls._prepare_params_and_visible_blocks(params)
first_attempted = params.pop('first_attempted')
user_id = params.pop('user_id')
usage_key = params.pop('usage_key')
attempted = params.pop('attempted')
grade, _ = cls.objects.update_or_create(
user_id=user_id,
......@@ -371,20 +372,33 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
usage_key=usage_key,
defaults=params,
)
if attempted and not grade.first_attempted:
grade.first_attempted = now()
if first_attempted is not None and grade.first_attempted is None:
if waffle.waffle().is_enabled(waffle.ESTIMATE_FIRST_ATTEMPTED):
grade.first_attempted = first_attempted
else:
grade.first_attempted = now()
grade.save()
cls._emit_grade_calculated_event(grade)
return grade
@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):
"""
Wrapper for objects.create.
"""
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)
cls._emit_grade_calculated_event(grade)
return grade
......@@ -400,9 +414,7 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
map(cls._prepare_params, grade_params_iter)
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)
first_attempt_timestamp = now()
for params in grade_params_iter:
cls._prepare_attempted_for_create(params, first_attempt_timestamp)
map(cls._prepare_first_attempted_for_create, grade_params_iter)
grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter]
grades = cls.objects.bulk_create(grades)
for grade in grades:
......@@ -429,15 +441,6 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
params['visible_blocks'] = BlockRecordList.from_list(params['visible_blocks'], params['course_id'])
@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):
"""
Prepares the visible_blocks_id field for the grade record,
......
......@@ -24,10 +24,13 @@ class CourseGradeBase(object):
self.passed = passed
def __unicode__(self):
return u'Course Grade: percent: %s, letter_grade: %s, passed: %s'.format(
unicode(self.percent), self.letter_grade, self.passed,
return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format(
unicode(self.percent),
self.letter_grade,
self.passed,
)
@property
def attempted(self):
"""
Returns whether at least one problem was attempted
......@@ -210,7 +213,7 @@ class CourseGrade(CourseGradeBase):
"""
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
if subsection_grade.attempted:
if subsection_grade.all_total.first_attempted:
return True
return False
......
......@@ -41,28 +41,16 @@ class SubsectionGradeBase(object):
"""
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 for Subsection Grades with Zero values.
"""
def __init__(self, subsection, course_data):
super(ZeroSubsectionGrade, self).__init__(subsection)
self.graded_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=False, attempted=False)
self.all_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=self.graded, 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, first_attempted=None)
self.course_data = course_data
@lazy
......@@ -118,13 +106,13 @@ class SubsectionGrade(SubsectionGradeBase):
tw_earned=model.earned_graded,
tw_possible=model.possible_graded,
graded=True,
attempted=model.first_attempted is not None,
first_attempted=model.first_attempted,
)
self.all_total = AggregatedScore(
tw_earned=model.earned_all,
tw_possible=model.possible_all,
graded=False,
attempted=model.first_attempted is not None,
first_attempted=model.first_attempted,
)
self._log_event(log.debug, u"init_from_model", student)
return self
......@@ -162,7 +150,7 @@ class SubsectionGrade(SubsectionGradeBase):
Returns whether the SubsectionGrade's model should be
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(
self,
......@@ -209,7 +197,7 @@ class SubsectionGrade(SubsectionGradeBase):
earned_graded=self.graded_total.earned,
possible_graded=self.graded_total.possible,
visible_blocks=self._get_visible_blocks,
attempted=self.attempted
first_attempted=self.all_total.first_attempted,
)
@property
......
......@@ -101,7 +101,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block):
# Priority order for retrieving the scores:
# submissions API -> CSM -> grades persisted block -> latest block content
raw_earned, raw_possible, weighted_earned, weighted_possible, attempted = (
raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted = (
_get_score_from_submissions(submissions_scores, block) or
_get_score_from_csm(csm_scores, block, weight) or
_get_score_from_persisted_or_latest_block(persisted_block, block, weight)
......@@ -121,7 +121,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block):
weighted_possible,
weight,
graded,
attempted=attempted,
first_attempted=first_attempted,
)
......@@ -149,10 +149,11 @@ def _get_score_from_submissions(submissions_scores, block):
if submissions_scores:
submission_value = submissions_scores.get(unicode(block.location))
if submission_value:
attempted = True
weighted_earned, weighted_possible = submission_value
first_attempted = submission_value['created_at']
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
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):
......@@ -175,13 +176,14 @@ def _get_score_from_csm(csm_scores, block, weight):
has_valid_score = score and score.total is not None
if has_valid_score:
if score.correct is not None:
attempted = True
first_attempted = score.created
raw_earned = score.correct
else:
attempted = False
first_attempted = None
raw_earned = 0.0
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):
......@@ -192,7 +194,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
the latest block content.
"""
raw_earned = 0.0
attempted = False
first_attempted = None
if persisted_block:
raw_possible = persisted_block.raw_possible
......@@ -205,7 +207,7 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
else:
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):
......
......@@ -2,8 +2,10 @@
Test grade calculation.
"""
import ddt
import datetime
import itertools
import ddt
from mock import patch
from nose.plugins.attrib import attr
......@@ -202,6 +204,8 @@ class TestWeightedProblems(SharedModuleStoreTestCase):
# verify all problem grades
for problem in self.problems:
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)
# verify subsection grades
......@@ -235,7 +239,7 @@ class TestWeightedProblems(SharedModuleStoreTestCase):
weighted_possible=expected_w_possible,
weight=weight,
graded=expected_graded,
attempted=True,
first_attempted=datetime.datetime(2010, 1, 1),
)
self._verify_grades(raw_earned, raw_possible, weight, expected_score)
......
......@@ -14,8 +14,10 @@ from django.test import TestCase
from django.utils.timezone import now
from freezegun import freeze_time
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 lms.djangoapps.grades.config import waffle
from lms.djangoapps.grades.models import (
BlockRecord,
BlockRecordList,
......@@ -212,7 +214,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": self.block_records,
"attempted": True,
"first_attempted": datetime(2000, 1, 1, 12, 30, 45, tzinfo=pytz.UTC),
}
def test_create(self):
......@@ -242,8 +244,8 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
("possible_all", IntegrityError),
("earned_graded", IntegrityError),
("possible_graded", IntegrityError),
("first_attempted", KeyError),
("visible_blocks", KeyError),
("attempted", KeyError),
)
@ddt.unpack
def test_non_optional_fields(self, field, error):
......@@ -262,12 +264,21 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
self.assertEqual(created_grade.id, updated_grade.id)
self.assertEqual(created_grade.earned_all, 6)
def test_update_or_create_attempted(self):
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime)
@ddt.unpack
@ddt.data(
(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):
self.params['attempted'] = False
self.params['first_attempted'] = None
self.params['earned_all'] = 0.0
self.params['earned_graded'] = 0.0
grade = PersistentSubsectionGrade.create_grade(**self.params)
......@@ -283,7 +294,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
def test_unattempted_save_does_not_remove_attempt(self):
PersistentSubsectionGrade.create_grade(**self.params)
self.params['attempted'] = False
self.params['first_attempted'] = None
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime)
self.assertEqual(grade.earned_all, 6.0)
......
......@@ -181,7 +181,7 @@ class TestCourseGradeFactory(GradeTestBase):
with self.assertNumQueries(12), mock_get_score(1, 2):
_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)
with self.assertNumQueries(1):
......@@ -294,7 +294,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
self.assertFalse(mock_create_grade.called)
self.assertEqual(grade_a.url_name, grade_b.url_name)
grade_b.all_total.attempted = False # TODO TNL-5930
grade_b.all_total.first_attempted = None
self.assertEqual(grade_a.all_total, grade_b.all_total)
def test_update(self):
......@@ -360,7 +360,7 @@ class ZeroGradeTest(GradeTestBase):
for section in chapter_grades[chapter]['sections']:
for score in section.locations_to_scores.itervalues():
self.assertEqual(score.earned, 0)
self.assertEqual(score.attempted, False)
self.assertEqual(score.first_attempted, None)
self.assertEqual(section.all_total.earned, 0)
......@@ -403,7 +403,7 @@ class SubsectionGradeTest(GradeTestBase):
)
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)
......@@ -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
block_count = self.SCORED_BLOCK_COUNT - 1
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)
)
score = subsection_factory.update(self.seq1)
......
......@@ -5,6 +5,7 @@ Tests for grades.scores module.
from collections import namedtuple
import ddt
from django.test import TestCase
from django.utils.timezone import now
import itertools
from lms.djangoapps.grades.models import BlockRecord
......@@ -15,6 +16,9 @@ from openedx.core.djangoapps.content.block_structure.block_structure import Bloc
from xmodule.graders import ProblemScore
NOW = now()
class TestScoredBlockTypes(TestCase):
"""
Tests for the possibly_scored function.
......@@ -47,12 +51,13 @@ class TestGetScore(TestCase):
display_name = 'test_name'
location = 'test_location'
SubmissionValue = namedtuple('SubmissionValue', 'exists, weighted_earned, weighted_possible')
CSMValue = namedtuple('CSMValue', 'exists, raw_earned, raw_possible')
SubmissionValue = namedtuple('SubmissionValue', 'exists, points_earned, points_possible, created_at')
CSMValue = namedtuple('CSMValue', 'exists, raw_earned, raw_possible, created')
PersistedBlockValue = namedtuple('PersistedBlockValue', 'exists, raw_possible, weight, graded')
ContentBlockValue = namedtuple('ContentBlockValue', 'raw_possible, weight, explicit_graded')
ExpectedResult = namedtuple(
'ExpectedResult', 'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded, attempted'
'ExpectedResult',
'raw_earned, raw_possible, weighted_earned, weighted_possible, weight, graded, first_attempted'
)
def _create_submissions_scores(self, submission_value):
......@@ -60,7 +65,7 @@ class TestGetScore(TestCase):
Creates a stub result from the submissions API for the given values.
"""
if submission_value.exists:
return {self.location: (submission_value.weighted_earned, submission_value.weighted_possible)}
return {self.location: submission_value._asdict()}
else:
return {}
......@@ -69,8 +74,14 @@ class TestGetScore(TestCase):
Creates a stub result from courseware student module for the given values.
"""
if csm_value.exists:
stub_csm_record = namedtuple('stub_csm_record', 'correct, total')
return {self.location: stub_csm_record(correct=csm_value.raw_earned, total=csm_value.raw_possible)}
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,
created=csm_value.created
)
}
else:
return {}
......@@ -106,64 +117,66 @@ class TestGetScore(TestCase):
return block
@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),
CSMValue(exists=True, raw_earned=10, raw_possible=40),
SubmissionValue(exists=True, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult(
raw_earned=None, raw_possible=None,
weighted_earned=50, weighted_possible=100,
weight=40, graded=True, 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),
CSMValue(exists=True, raw_earned=10, raw_possible=40),
SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult(
raw_earned=10, raw_possible=40,
weighted_earned=10, weighted_possible=40,
weight=40, graded=True, attempted=True,
weight=40, graded=True, first_attempted=NOW,
),
),
# CSM values exist, but with NULL earned score treated as not-attempted
(
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100),
CSMValue(exists=True, raw_earned=None, raw_possible=40),
SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=True, raw_earned=None, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult(
raw_earned=0, raw_possible=40,
weighted_earned=0, weighted_possible=40,
weight=40, graded=True, attempted=False,
weight=40, graded=True, first_attempted=None
),
),
# neither submissions nor CSM exist; Persisted values used
(
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100),
CSMValue(exists=False, raw_earned=10, raw_possible=40),
SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=False, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=True, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult(
raw_earned=0, raw_possible=5,
weighted_earned=0, weighted_possible=40,
weight=40, graded=True, attempted=False,
weight=40, graded=True, first_attempted=None
),
),
# none of submissions, CSM, or persisted exist; Latest content values used
(
SubmissionValue(exists=False, weighted_earned=50, weighted_possible=100),
CSMValue(exists=False, raw_earned=10, raw_possible=40),
SubmissionValue(exists=False, points_earned=50, points_possible=100, created_at=NOW),
CSMValue(exists=False, raw_earned=10, raw_possible=40, created=NOW),
PersistedBlockValue(exists=False, raw_possible=5, weight=40, graded=True),
ContentBlockValue(raw_possible=1, weight=20, explicit_graded=False),
ExpectedResult(
raw_earned=0, raw_possible=1,
weighted_earned=0, weighted_possible=20,
weight=20, graded=False, attempted=False,
weight=20, graded=False,
first_attempted=None
),
),
)
......@@ -278,7 +291,7 @@ class TestInternalGetScoreFromBlock(TestCase):
"""
# 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)
self.assertEquals(raw_earned, 0.0)
......@@ -288,7 +301,7 @@ class TestInternalGetScoreFromBlock(TestCase):
self.assertEquals(weighted_possible, expected_r_possible)
else:
self.assertEquals(weighted_possible, weight)
self.assertFalse(attempted)
self.assertIsNone(first_attempted)
@ddt.data(
*itertools.product((0, 1, 5), (None, 0, 1, 5))
......
......@@ -2,6 +2,7 @@
Utilities for grades related tests
"""
from contextlib import contextmanager
from datetime import datetime
from mock import patch
from courseware.module_render import get_module
from courseware.model_data import FieldDataCache
......@@ -33,18 +34,18 @@ def mock_get_score(earned=0, possible=1):
weighted_possible=possible,
weight=1,
graded=True,
attempted=True,
first_attempted=datetime(2000, 1, 1, 0, 0, 0)
)
yield mock_score
@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
"""
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
......
......@@ -848,7 +848,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
except KeyError:
grade_results.append([u'Not Available'])
else:
if subsection_grade.graded_total.attempted:
if subsection_grade.graded_total.first_attempted is not None:
grade_results.append(
[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
except KeyError:
earned_possible_values.append([u'Not Available', u'Not Available'])
else:
if problem_score.attempted:
if problem_score.first_attempted:
earned_possible_values.append([problem_score.earned, problem_score.possible])
else:
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
-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/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
git+https://github.com/edx/edx-ora2.git@1.3.2#egg=ora2==1.3.2
-e git+https://github.com/edx/edx-submissions.git@1.2.0#egg=edx-submissions==1.2.0
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@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/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
......
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