Commit 52e861b9 by Tyler Hallada Committed by GitHub

Merge pull request #365 from edx/EDUCATOR-927

EDUCATOR-927 Override grade to zero when exam attempt is rejected
parents 952ad87c 7c401352
......@@ -4,3 +4,4 @@ Afzal Wali <afzal@edx.org>
Mushtaq Ali <mushtaak@gmail.com>
Christina Roberts <christina@edx.org>
Dennis Jen <djen@edx.org>
Tyler Hallada <thallada@edx.org>
......@@ -4,6 +4,6 @@ The exam proctoring subsystem for the Open edX platform.
from __future__ import absolute_import
__version__ = '0.19.0'
__version__ = '1.0.0'
default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
......@@ -58,6 +58,8 @@ SHOW_EXPIRY_MESSAGE_DURATION = 1 * 60 # duration within which expiry message is
APPROVED_STATUS = 'approved'
REJECTED_GRADE_OVERRIDE_EARNED = 0.0
def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None,
is_proctored=True, is_practice_exam=False, external_id=None, is_active=True, hide_after_due=False):
......@@ -762,6 +764,7 @@ def update_attempt_status(exam_id, user_id, to_status,
else:
return
from_status = exam_attempt_obj.status
exam = get_exam_by_id(exam_id)
#
......@@ -776,7 +779,7 @@ def update_attempt_status(exam_id, user_id, to_status,
'A status transition from {from_status} to {to_status} was attempted '
'on exam_id {exam_id} for user_id {user_id}. This is not '
'allowed!'.format(
from_status=exam_attempt_obj.status,
from_status=from_status,
to_status=to_status,
exam_id=exam_id,
user_id=user_id
......@@ -791,7 +794,7 @@ def update_attempt_status(exam_id, user_id, to_status,
'A status transition from {from_status} to {to_status} was attempted '
'on exam_id {exam_id} for user_id {user_id}. This is not '
'allowed!'.format(
from_status=exam_attempt_obj.status,
from_status=from_status,
to_status=to_status,
exam_id=exam_id,
user_id=user_id
......@@ -901,6 +904,59 @@ def update_attempt_status(exam_id, user_id, to_status,
cascade_effects=False
)
if ProctoredExamStudentAttemptStatus.needs_grade_override(to_status):
grades_service = get_runtime_service('grades')
if grades_service.should_override_grade_on_rejected_exam(exam['course_id']):
log_msg = (
'Overriding exam subsection grade for '
'user_id {user_id} on {course_id} for '
'content_id {content_id}. Override '
'earned_all: {earned_all}, '
'earned_graded: {earned_graded}.'.format(
user_id=exam_attempt_obj.user_id,
course_id=exam['course_id'],
content_id=exam_attempt_obj.proctored_exam.content_id,
earned_all=REJECTED_GRADE_OVERRIDE_EARNED,
earned_graded=REJECTED_GRADE_OVERRIDE_EARNED
)
)
log.info(log_msg)
grades_service.override_subsection_grade(
user_id=exam_attempt_obj.user_id,
course_key_or_id=exam['course_id'],
usage_key_or_id=exam_attempt_obj.proctored_exam.content_id,
earned_all=REJECTED_GRADE_OVERRIDE_EARNED,
earned_graded=REJECTED_GRADE_OVERRIDE_EARNED
)
if (to_status == ProctoredExamStudentAttemptStatus.verified and
ProctoredExamStudentAttemptStatus.needs_grade_override(from_status)):
grades_service = get_runtime_service('grades')
if grades_service.should_override_grade_on_rejected_exam(exam['course_id']):
log_msg = (
'Deleting override of exam subsection grade for '
'user_id {user_id} on {course_id} for '
'content_id {content_id}. Override '
'earned_all: {earned_all}, '
'earned_graded: {earned_graded}.'.format(
user_id=exam_attempt_obj.user_id,
course_id=exam['course_id'],
content_id=exam_attempt_obj.proctored_exam.content_id,
earned_all=REJECTED_GRADE_OVERRIDE_EARNED,
earned_graded=REJECTED_GRADE_OVERRIDE_EARNED
)
)
log.info(log_msg)
grades_service.undo_override_subsection_grade(
user_id=exam_attempt_obj.user_id,
course_key_or_id=exam['course_id'],
usage_key_or_id=exam_attempt_obj.proctored_exam.content_id,
)
# call service to get course name.
credit_service = get_runtime_service('credit')
credit_state = credit_service.get_credit_state(
......
......@@ -43,7 +43,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance
)
from edx_proctoring.backends.tests.test_review_payload import create_test_review_payload
from edx_proctoring.tests.test_services import MockCreditService, MockInstructorService
from edx_proctoring.tests.test_services import MockCreditService, MockInstructorService, MockGradesService
from edx_proctoring.backends.software_secure import SOFTWARE_SECURE_INVALID_CHARS
......@@ -104,6 +104,7 @@ class SoftwareSecureTests(TestCase):
set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService())
set_runtime_service('grades', MockGradesService())
def tearDown(self):
"""
......@@ -111,6 +112,7 @@ class SoftwareSecureTests(TestCase):
"""
super(SoftwareSecureTests, self).tearDown()
set_runtime_service('credit', None)
set_runtime_service('grades', None)
def test_provider_instance(self):
"""
......
......@@ -15,6 +15,7 @@ from edx_proctoring.management.commands import set_attempt_status
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from edx_proctoring.tests.test_services import (
MockCreditService,
MockGradesService
)
from edx_proctoring.runtime import set_runtime_service
......@@ -31,6 +32,7 @@ class SetAttemptStatusTests(LoggedInTestCase):
"""
super(SetAttemptStatusTests, self).setUp()
set_runtime_service('credit', MockCreditService())
set_runtime_service('grades', MockGradesService())
self.exam_id = create_exam(
course_id='foo',
content_id='bar',
......
......@@ -214,6 +214,15 @@ class ProctoredExamStudentAttemptStatus(object):
]
@classmethod
def needs_grade_override(cls, to_status):
"""
Returns a boolean if the passed in to_status calls for an override of the learner's grade.
"""
return to_status in [
cls.rejected
]
@classmethod
def is_a_cascadable_failure(cls, to_status):
"""
Returns a boolean if the passed in to_status has a failure that needs to be cascaded
......
......@@ -72,6 +72,7 @@ from .test_services import (
MockCreditService,
MockCreditServiceNone,
MockCreditServiceWithCourseEndDate,
MockGradesService
)
from .utils import ProctoredExamTestCase
......@@ -862,6 +863,7 @@ class ProctoredExamApiTests(ProctoredExamTestCase):
are auto marked as declined
"""
set_runtime_service('grades', MockGradesService())
# create other exams in course
second_exam_id = create_exam(
course_id=self.course_id,
......@@ -930,6 +932,170 @@ class ProctoredExamApiTests(ProctoredExamTestCase):
self.assertIsNone(get_exam_attempt(timed_exam_id, self.user_id))
self.assertIsNone(get_exam_attempt(inactive_exam_id, self.user_id))
def test_grade_override(self):
"""
Verify that putting an attempt into the rejected state will also override
the learner's subsection grade for the exam
"""
set_runtime_service('grades', MockGradesService())
grades_service = get_runtime_service('grades')
exam_attempt = self._create_started_exam_attempt()
# Pretend learner answered 5 graded questions in the exam correctly
grades_service.init_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id,
earned_all=5.0,
earned_graded=5.0
)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.rejected
)
# Rejected exam attempt should override learner's grade to 0
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
self.assertDictEqual({
'earned_all': override.earned_all_override,
'earned_graded': override.earned_graded_override
}, {
'earned_all': 0.0,
'earned_graded': 0.0
})
# The MockGradeService updates the PersistentSubsectionGrade synchronously, but in the real GradesService, this
# would be updated by an asynchronous recalculation celery task.
grade = grades_service.get_subsection_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
self.assertDictEqual({
'earned_all': grade.earned_all,
'earned_graded': grade.earned_graded
}, {
'earned_all': 0.0,
'earned_graded': 0.0
})
# Verify that transitioning an attempt from the rejected state to the verified state
# will remove the override for the learner's subsection grade on the exam that was created
# when the attempt entered the rejected state.
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.verified
)
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
self.assertIsNone(override)
grade = grades_service.get_subsection_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
# Grade has returned to original score
self.assertDictEqual({
'earned_all': grade.earned_all,
'earned_graded': grade.earned_graded
}, {
'earned_all': 5.0,
'earned_graded': 5.0
})
def test_disabled_grade_override(self):
"""
Verify that when the REJECTED_EXAM_OVERRIDES_GRADE flag is disabled for a course,
the learner's subsection grade for the exam will not be overriden.
"""
set_runtime_service('grades', MockGradesService(rejected_exam_overrides_grade=False))
grades_service = get_runtime_service('grades')
exam_attempt = self._create_started_exam_attempt()
# Pretend learner answered 5 graded questions in the exam correctly
grades_service.init_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id,
earned_all=5.0,
earned_graded=5.0
)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.rejected
)
# Rejected exam attempt should not override learner's grade
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
self.assertIsNone(override)
grade = grades_service.get_subsection_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
# Grade is not overriden
self.assertDictEqual({
'earned_all': grade.earned_all,
'earned_graded': grade.earned_graded
}, {
'earned_all': 5.0,
'earned_graded': 5.0
})
# Transitioning from rejected to verified will also have no effect
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.verified
)
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
self.assertIsNone(override)
grade = grades_service.get_subsection_grade(
user_id=self.user.id,
course_key_or_id=exam_attempt.proctored_exam.course_id,
usage_key_or_id=exam_attempt.proctored_exam.content_id
)
# Grade has still the original score
self.assertDictEqual({
'earned_all': grade.earned_all,
'earned_graded': grade.earned_graded
}, {
'earned_all': 5.0,
'earned_graded': 5.0
})
@ddt.data(
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
......
......@@ -173,3 +173,69 @@ class TestProctoringService(unittest.TestCase):
service1 = ProctoringService()
service2 = ProctoringService()
self.assertIs(service1, service2)
class MockGrade(object):
"""Fake PersistentSubsectionGrade instance."""
def __init__(self, earned_all=0.0, earned_graded=0.0):
self.earned_all = earned_all
self.earned_graded = earned_graded
class MockGradeOverride(object):
"""Fake PersistentSubsectionGradeOverride instance."""
def __init__(self, earned_all=0.0, earned_graded=0.0):
self.earned_all_override = earned_all
self.earned_graded_override = earned_graded
class MockGradesService(object):
"""
Simple mock of the Grades Service
"""
def __init__(self, rejected_exam_overrides_grade=True):
"""Initialize empty data stores for grades and overrides (just dicts)"""
self.grades = {}
self.overrides = {}
self.rejected_exam_overrides_grade = rejected_exam_overrides_grade
def init_grade(self, user_id, course_key_or_id, usage_key_or_id, earned_all, earned_graded):
"""Initialize a grade in MockGradesService for testing. Actual GradesService does not have this method."""
self.grades[str(user_id) + str(course_key_or_id) + str(usage_key_or_id)] = MockGrade(
earned_all=earned_all,
earned_graded=earned_graded
)
def get_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
"""Returns entered grade for key (user_id + course_key + subsection) or None"""
key = str(user_id) + str(course_key_or_id) + str(usage_key_or_id)
if key in self.overrides:
# pretend override was applied
return MockGrade(
earned_all=self.overrides[key].earned_all_override,
earned_graded=self.overrides[key].earned_graded_override
)
return self.grades.get(str(user_id) + str(course_key_or_id) + str(usage_key_or_id))
def get_subsection_grade_override(self, user_id, course_key_or_id, usage_key_or_id):
"""Returns entered grade override for key (user_id + course_key + subsection) or None"""
return self.overrides.get(str(user_id) + str(course_key_or_id) + str(usage_key_or_id))
def override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id, earned_all=None,
earned_graded=None):
"""Sets grade override earned points for key (user_id + course_key + subsection)"""
key = str(user_id) + str(course_key_or_id) + str(usage_key_or_id)
self.overrides[key] = MockGradeOverride(
earned_all=earned_all,
earned_graded=earned_graded
)
def undo_override_subsection_grade(self, user_id, course_key_or_id, usage_key_or_id):
"""Deletes grade override for key (user_id + course_key + subsection)"""
key = str(user_id) + str(course_key_or_id) + str(usage_key_or_id)
if key in self.overrides:
del self.overrides[key]
def should_override_grade_on_rejected_exam(self, course_key):
"""Mock will always return instance variable: rejected_exam_overrides_grade"""
return self.rejected_exam_overrides_grade
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