Estimate creation time for subsections grades based on timestamp of
incoming scores. TNL-6697
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""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()
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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:
|
||||
@@ -428,15 +440,6 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
|
||||
params['course_version'] = params.get('course_version', None) or ""
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user