Commit 2298bd25 by Tyler Hallada Committed by GitHub

Merge pull request #15791 from edx/EDUCATOR-926

EDUCATOR-1127 Override grade to zero when exam attempt is rejected Final
parents 65ee2ebb a9c1e1cf
...@@ -412,7 +412,22 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel): ...@@ -412,7 +412,22 @@ class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
usage_key = params.pop('usage_key') usage_key = params.pop('usage_key')
# apply grade override if one exists before saving model # apply grade override if one exists before saving model
# EDUCTATOR-1127: remove override until this behavior is verified in production try:
override = PersistentSubsectionGradeOverride.objects.get(
grade__user_id=user_id,
grade__course_id=usage_key.course_key,
grade__usage_key=usage_key,
)
if override.earned_all_override is not None:
params['earned_all'] = override.earned_all_override
if override.possible_all_override is not None:
params['possible_all'] = override.possible_all_override
if override.earned_graded_override is not None:
params['earned_graded'] = override.earned_graded_override
if override.possible_graded_override is not None:
params['possible_graded'] = override.possible_graded_override
except PersistentSubsectionGradeOverride.DoesNotExist:
pass
grade, _ = cls.objects.update_or_create( grade, _ = cls.objects.update_or_create(
user_id=user_id, user_id=user_id,
......
from datetime import datetime from datetime import datetime
import logging
import pytz import pytz
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
...@@ -12,8 +11,6 @@ from .constants import ScoreDatabaseTableEnum ...@@ -12,8 +11,6 @@ from .constants import ScoreDatabaseTableEnum
from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride from .models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride
from .signals.signals import SUBSECTION_OVERRIDE_CHANGED from .signals.signals import SUBSECTION_OVERRIDE_CHANGED
log = logging.getLogger(__name__)
def _get_key(key_or_id, key_cls): def _get_key(key_or_id, key_cls):
""" """
...@@ -73,21 +70,40 @@ class GradesService(object): ...@@ -73,21 +70,40 @@ class GradesService(object):
Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. Will not Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. Will not
override earned_all or earned_graded value if they are None. Both default to None. override earned_all or earned_graded value if they are None. Both default to None.
""" """
# prevent circular imports:
from .signals.handlers import SUBSECTION_OVERRIDE_EVENT_TYPE
course_key = _get_key(course_key_or_id, CourseKey) course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey) usage_key = _get_key(usage_key_or_id, UsageKey)
log.info( grade = PersistentSubsectionGrade.objects.get(
u"EDUCATOR-1127: Subsection grade override for user {user_id} on subsection {usage_key} in course " user_id=user_id,
u"{course_key} would be created with params: {params}" course_id=course_key,
.format( usage_key=usage_key
user_id=unicode(user_id), )
usage_key=unicode(usage_key),
course_key=unicode(course_key), # Create override that will prevent any future updates to grade
params=unicode({ override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(
'earned_all': earned_all, grade=grade,
'earned_graded': earned_graded, earned_all_override=earned_all,
}) earned_graded_override=earned_graded
) )
# Cache a new event id and event type which the signal handler will use to emit a tracking log event.
create_new_event_transaction_id()
set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE)
# Signal will trigger subsection recalculation which will call PersistentSubsectionGrade.update_or_create_grade
# which will use the above override to update the grade before writing to the table.
SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=unicode(course_key),
usage_id=unicode(usage_key),
only_if_higher=False,
modified=override.modified,
score_deleted=False,
score_db_table=ScoreDatabaseTableEnum.overrides
) )
def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id): def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
...@@ -97,17 +113,33 @@ class GradesService(object): ...@@ -97,17 +113,33 @@ class GradesService(object):
Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. If the Fires off a recalculate_subsection_grade async task to update the PersistentSubsectionGrade table. If the
override does not exist, no error is raised, it just triggers the recalculation. override does not exist, no error is raised, it just triggers the recalculation.
""" """
# prevent circular imports:
from .signals.handlers import SUBSECTION_OVERRIDE_EVENT_TYPE
course_key = _get_key(course_key_or_id, CourseKey) course_key = _get_key(course_key_or_id, CourseKey)
usage_key = _get_key(usage_key_or_id, UsageKey) usage_key = _get_key(usage_key_or_id, UsageKey)
log.info( override = self.get_subsection_grade_override(user_id, course_key, usage_key)
u"EDUCATOR-1127: Subsection grade override for user {user_id} on subsection {usage_key} in course " # Older rejected exam attempts that transition to verified might not have an override created
u"{course_key} would be deleted" if override is not None:
.format( override.delete()
user_id=unicode(user_id),
usage_key=unicode(usage_key), # Cache a new event id and event type which the signal handler will use to emit a tracking log event.
course_key=unicode(course_key) create_new_event_transaction_id()
) set_event_transaction_type(SUBSECTION_OVERRIDE_EVENT_TYPE)
# Signal will trigger subsection recalculation which will call PersistentSubsectionGrade.update_or_create_grade
# which will no longer use the above deleted override, and instead return the grade to the original score from
# the actual problem responses before writing to the table.
SUBSECTION_OVERRIDE_CHANGED.send(
sender=None,
user_id=user_id,
course_id=unicode(course_key),
usage_id=unicode(usage_key),
only_if_higher=False,
modified=datetime.now().replace(tzinfo=pytz.UTC), # Not used when score_deleted=True
score_deleted=True,
score_db_table=ScoreDatabaseTableEnum.overrides
) )
def should_override_grade_on_rejected_exam(self, course_key_or_id): def should_override_grade_on_rejected_exam(self, course_key_or_id):
......
...@@ -312,9 +312,8 @@ class PersistentSubsectionGradeTest(GradesModelTestCase): ...@@ -312,9 +312,8 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
override = PersistentSubsectionGradeOverride(grade=grade, earned_all_override=0.0, earned_graded_override=0.0) override = PersistentSubsectionGradeOverride(grade=grade, earned_all_override=0.0, earned_graded_override=0.0)
override.save() override.save()
grade = PersistentSubsectionGrade.update_or_create_grade(**self.params) grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
# EDUCATOR-1127 Override is not enabled yet, change to 0.0 when enabled self.assertEqual(grade.earned_all, 0.0)
self.assertEqual(grade.earned_all, 6.0) self.assertEqual(grade.earned_graded, 0.0)
self.assertEqual(grade.earned_graded, 6.0)
def _assert_tracker_emitted_event(self, tracker_mock, grade): def _assert_tracker_emitted_event(self, tracker_mock, grade):
""" """
......
...@@ -171,10 +171,28 @@ class GradesServiceTests(ModuleStoreTestCase): ...@@ -171,10 +171,28 @@ class GradesServiceTests(ModuleStoreTestCase):
self.course.id, self.course.id,
self.subsection.location self.subsection.location
) )
self.assertIsNone(override_obj) self.assertIsNotNone(override_obj)
self.assertEqual(override_obj.earned_all_override, override['earned_all'])
self.assertEqual(override_obj.earned_graded_override, override['earned_graded'])
self.assertEqual(
self.mock_signal.call_args,
call(
sender=None,
user_id=self.user.id,
course_id=unicode(self.course.id),
usage_id=unicode(self.subsection.location),
only_if_higher=False,
modified=override_obj.modified,
score_deleted=False,
score_db_table=ScoreDatabaseTableEnum.overrides
)
)
@freeze_time('2017-01-01') @freeze_time('2017-01-01')
def test_undo_override_subsection_grade(self): def test_undo_override_subsection_grade(self):
override, _ = PersistentSubsectionGradeOverride.objects.update_or_create(grade=self.grade)
self.service.undo_override_subsection_grade( self.service.undo_override_subsection_grade(
user_id=self.user.id, user_id=self.user.id,
course_key_or_id=self.course.id, course_key_or_id=self.course.id,
...@@ -184,6 +202,20 @@ class GradesServiceTests(ModuleStoreTestCase): ...@@ -184,6 +202,20 @@ class GradesServiceTests(ModuleStoreTestCase):
override = self.service.get_subsection_grade_override(self.user.id, self.course.id, self.subsection.location) override = self.service.get_subsection_grade_override(self.user.id, self.course.id, self.subsection.location)
self.assertIsNone(override) self.assertIsNone(override)
self.assertEqual(
self.mock_signal.call_args,
call(
sender=None,
user_id=self.user.id,
course_id=unicode(self.course.id),
usage_id=unicode(self.subsection.location),
only_if_higher=False,
modified=datetime.now().replace(tzinfo=pytz.UTC),
score_deleted=True,
score_db_table=ScoreDatabaseTableEnum.overrides
)
)
@ddt.data( @ddt.data(
['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey], ['edX/DemoX/Demo_Course', CourseKey.from_string('edX/DemoX/Demo_Course'), CourseKey],
['course-v1:edX+DemoX+Demo_Course', CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey], ['course-v1:edX+DemoX+Demo_Course', CourseKey.from_string('course-v1:edX+DemoX+Demo_Course'), CourseKey],
......
...@@ -163,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -163,10 +163,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_block_structure_create.call_count, 1) self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 29, True), (ModuleStoreEnum.Type.mongo, 1, 30, True),
(ModuleStoreEnum.Type.mongo, 1, 25, False), (ModuleStoreEnum.Type.mongo, 1, 26, False),
(ModuleStoreEnum.Type.split, 3, 29, True), (ModuleStoreEnum.Type.split, 3, 30, True),
(ModuleStoreEnum.Type.split, 3, 25, False), (ModuleStoreEnum.Type.split, 3, 26, False),
) )
@ddt.unpack @ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections): def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
...@@ -178,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -178,8 +178,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade() self._apply_recalculate_subsection_grade()
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 29), (ModuleStoreEnum.Type.mongo, 1, 30),
(ModuleStoreEnum.Type.split, 3, 29), (ModuleStoreEnum.Type.split, 3, 30),
) )
@ddt.unpack @ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls): def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
...@@ -239,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest ...@@ -239,8 +239,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0) self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 26), (ModuleStoreEnum.Type.mongo, 1, 27),
(ModuleStoreEnum.Type.split, 3, 26), (ModuleStoreEnum.Type.split, 3, 27),
) )
@ddt.unpack @ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries): def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
......
...@@ -183,18 +183,15 @@ from django.utils.http import urlquote_plus ...@@ -183,18 +183,15 @@ from django.utils.http import urlquote_plus
<em class="localized-datetime" data-datetime="${section.due}" data-string="${_('due {date}')}" data-timezone="${user_timezone}" data-language="${user_language}"></em> <em class="localized-datetime" data-datetime="${section.due}" data-string="${_('due {date}')}" data-timezone="${user_timezone}" data-language="${user_language}"></em>
%endif %endif
</p> </p>
<%doc> <p class="override-notice">
EDUCATOR-1127: Do not display override notice until override is enabled
%if section.override is not None: %if section.override is not None:
<p class="override-notice"> %if section.format is not None and section.format == "Exam":
%if section.format is not None and section.format == "Exam": ${_("Suspicious activity detected during proctored exam review. Exam score 0.")}
${_("Exam grade has been overridden due to a failed proctoring review.")} %else:
%else: ${_("Section grade has been overridden.")}
${_("Section grade has been overridden.")} %endif
%endif
</p>
%endif %endif
</%doc> </p>
%if len(section.problem_scores.values()) > 0: %if len(section.problem_scores.values()) > 0:
%if section.show_grades(staff_access): %if section.show_grades(staff_access):
<dl class="scores"> <dl class="scores">
......
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