Commit 4f839e65 by Chris Dodge

also mark other proctored exams as declined if we fail one exam

parent c609b163
......@@ -498,7 +498,7 @@ def mark_exam_attempt_as_ready(exam_id, user_id):
return update_attempt_status(exam_id, user_id, ProctoredExamStudentAttemptStatus.ready_to_start)
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, cascade_effects=True):
"""
Internal helper to handle state transitions of attempt status
"""
......@@ -561,9 +561,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
exam_attempt_obj.status = to_status
exam_attempt_obj.save()
# trigger workflow, as needed
credit_service = get_runtime_service('credit')
# see if the status transition this changes credit requirement status
update_credit = to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
......@@ -572,6 +569,9 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
]
if update_credit:
# trigger credit workflow, as needed
credit_service = get_runtime_service('credit')
exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified:
verification = 'satisfied'
......@@ -600,6 +600,51 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
status=verification
)
if cascade_effects:
# some state transitions (namely to a rejected or declined status)
# will mark other exams as declined because once we fail or decline
# one exam all other (un-completed) proctored exams will we likewise
# updated to reflect a declined status
cascade_failure = to_status in [
ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined
]
if cascade_failure:
# get all other unattempted exams and mark also as declined
_exams = ProctoredExam.get_all_exams_for_course(
exam_attempt_obj.proctored_exam.course_id,
active_only=True
)
# we just want other exams which are proctored and are not practice
exams = [
exam
for exam in _exams
if (
exam.content_id != exam_attempt_obj.proctored_exam.content_id and
exam.is_proctored and not exam.is_practice_exam
)
]
for exam in exams:
if exam.content_id != exam_attempt_obj.proctored_exam.content_id:
# make sure there was no attempt
attempt = get_exam_attempt(exam.id, user_id)
if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
# don't touch any completed statuses
continue
if not attempt:
create_exam_attempt(exam.id, user_id, taking_as_proctored=False)
# update any new or existing status to declined
update_attempt_status(
exam.id,
user_id,
ProctoredExamStudentAttemptStatus.declined,
cascade_effects=False
)
if to_status == ProctoredExamStudentAttemptStatus.submitted:
# also mark the exam attempt completed_at timestamp
# after we submit the attempt
......
......@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel):
return proctored_exam
@classmethod
def get_all_exams_for_course(cls, course_id):
def get_all_exams_for_course(cls, course_id, active_only=False):
"""
Returns all exams for a give course
"""
return cls.objects.filter(course_id=course_id)
result = cls.objects.filter(course_id=course_id)
if active_only:
result = result.filter(is_active=True)
return result
class ProctoredExamStudentAttemptStatus(object):
......@@ -131,6 +134,19 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error
error = 'error'
@classmethod
def is_completed_status(cls, status):
"""
Returns a boolean if the passed in status is in a "completed" state, meaning
that it cannot go backwards in state
"""
return status in [
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.error
]
class ProctoredExamStudentAttemptManager(models.Manager):
"""
......
......@@ -1083,6 +1083,93 @@ class ProctoredExamApiTests(LoggedInTestCase):
)
@ddt.data(
(
ProctoredExamStudentAttemptStatus.declined,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.verified
),
(
ProctoredExamStudentAttemptStatus.declined,
True,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.submitted
),
)
@ddt.unpack
def test_cascading(self, to_status, create_attempt, second_attempt_status, expected_second_status):
"""
Make sure that when we decline/reject one attempt all other exams in the course
are auto marked as declined
"""
# create other exams in course
second_exam_id = create_exam(
course_id=self.course_id,
content_id="2nd exam",
exam_name="2nd exam",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=True
)
practice_exam_id = create_exam(
course_id=self.course_id,
content_id="practice",
exam_name="practice",
time_limit_mins=self.default_time_limit,
is_practice_exam=True,
is_proctored=True
)
timed_exam_id = create_exam(
course_id=self.course_id,
content_id="timed",
exam_name="timed",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=False
)
if create_attempt:
create_exam_attempt(second_exam_id, self.user_id, taking_as_proctored=False)
if second_attempt_status:
update_attempt_status(second_exam_id, self.user_id, second_attempt_status)
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
to_status
)
# make sure we reamain in the right status
read_back = get_exam_attempt(exam_attempt.proctored_exam_id, self.user.id)
self.assertEqual(read_back['status'], to_status)
# make sure an attempt was made for second_exam
second_exam_attempt = get_exam_attempt(second_exam_id, self.user_id)
self.assertIsNotNone(second_exam_attempt)
self.assertEqual(second_exam_attempt['status'], expected_second_status)
# no auto-generated attempts for practice and timed exams
self.assertIsNone(get_exam_attempt(practice_exam_id, self.user_id))
self.assertIsNone(get_exam_attempt(timed_exam_id, self.user_id))
@ddt.data(
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start),
......
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