Skip to content

Commit

Permalink
Merge pull request #365 from edx/EDUCATOR-927
Browse files Browse the repository at this point in the history
EDUCATOR-927 Override grade to zero when exam attempt is rejected
  • Loading branch information
thallada authored Aug 9, 2017
2 parents 952ad87 + 7c40135 commit 52e861b
Show file tree
Hide file tree
Showing 8 changed files with 306 additions and 4 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ Afzal Wali <[email protected]>
Mushtaq Ali <[email protected]>
Christina Roberts <[email protected]>
Dennis Jen <[email protected]>
Tyler Hallada <[email protected]>
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

from __future__ import absolute_import

__version__ = '0.19.0'
__version__ = '1.0.0'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
60 changes: 58 additions & 2 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@

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):
Expand Down Expand Up @@ -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)

#
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion edx_proctoring/backends/tests/test_software_secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
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


Expand Down Expand Up @@ -104,13 +104,15 @@ def setUp(self):

set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService())
set_runtime_service('grades', MockGradesService())

def tearDown(self):
"""
When tests are done
"""
super(SoftwareSecureTests, self).tearDown()
set_runtime_service('credit', None)
set_runtime_service('grades', None)

def test_provider_instance(self):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt
from edx_proctoring.tests.test_services import (
MockCreditService,
MockGradesService
)
from edx_proctoring.runtime import set_runtime_service

Expand All @@ -31,6 +32,7 @@ def setUp(self):
"""
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',
Expand Down
9 changes: 9 additions & 0 deletions edx_proctoring/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ def needs_credit_status_update(cls, to_status):
cls.verified, cls.rejected, cls.declined, cls.submitted, cls.error
]

@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):
"""
Expand Down
166 changes: 166 additions & 0 deletions edx_proctoring/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
MockCreditService,
MockCreditServiceNone,
MockCreditServiceWithCourseEndDate,
MockGradesService
)
from .utils import ProctoredExamTestCase

Expand Down Expand Up @@ -862,6 +863,7 @@ def test_cascading(self, to_status, create_attempt, second_attempt_status, expec
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,
Expand Down Expand Up @@ -930,6 +932,170 @@ def test_cascading(self, to_status, create_attempt, second_attempt_status, expec
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),
Expand Down
Loading

0 comments on commit 52e861b

Please sign in to comment.