Commit 862ab20f by chrisndodge

Merge pull request #245 from edx/cdodge/add-second-review-required-status

PHX-226 Define an internal status of 'second_review_required' when a failing …
parents 53ceab51 0776b50f
...@@ -298,7 +298,7 @@ class ProctoredExamSoftwareSecureReviewAdmin(admin.ModelAdmin): ...@@ -298,7 +298,7 @@ class ProctoredExamSoftwareSecureReviewAdmin(admin.ModelAdmin):
review.save() review.save()
# call the review saved and since it's coming from # call the review saved and since it's coming from
# the Django admin will we accept failures # the Django admin will we accept failures
get_backend_provider().on_review_saved(review, allow_status_update_on_fail=True) get_backend_provider().on_review_saved(review, allow_rejects=True)
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super(ProctoredExamSoftwareSecureReviewAdmin, self).get_form(request, obj, **kwargs) form = super(ProctoredExamSoftwareSecureReviewAdmin, self).get_form(request, obj, **kwargs)
......
...@@ -1687,6 +1687,13 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id): ...@@ -1687,6 +1687,13 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
) else 'proctored_exam/submitted.html' ) else 'proctored_exam/submitted.html'
else: else:
student_view_template = 'proctored_exam/waiting_for_app_shutdown.html' student_view_template = 'proctored_exam/waiting_for_app_shutdown.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.second_review_required:
# the student should still see a 'submitted'
# rendering even if the review needs a 2nd review
student_view_template = None if _was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam['due_date']
) else 'proctored_exam/submitted.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.verified: elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
student_view_template = None if _was_review_status_acknowledged( student_view_template = None if _was_review_status_acknowledged(
attempt['is_status_acknowledged'], attempt['is_status_acknowledged'],
......
...@@ -247,9 +247,9 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -247,9 +247,9 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
# update our attempt status, note we have to import api.py here because # update our attempt status, note we have to import api.py here because
# api.py imports software_secure.py, so we'll get an import circular reference # api.py imports software_secure.py, so we'll get an import circular reference
allow_status_update_on_fail = not constants.REQUIRE_FAILURE_SECOND_REVIEWS allow_rejects = not constants.REQUIRE_FAILURE_SECOND_REVIEWS
self.on_review_saved(review, allow_status_update_on_fail=allow_status_update_on_fail) self.on_review_saved(review, allow_rejects=allow_rejects)
# emit an event for 'review-received' # emit an event for 'review-received'
data = { data = {
...@@ -265,7 +265,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -265,7 +265,7 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
exam = serialized_exam_object.data exam = serialized_exam_object.data
emit_event(exam, 'review-received', attempt=attempt, override_data=data) emit_event(exam, 'review-received', attempt=attempt, override_data=data)
def on_review_saved(self, review, allow_status_update_on_fail=False): # pylint: disable=arguments-differ def on_review_saved(self, review, allow_rejects=False): # pylint: disable=arguments-differ
""" """
called when a review has been save - either through API (on_review_callback) or via Django Admin panel called when a review has been save - either through API (on_review_callback) or via Django Admin panel
in order to trigger any workflow associated with proctoring review results in order to trigger any workflow associated with proctoring review results
...@@ -293,21 +293,23 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider): ...@@ -293,21 +293,23 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
status = ( status = (
ProctoredExamStudentAttemptStatus.verified ProctoredExamStudentAttemptStatus.verified
if review.review_status in self.passing_review_status if review.review_status in self.passing_review_status
else ProctoredExamStudentAttemptStatus.rejected else (
# if we are not allowed to store 'rejected' on this
# code path, then put status into 'second_review_required'
ProctoredExamStudentAttemptStatus.rejected if allow_rejects else
ProctoredExamStudentAttemptStatus.second_review_required
)
) )
# are we allowed to update the status if we have a failure status # updating attempt status will trigger workflow
# i.e. do we need a review to come in from Django Admin panel? # (i.e. updating credit eligibility table)
if status == ProctoredExamStudentAttemptStatus.verified or allow_status_update_on_fail: from edx_proctoring.api import update_attempt_status
# updating attempt status will trigger workflow
# (i.e. updating credit eligibility table) update_attempt_status(
from edx_proctoring.api import update_attempt_status attempt_obj.proctored_exam_id,
attempt_obj.user_id,
update_attempt_status( status
attempt_obj.proctored_exam_id, )
attempt_obj.user_id,
status
)
def _save_review_comment(self, review, comment): def _save_review_comment(self, review, comment):
""" """
......
...@@ -778,7 +778,8 @@ class SoftwareSecureTests(TestCase): ...@@ -778,7 +778,8 @@ class SoftwareSecureTests(TestCase):
self.assertEqual(records[0].review_status, 'Clean') self.assertEqual(records[0].review_status, 'Clean')
self.assertEqual(records[1].review_status, 'Suspicious') self.assertEqual(records[1].review_status, 'Suspicious')
def test_failure_submission(self): @ddt.data(False, True)
def test_failure_submission(self, allow_rejects):
""" """
Tests that a submission of a failed test and make sure that we Tests that a submission of a failed test and make sure that we
don't automatically update the status to failure don't automatically update the status to failure
...@@ -824,10 +825,17 @@ class SoftwareSecureTests(TestCase): ...@@ -824,10 +825,17 @@ class SoftwareSecureTests(TestCase):
# now simulate a update via Django Admin table which will actually # now simulate a update via Django Admin table which will actually
# push through the failure into our attempt status (as well as trigger) # push through the failure into our attempt status (as well as trigger)
# other workflow # other workflow
provider.on_review_saved(review, allow_status_update_on_fail=True) provider.on_review_saved(review, allow_rejects=allow_rejects)
attempt = get_exam_attempt_by_id(attempt_id) attempt = get_exam_attempt_by_id(attempt_id)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.rejected)
# if we don't allow rejects to be stored in attempt status
# then we should expect a 'second_review_required' status
expected_status = (
ProctoredExamStudentAttemptStatus.rejected if allow_rejects else
ProctoredExamStudentAttemptStatus.second_review_required
)
self.assertEqual(attempt['status'], expected_status)
def test_update_archived_attempt(self): def test_update_archived_attempt(self):
""" """
...@@ -877,7 +885,7 @@ class SoftwareSecureTests(TestCase): ...@@ -877,7 +885,7 @@ class SoftwareSecureTests(TestCase):
# now simulate a update via Django Admin table which will actually # now simulate a update via Django Admin table which will actually
# push through the failure into our attempt status but # push through the failure into our attempt status but
# as this is an archived attempt, we don't do anything # as this is an archived attempt, we don't do anything
provider.on_review_saved(review, allow_status_update_on_fail=True) provider.on_review_saved(review, allow_rejects=True)
# look at the attempt again, since it moved into Archived state # look at the attempt again, since it moved into Archived state
# then it should still remain unchanged # then it should still remain unchanged
...@@ -897,7 +905,7 @@ class SoftwareSecureTests(TestCase): ...@@ -897,7 +905,7 @@ class SoftwareSecureTests(TestCase):
review = ProctoredExamSoftwareSecureReview() review = ProctoredExamSoftwareSecureReview()
review.attempt_code = 'foo' review.attempt_code = 'foo'
self.assertIsNone(provider.on_review_saved(review, allow_status_update_on_fail=True)) self.assertIsNone(provider.on_review_saved(review, allow_rejects=True))
def test_split_fullname(self): def test_split_fullname(self):
""" """
......
...@@ -150,6 +150,9 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -150,6 +150,9 @@ class ProctoredExamStudentAttemptStatus(object):
# the student has submitted the exam for proctoring review # the student has submitted the exam for proctoring review
submitted = 'submitted' submitted = 'submitted'
# the student has submitted the exam for proctoring review
second_review_required = 'second_review_required'
# the exam has been verified and approved # the exam has been verified and approved
verified = 'verified' verified = 'verified'
...@@ -176,8 +179,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -176,8 +179,8 @@ class ProctoredExamStudentAttemptStatus(object):
that it cannot go backwards in state that it cannot go backwards in state
""" """
return status in [ return status in [
cls.declined, cls.timed_out, cls.submitted, cls.verified, cls.rejected, cls.declined, cls.timed_out, cls.submitted, cls.second_review_required,
cls.not_reviewed, cls.error cls.verified, cls.rejected, cls.not_reviewed, cls.error
] ]
@classmethod @classmethod
...@@ -196,8 +199,8 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -196,8 +199,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in to_status calls for an update to the credit requirement status. Returns a boolean if the passed in to_status calls for an update to the credit requirement status.
""" """
return to_status in [ return to_status in [
cls.verified, cls.rejected, cls.declined, cls.not_reviewed, cls.submitted, cls.verified, cls.rejected, cls.declined, cls.not_reviewed,
cls.error cls.submitted, cls.error
] ]
@classmethod @classmethod
......
...@@ -14,6 +14,7 @@ var edx = edx || {}; ...@@ -14,6 +14,7 @@ var edx = edx || {};
ready_to_submit: gettext('Ready to submit'), ready_to_submit: gettext('Ready to submit'),
declined: gettext('Declined'), declined: gettext('Declined'),
timed_out: gettext('Timed out'), timed_out: gettext('Timed out'),
second_review_required: gettext('Second Review Required'),
submitted: gettext('Submitted'), submitted: gettext('Submitted'),
verified: gettext('Verified'), verified: gettext('Verified'),
rejected: gettext('Rejected'), rejected: gettext('Rejected'),
......
...@@ -824,6 +824,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -824,6 +824,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
(ProctoredExamStudentAttemptStatus.submitted, 'submitted'), (ProctoredExamStudentAttemptStatus.submitted, 'submitted'),
(ProctoredExamStudentAttemptStatus.declined, 'declined'), (ProctoredExamStudentAttemptStatus.declined, 'declined'),
(ProctoredExamStudentAttemptStatus.error, 'failed'), (ProctoredExamStudentAttemptStatus.error, 'failed'),
(ProctoredExamStudentAttemptStatus.second_review_required, None),
) )
@ddt.unpack @ddt.unpack
def test_remove_exam_attempt_with_status(self, to_status, requirement_status): def test_remove_exam_attempt_with_status(self, to_status, requirement_status):
...@@ -843,19 +844,24 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -843,19 +844,24 @@ class ProctoredExamApiTests(LoggedInTestCase):
credit_service = get_runtime_service('credit') credit_service = get_runtime_service('credit')
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id) credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1) if requirement_status:
self.assertEqual( self.assertEqual(len(credit_status['credit_requirement_status']), 1)
credit_status['credit_requirement_status'][0]['status'], self.assertEqual(
requirement_status credit_status['credit_requirement_status'][0]['status'],
) requirement_status
)
# now remove exam attempt which calls the credit service method 'remove_credit_requirement_status' # now remove exam attempt which calls the credit service method 'remove_credit_requirement_status'
remove_exam_attempt(exam_attempt.proctored_exam_id) remove_exam_attempt(exam_attempt.proctored_exam_id)
# make sure the credit requirement status is no longer there # make sure the credit requirement status is no longer there
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id) credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 0) self.assertEqual(len(credit_status['credit_requirement_status']), 0)
else:
# There is not an expected changed to the credit requirement table
# given the attempt status
self.assertEqual(len(credit_status['credit_requirement_status']), 0)
def test_stop_a_non_started_exam(self): def test_stop_a_non_started_exam(self):
""" """
...@@ -1548,6 +1554,25 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1548,6 +1554,25 @@ class ProctoredExamApiTests(LoggedInTestCase):
) )
self.assertIn(self.proctored_exam_submitted_msg, rendered_response) self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
# now make sure if this status transitions to 'second_review_required'
# the student will still see a 'submitted' message
update_attempt_status(
exam_attempt.proctored_exam_id,
exam_attempt.user_id,
ProctoredExamStudentAttemptStatus.second_review_required
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
def test_get_studentview_submitted_status_with_duedate(self): def test_get_studentview_submitted_status_with_duedate(self):
""" """
Test for get_student_view proctored exam which has been submitted Test for get_student_view proctored exam which has been submitted
...@@ -2471,6 +2496,23 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -2471,6 +2496,23 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertIn(ProctoredExamStudentAttemptStatus.get_status_alias(status), mail.outbox[0].body) self.assertIn(ProctoredExamStudentAttemptStatus.get_status_alias(status), mail.outbox[0].body)
self.assertIn(credit_state['course_name'], mail.outbox[0].body) self.assertIn(credit_state['course_name'], mail.outbox[0].body)
@ddt.data(
ProctoredExamStudentAttemptStatus.second_review_required,
ProctoredExamStudentAttemptStatus.error
)
def test_email_not_sent(self, status):
"""
Assert than email is not sent on the following statuses of proctoring attempt
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
def test_send_email_unicode(self): def test_send_email_unicode(self):
""" """
Assert that email can be sent with a unicode course name. Assert that email can be sent with a unicode course name.
......
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