Commit 5804243c by chrisndodge

Merge pull request #97 from edx/cdodge/adjust-prerequiste-checks

change proctoring eligibility to be true unless we explicitly failed …
parents bf5de42f d6864ef6
...@@ -498,7 +498,7 @@ def mark_exam_attempt_as_ready(exam_id, user_id): ...@@ -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) 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 Internal helper to handle state transitions of attempt status
""" """
...@@ -531,18 +531,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True): ...@@ -531,18 +531,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
# don't allow state transitions from a completed state to an incomplete state # don't allow state transitions from a completed state to an incomplete state
# if a re-attempt is desired then the current attempt must be deleted # if a re-attempt is desired then the current attempt must be deleted
# #
in_completed_status = exam_attempt_obj.status in [ in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(exam_attempt_obj.status)
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status)
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error,
ProctoredExamStudentAttemptStatus.timed_out
]
to_incompleted_status = to_status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
]
if in_completed_status and to_incompleted_status: if in_completed_status and to_incompleted_status:
err_msg = ( err_msg = (
...@@ -561,17 +551,11 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True): ...@@ -561,17 +551,11 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
exam_attempt_obj.status = to_status exam_attempt_obj.status = to_status
exam_attempt_obj.save() exam_attempt_obj.save()
# trigger workflow, as needed
credit_service = get_runtime_service('credit')
# see if the status transition this changes credit requirement status # see if the status transition this changes credit requirement status
update_credit = to_status in [ if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected, # trigger credit workflow, as needed
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed, credit_service = get_runtime_service('credit')
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
]
if update_credit:
exam = get_exam_by_id(exam_id) exam = get_exam_by_id(exam_id)
if to_status == ProctoredExamStudentAttemptStatus.verified: if to_status == ProctoredExamStudentAttemptStatus.verified:
verification = 'satisfied' verification = 'satisfied'
...@@ -600,6 +584,46 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True): ...@@ -600,6 +584,46 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True):
status=verification status=verification
) )
if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status):
# 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 be likewise
# updated to reflect a declined status
# 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:
# see if there was an attempt on those other exams already
attempt = get_exam_attempt(exam.id, user_id)
if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']):
# don't touch any completed statuses
# we won't revoke those
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: if to_status == ProctoredExamStudentAttemptStatus.submitted:
# also mark the exam attempt completed_at timestamp # also mark the exam attempt completed_at timestamp
# after we submit the attempt # after we submit the attempt
...@@ -771,7 +795,7 @@ def _check_eligibility_of_prerequisites(credit_state): ...@@ -771,7 +795,7 @@ def _check_eligibility_of_prerequisites(credit_state):
# then make sure those has a 'satisfied' status # then make sure those has a 'satisfied' status
for requirement in credit_state['credit_requirement_status']: for requirement in credit_state['credit_requirement_status']:
if requirement['namespace'] == 'reverification': if requirement['namespace'] == 'reverification':
if requirement['status'] != 'satisfied': if requirement['status'] == 'failed':
return False return False
return True return True
......
...@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel): ...@@ -71,11 +71,14 @@ class ProctoredExam(TimeStampedModel):
return proctored_exam return proctored_exam
@classmethod @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 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): class ProctoredExamStudentAttemptStatus(object):
...@@ -131,6 +134,52 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -131,6 +134,52 @@ class ProctoredExamStudentAttemptStatus(object):
# the exam is believed to be in error # the exam is believed to be in error
error = '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
]
@classmethod
def is_incomplete_status(cls, status):
"""
Returns a boolean if the passed in status is in an "incomplete" state.
"""
return status in [
ProctoredExamStudentAttemptStatus.eligible, ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.ready_to_start, ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit
]
@classmethod
def needs_credit_status_update(cls, to_status):
"""
Returns a boolean if the passed in to_status calls for an update to the credit requirement status.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.verified, ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.not_reviewed,
ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.error
]
@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
to other attempts.
"""
return to_status in [
ProctoredExamStudentAttemptStatus.rejected,
ProctoredExamStudentAttemptStatus.declined
]
class ProctoredExamStudentAttemptManager(models.Manager): class ProctoredExamStudentAttemptManager(models.Manager):
""" """
......
...@@ -650,14 +650,14 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -650,14 +650,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIsNone(rendered_response) self.assertIsNone(rendered_response)
@ddt.data( @ddt.data(
('reverification', None, False, True, ProctoredExamStudentAttemptStatus.declined), ('reverification', None, True, True, False),
('reverification', 'failed', False, False, ProctoredExamStudentAttemptStatus.declined), ('reverification', 'failed', False, False, True),
('reverification', 'satisfied', True, True, None), ('reverification', 'satisfied', True, True, False),
('grade', 'failed', True, False, None) ('grade', 'failed', True, False, False)
) )
@ddt.unpack @ddt.unpack
def test_prereq_scarios(self, namespace, req_status, show_proctored, def test_prereq_scenarios(self, namespace, req_status, show_proctored,
pre_create_attempt, mark_as_declined): pre_create_attempt, mark_as_declined):
""" """
This test asserts that proctoring will not be displayed under the following This test asserts that proctoring will not be displayed under the following
conditions: conditions:
...@@ -1083,6 +1083,104 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1083,6 +1083,104 @@ class ProctoredExamApiTests(LoggedInTestCase):
) )
@ddt.data( @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
)
inactive_exam_id = create_exam(
course_id=self.course_id,
content_id="inactive",
exam_name="inactive",
time_limit_mins=self.default_time_limit,
is_practice_exam=False,
is_proctored=True,
is_active=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))
self.assertIsNone(get_exam_attempt(inactive_exam_id, self.user_id))
@ddt.data(
(ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible), (ProctoredExamStudentAttemptStatus.declined, ProctoredExamStudentAttemptStatus.eligible),
(ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created), (ProctoredExamStudentAttemptStatus.timed_out, ProctoredExamStudentAttemptStatus.created),
(ProctoredExamStudentAttemptStatus.submitted, ProctoredExamStudentAttemptStatus.ready_to_start), (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