Commit ca5c741f by Cliff Dyer Committed by GitHub

Merge pull request #14027 from edx/cdyer/mark-attempted-problems

Use first_attempted value from database and attempted values from individual problems.
parents 624f31f3 5350e316
...@@ -15,6 +15,7 @@ import json ...@@ -15,6 +15,7 @@ import json
from lazy import lazy from lazy import lazy
import logging import logging
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
...@@ -244,6 +245,21 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -244,6 +245,21 @@ class PersistentSubsectionGrade(TimeStampedModel):
# track which blocks were visible at the time of grade calculation # track which blocks were visible at the time of grade calculation
visible_blocks = models.ForeignKey(VisibleBlocks, db_column='visible_blocks_hash', to_field='hashed') visible_blocks = models.ForeignKey(VisibleBlocks, db_column='visible_blocks_hash', to_field='hashed')
def _is_unattempted_with_score(self):
"""
Return True if the object has a non-zero score, but has not been
attempted. This is an inconsistent state, and needs to be cleaned up.
"""
return self.first_attempted is None and any(field != 0.0 for field in (self.earned_all, self.earned_graded))
def clean(self):
"""
If an grade has not been attempted, but was given a non-zero score,
raise a ValidationError.
"""
if self._is_unattempted_with_score():
raise ValidationError("Unattempted problems cannot have a non-zero score.")
@property @property
def full_usage_key(self): def full_usage_key(self):
""" """
...@@ -305,30 +321,39 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -305,30 +321,39 @@ class PersistentSubsectionGrade(TimeStampedModel):
) )
@classmethod @classmethod
def update_or_create_grade(cls, **kwargs): def update_or_create_grade(cls, **params):
""" """
Wrapper for objects.update_or_create. Wrapper for objects.update_or_create.
""" """
cls._prepare_params_and_visible_blocks(kwargs) cls._prepare_params_and_visible_blocks(params)
user_id = kwargs.pop('user_id') user_id = params.pop('user_id')
usage_key = kwargs.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,
course_id=usage_key.course_key, course_id=usage_key.course_key,
usage_key=usage_key, usage_key=usage_key,
defaults=kwargs, defaults=params,
) )
if attempted and not grade.first_attempted:
grade.first_attempted = now()
grade.save()
grade.full_clean()
return grade return grade
@classmethod @classmethod
def create_grade(cls, **kwargs): def create_grade(cls, **params):
""" """
Wrapper for objects.create. Wrapper for objects.create.
""" """
cls._prepare_params_and_visible_blocks(kwargs) cls._prepare_params_and_visible_blocks(params)
return cls.objects.create(**kwargs) cls._prepare_attempted_for_create(params, now())
grade = cls(**params)
grade.full_clean()
grade.save()
return grade
@classmethod @classmethod
def bulk_create_grades(cls, grade_params_iter, course_key): def bulk_create_grades(cls, grade_params_iter, course_key):
...@@ -341,8 +366,13 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -341,8 +366,13 @@ class PersistentSubsectionGrade(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()
return cls.objects.bulk_create([PersistentSubsectionGrade(**params) for params in 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]
for grade in grades:
grade.full_clean()
return cls.objects.bulk_create(grades)
@classmethod @classmethod
def _prepare_params_and_visible_blocks(cls, params): def _prepare_params_and_visible_blocks(cls, params):
...@@ -364,6 +394,15 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -364,6 +394,15 @@ class PersistentSubsectionGrade(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,
...@@ -422,7 +461,7 @@ class PersistentCourseGrade(TimeStampedModel): ...@@ -422,7 +461,7 @@ class PersistentCourseGrade(TimeStampedModel):
u"grading policy: {}".format(self.grading_policy_hash), u"grading policy: {}".format(self.grading_policy_hash),
u"percent grade: {}%".format(self.percent_grade), u"percent grade: {}%".format(self.percent_grade),
u"letter grade: {}".format(self.letter_grade), u"letter grade: {}".format(self.letter_grade),
u"passed_timestamp: {}".format(self.passed_timestamp), u"passed timestamp: {}".format(self.passed_timestamp),
]) ])
@classmethod @classmethod
......
...@@ -51,6 +51,11 @@ class SubsectionGrade(object): ...@@ -51,6 +51,11 @@ class SubsectionGrade(object):
Returns whether any problem in this subsection Returns whether any problem in this subsection
was attempted by the student. 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 return self.all_total.attempted
def init_from_structure(self, student, course_structure, submissions_scores, csm_scores): def init_from_structure(self, student, course_structure, submissions_scores, csm_scores):
...@@ -80,7 +85,7 @@ class SubsectionGrade(object): ...@@ -80,7 +85,7 @@ class SubsectionGrade(object):
graded=True, graded=True,
display_name=self.display_name, display_name=self.display_name,
module_id=self.location, module_id=self.location,
attempted=True, # TODO TNL-5930 attempted=model.first_attempted is not None,
) )
self.all_total = AggregatedScore( self.all_total = AggregatedScore(
tw_earned=model.earned_all, tw_earned=model.earned_all,
...@@ -88,7 +93,7 @@ class SubsectionGrade(object): ...@@ -88,7 +93,7 @@ class SubsectionGrade(object):
graded=False, graded=False,
display_name=self.display_name, display_name=self.display_name,
module_id=self.location, module_id=self.location,
attempted=True, # TODO TNL-5930 attempted=model.first_attempted is not None,
) )
self._log_event(log.debug, u"init_from_model", student) self._log_event(log.debug, u"init_from_model", student)
return self return self
...@@ -156,6 +161,7 @@ class SubsectionGrade(object): ...@@ -156,6 +161,7 @@ class SubsectionGrade(object):
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
) )
@property @property
......
...@@ -8,6 +8,7 @@ import ddt ...@@ -8,6 +8,7 @@ import ddt
from hashlib import sha1 from hashlib import sha1
import json import json
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
...@@ -210,7 +211,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -210,7 +211,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,
"first_attempted": "2016-08-01 18:53:24.354741", "attempted": True,
} }
def test_create(self): def test_create(self):
...@@ -225,31 +226,23 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -225,31 +226,23 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
) )
self.assertEqual(created_grade, read_grade) self.assertEqual(created_grade, read_grade)
self.assertEqual(read_grade.visible_blocks.blocks, self.block_records) self.assertEqual(read_grade.visible_blocks.blocks, self.block_records)
with self.assertRaises(IntegrityError): with self.assertRaises(ValidationError):
PersistentSubsectionGrade.create_grade(**self.params) PersistentSubsectionGrade.create_grade(**self.params)
def test_create_bad_params(self): def test_optional_fields(self):
""" del self.params["course_version"]
Confirms create will fail if params are missing.
"""
del self.params["earned_graded"]
with self.assertRaises(IntegrityError):
PersistentSubsectionGrade.create_grade(**self.params)
@ddt.data("course_version", "first_attempted")
def test_optional_fields(self, field):
del self.params[field]
PersistentSubsectionGrade.create_grade(**self.params) PersistentSubsectionGrade.create_grade(**self.params)
@ddt.data( @ddt.data(
("user_id", IntegrityError), ("user_id", ValidationError),
("usage_key", KeyError), ("usage_key", KeyError),
("subtree_edited_timestamp", IntegrityError), ("subtree_edited_timestamp", ValidationError),
("earned_all", IntegrityError), ("earned_all", ValidationError),
("possible_all", IntegrityError), ("possible_all", ValidationError),
("earned_graded", IntegrityError), ("earned_graded", ValidationError),
("possible_graded", IntegrityError), ("possible_graded", ValidationError),
("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):
...@@ -268,6 +261,44 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -268,6 +261,44 @@ 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):
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime)
def test_unattempted(self):
self.params['attempted'] = False
self.params['earned_all'] = 0.0
self.params['earned_graded'] = 0.0
grade = PersistentSubsectionGrade.create_grade(**self.params)
self.assertIsNone(grade.first_attempted)
self.assertEqual(grade.earned_all, 0.0)
self.assertEqual(grade.earned_graded, 0.0)
def test_create_inconsistent_unattempted(self):
self.params['attempted'] = False
with self.assertRaises(ValidationError):
PersistentSubsectionGrade.create_grade(**self.params)
def test_update_or_create_inconsistent_unattempted(self):
self.params['attempted'] = False
self.params['earned_all'] = 1.0
self.params['earned_graded'] = 1.0
with self.assertRaises(ValidationError):
PersistentSubsectionGrade.update_or_create_grade(**self.params)
def test_first_attempted_not_changed_on_update(self):
PersistentSubsectionGrade.create_grade(**self.params)
moment = now()
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertLess(grade.first_attempted, moment)
def test_unattempted_save_does_not_remove_attempt(self):
PersistentSubsectionGrade.create_grade(**self.params)
self.params['attempted'] = False
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertIsInstance(grade.first_attempted, datetime)
self.assertEqual(grade.earned_all, 6.0)
@ddt.ddt @ddt.ddt
class PersistentCourseGradesTest(GradesModelTestCase): class PersistentCourseGradesTest(GradesModelTestCase):
......
...@@ -214,7 +214,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): ...@@ -214,7 +214,7 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_bulk_cached_grade', 'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_bulk_cached_grade',
wraps=self.subsection_grade_factory._get_bulk_cached_grade wraps=self.subsection_grade_factory._get_bulk_cached_grade
) as mock_get_bulk_cached_grade: ) as mock_get_bulk_cached_grade:
with self.assertNumQueries(12): with self.assertNumQueries(14):
grade_a = self.subsection_grade_factory.create(self.sequence) grade_a = self.subsection_grade_factory.create(self.sequence)
self.assertTrue(mock_get_bulk_cached_grade.called) self.assertTrue(mock_get_bulk_cached_grade.called)
self.assertTrue(mock_create_grade.called) self.assertTrue(mock_create_grade.called)
......
...@@ -113,7 +113,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -113,7 +113,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
with self.store.default_store(default_store): with self.store.default_store(default_store):
self.set_up_course() self.set_up_course()
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id)) self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
with check_mongo_calls(2) and self.assertNumQueries(20 + added_queries): with check_mongo_calls(2) and self.assertNumQueries(22 + added_queries):
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
@patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send') @patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
...@@ -161,7 +161,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): ...@@ -161,7 +161,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id)) self.assertTrue(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2') ItemFactory.create(parent=self.sequential, category='problem', display_name='problem2')
ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3') ItemFactory.create(parent=self.sequential, category='problem', display_name='problem3')
with check_mongo_calls(2) and self.assertNumQueries(20 + added_queries): with check_mongo_calls(2) and self.assertNumQueries(22 + added_queries):
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
......
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