Commit 45b572a4 by Hasnain Committed by Chris Dodge

PHX-149 added new screen

parent d30a3a91
...@@ -38,7 +38,10 @@ from edx_proctoring.serializers import ( ...@@ -38,7 +38,10 @@ from edx_proctoring.serializers import (
ProctoredExamStudentAttemptSerializer, ProctoredExamStudentAttemptSerializer,
ProctoredExamStudentAllowanceSerializer, ProctoredExamStudentAllowanceSerializer,
) )
from edx_proctoring.utils import humanized_time from edx_proctoring.utils import (
humanized_time,
has_client_app_shutdown
)
from edx_proctoring.backends import get_backend_provider from edx_proctoring.backends import get_backend_provider
from edx_proctoring.runtime import get_runtime_service from edx_proctoring.runtime import get_runtime_service
...@@ -1312,7 +1315,10 @@ def _get_practice_exam_view(exam, context, exam_id, user_id, course_id): ...@@ -1312,7 +1315,10 @@ def _get_practice_exam_view(exam, context, exam_id, user_id, course_id):
elif attempt_status == ProctoredExamStudentAttemptStatus.error: elif attempt_status == ProctoredExamStudentAttemptStatus.error:
student_view_template = 'practice_exam/error.html' student_view_template = 'practice_exam/error.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted: elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
student_view_template = 'practice_exam/submitted.html' if has_client_app_shutdown(attempt):
student_view_template = 'practice_exam/submitted.html'
else:
student_view_template = 'proctored_exam/waiting_for_app_shutdown.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit: elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
student_view_template = 'proctored_exam/ready_to_submit.html' student_view_template = 'proctored_exam/ready_to_submit.html'
...@@ -1414,7 +1420,10 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id): ...@@ -1414,7 +1420,10 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out: elif attempt_status == ProctoredExamStudentAttemptStatus.timed_out:
raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!') raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!')
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted: elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
student_view_template = 'proctored_exam/submitted.html' if has_client_app_shutdown(attempt):
student_view_template = 'proctored_exam/submitted.html'
else:
student_view_template = 'proctored_exam/waiting_for_app_shutdown.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.verified: elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
student_view_template = 'proctored_exam/verified.html' student_view_template = 'proctored_exam/verified.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.rejected: elif attempt_status == ProctoredExamStudentAttemptStatus.rejected:
......
...@@ -41,3 +41,15 @@ REQUIRE_FAILURE_SECOND_REVIEWS = ( ...@@ -41,3 +41,15 @@ REQUIRE_FAILURE_SECOND_REVIEWS = (
'REQUIRE_FAILURE_SECOND_REVIEWS' in settings.PROCTORING_SETTINGS 'REQUIRE_FAILURE_SECOND_REVIEWS' in settings.PROCTORING_SETTINGS
else getattr(settings, 'REQUIRE_FAILURE_SECOND_REVIEWS', True) else getattr(settings, 'REQUIRE_FAILURE_SECOND_REVIEWS', True)
) )
SOFTWARE_SECURE_CLIENT_TIMEOUT = (
settings.PROCTORING_SETTINGS['SOFTWARE_SECURE_CLIENT_TIMEOUT'] if
'SOFTWARE_SECURE_CLIENT_TIMEOUT' in settings.PROCTORING_SETTINGS
else getattr(settings, 'SOFTWARE_SECURE_CLIENT_TIMEOUT', 30)
)
SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD = (
settings.PROCTORING_SETTINGS['SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD'] if
'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD' in settings.PROCTORING_SETTINGS
else getattr(settings, 'SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD', 10)
)
...@@ -109,7 +109,7 @@ class ProctoredExamStudentAttemptStatus(object): ...@@ -109,7 +109,7 @@ class ProctoredExamStudentAttemptStatus(object):
might change over time. might change over time.
""" """
# the student is eligible to decide if he/she wants to persue credit # the student is eligible to decide if he/she wants to pursue credit
eligible = 'eligible' eligible = 'eligible'
# the attempt record has been created, but the exam has not yet # the attempt record has been created, but the exam has not yet
......
{% load i18n %}
<div class="sequence proctored-exam completed" data-exam-id="{{exam_id}}">
<h3>
{% blocktrans %}
You are about to complete your proctored exam
{% endblocktrans %}
</h3>
<p>
{% blocktrans %}
Make sure you return to the proctoring software and select <strong> Quit </strong> to end proctoring
and submit your exam.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
If you have questions about the status of your proctored exam results, contact {{ platform_name }} Support.
{% endblocktrans %}
</p>
</div>
<script type="text/javascript">
var _waiting_for_app_timeout = null;
$(document).ready(function(){
_waiting_for_app_timeout = setInterval(
poll_exam_started,
1000
);
});
function poll_exam_started() {
var url = '{{ exam_started_poll_url }}';
$.ajax(url).success(function(data){
if (data.status === 'submitted') {
// Do we believe the client proctoring app has shut down
// if so, then refresh the page which will expose
// other content
if (data.client_has_shutdown !== undefined && data.client_has_shutdown) {
if (_waiting_for_app_timeout != null) {
clearInterval(_waiting_for_app_timeout)
}
// we've state transitioned, so refresh the page
// to reflect the new state (which will expose the test)
location.reload();
}
}
});
}
</script>
...@@ -105,6 +105,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -105,6 +105,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.chose_proctored_exam_msg = 'Follow these steps to set up and start your proctored exam' self.chose_proctored_exam_msg = 'Follow these steps to set up and start your proctored exam'
self.proctored_exam_optout_msg = 'Take this exam as an open exam instead' self.proctored_exam_optout_msg = 'Take this exam as an open exam instead'
self.proctored_exam_completed_msg = 'Are you sure you want to end your proctored exam' self.proctored_exam_completed_msg = 'Are you sure you want to end your proctored exam'
self.proctored_exam_waiting_for_app_shutdown_msg = 'You are about to complete your proctored exam'
self.proctored_exam_submitted_msg = 'You have submitted this proctored exam for review' self.proctored_exam_submitted_msg = 'You have submitted this proctored exam for review'
self.proctored_exam_verified_msg = 'Your proctoring session was reviewed and passed all requirements' self.proctored_exam_verified_msg = 'Your proctoring session was reviewed and passed all requirements'
self.proctored_exam_rejected_msg = 'Your proctoring session was reviewed and did not pass requirements' self.proctored_exam_rejected_msg = 'Your proctoring session was reviewed and did not pass requirements'
...@@ -243,6 +244,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -243,6 +244,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
return ProctoredExamStudentAttempt.objects.create( return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.practice_exam_id, proctored_exam_id=self.practice_exam_id,
taking_as_proctored=True,
user_id=self.user_id, user_id=self.user_id,
external_id=self.external_id, external_id=self.external_id,
started_at=started_at if started_at else datetime.now(pytz.UTC), started_at=started_at if started_at else datetime.now(pytz.UTC),
...@@ -1067,6 +1069,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1067,6 +1069,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
exam_attempt = self._create_started_exam_attempt() exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save() exam_attempt.save()
rendered_response = get_student_view( rendered_response = get_student_view(
...@@ -1079,7 +1082,21 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1079,7 +1082,21 @@ class ProctoredExamApiTests(LoggedInTestCase):
'default_time_limit_mins': 90 'default_time_limit_mins': 90
} }
) )
self.assertIn(self.proctored_exam_submitted_msg, rendered_response) self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
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_practiceexam(self): def test_get_studentview_submitted_status_practiceexam(self):
""" """
...@@ -1087,6 +1104,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1087,6 +1104,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
""" """
exam_attempt = self._create_started_practice_exam_attempt() exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save() exam_attempt.save()
rendered_response = get_student_view( rendered_response = get_student_view(
...@@ -1099,7 +1117,21 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1099,7 +1117,21 @@ class ProctoredExamApiTests(LoggedInTestCase):
'default_time_limit_mins': 90 'default_time_limit_mins': 90
} }
) )
self.assertIn(self.practice_exam_submitted_msg, rendered_response) self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_submitted_msg, rendered_response)
def test_get_studentview_created_status_practiceexam(self): def test_get_studentview_created_status_practiceexam(self):
""" """
...@@ -2046,7 +2078,25 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -2046,7 +2078,25 @@ class ProctoredExamApiTests(LoggedInTestCase):
} }
) )
self.assertIsNotNone(rendered_response) self.assertIsNotNone(rendered_response)
self.assertIn(self.footer_msg, rendered_response) if status == ProctoredExamStudentAttemptStatus.submitted:
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
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.footer_msg, rendered_response)
else:
self.assertIn(self.footer_msg, rendered_response)
def test_requirement_status_order(self): def test_requirement_status_order(self):
""" """
......
...@@ -5,7 +5,7 @@ All tests for the proctored_exams.py ...@@ -5,7 +5,7 @@ All tests for the proctored_exams.py
import json import json
import pytz import pytz
import ddt import ddt
from mock import Mock from mock import Mock, patch
from freezegun import freeze_time from freezegun import freeze_time
from httmock import HTTMock from httmock import HTTMock
from string import Template # pylint: disable=deprecated-module from string import Template # pylint: disable=deprecated-module
...@@ -20,6 +20,9 @@ from edx_proctoring.models import ( ...@@ -20,6 +20,9 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance, ProctoredExamStudentAllowance,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
) )
from edx_proctoring.exceptions import (
ProctoredExamIllegalStatusTransition,
)
from edx_proctoring.views import require_staff, require_course_or_global_staff from edx_proctoring.views import require_staff, require_course_or_global_staff
from edx_proctoring.api import ( from edx_proctoring.api import (
create_exam, create_exam,
...@@ -408,6 +411,34 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -408,6 +411,34 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True)) set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
def _create_exam_attempt(self):
"""
Create and start the exam attempt, and return the exam attempt object
"""
# Create an exam.
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=90
)
attempt_data = {
'exam_id': proctored_exam.id,
'external_id': proctored_exam.external_id,
'start_clock': True,
}
# Starting exam attempt
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
return ProctoredExamStudentAttempt.objects.get_exam_attempt(proctored_exam.id, self.user.id)
def test_start_exam_create(self): def test_start_exam_create(self):
""" """
Start an exam (create an exam attempt) Start an exam (create an exam attempt)
...@@ -617,6 +648,51 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase): ...@@ -617,6 +648,51 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content) response_data = json.loads(response.content)
self.assertEqual(response_data['status'], ProctoredExamStudentAttemptStatus.error) self.assertEqual(response_data['status'], ProctoredExamStudentAttemptStatus.error)
def test_attempt_status_waiting_for_app_shutdown(self):
"""
Test to confirm that attempt status is submitted when proctored client is shutdown
"""
exam_attempt = self._create_exam_attempt()
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.save()
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[exam_attempt.id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertFalse(response_data['client_has_shutdown'])
# now reset the time to 2 minutes in the future.
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[exam_attempt.id])
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertTrue(response_data['client_has_shutdown'])
def test_attempt_status_for_exception(self):
"""
Test to confirm that exception will not effect the API call
"""
exam_attempt = self._create_exam_attempt()
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.status = ProctoredExamStudentAttemptStatus.verified
exam_attempt.save()
# now reset the time to 2 minutes in the future.
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with patch('edx_proctoring.api.update_attempt_status', Mock(side_effect=ProctoredExamIllegalStatusTransition)):
with freeze_time(reset_time):
response = self.client.get(
reverse('edx_proctoring.proctored_exam.attempt', args=[exam_attempt.id])
)
self.assertEqual(response.status_code, 200)
def test_attempt_status_stickiness(self): def test_attempt_status_stickiness(self):
""" """
Test to confirm that a status timeout error will not alter a completed state Test to confirm that a status timeout error will not alter a completed state
......
...@@ -15,6 +15,7 @@ from edx_proctoring.models import ( ...@@ -15,6 +15,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAttempt, ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptHistory, ProctoredExamStudentAttemptHistory,
) )
from edx_proctoring import constants
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -114,3 +115,16 @@ def locate_attempt_by_attempt_code(attempt_code): ...@@ -114,3 +115,16 @@ def locate_attempt_by_attempt_code(attempt_code):
log.error(err_msg) log.error(err_msg)
return (attempt_obj, is_archived_attempt) return (attempt_obj, is_archived_attempt)
def has_client_app_shutdown(attempt):
"""
Returns True if the client app has shut down, False otherwise
"""
# we never heard from the client, so it must not have started
if not attempt['last_poll_timestamp']:
return True
elapsed_time = (datetime.now(pytz.UTC) - attempt['last_poll_timestamp']).total_seconds()
return elapsed_time > constants.SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD
...@@ -40,16 +40,20 @@ from edx_proctoring.exceptions import ( ...@@ -40,16 +40,20 @@ from edx_proctoring.exceptions import (
ProctoredExamIllegalStatusTransition, ProctoredExamIllegalStatusTransition,
ProctoredExamNotActiveException, ProctoredExamNotActiveException,
) )
from edx_proctoring import constants
from edx_proctoring.runtime import get_runtime_service from edx_proctoring.runtime import get_runtime_service
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt, ProctoredExam from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt, ProctoredExam
from .utils import AuthenticatedAPIView, get_time_remaining_for_attempt, humanized_time from edx_proctoring.utils import (
AuthenticatedAPIView,
get_time_remaining_for_attempt,
humanized_time,
has_client_app_shutdown,
)
ATTEMPTS_PER_PAGE = 25 ATTEMPTS_PER_PAGE = 25
SOFTWARE_SECURE_CLIENT_TIMEOUT = settings.PROCTORING_SETTINGS.get('SOFTWARE_SECURE_CLIENT_TIMEOUT', 30)
LOG = logging.getLogger("edx_proctoring_views") LOG = logging.getLogger("edx_proctoring_views")
...@@ -323,18 +327,30 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -323,18 +327,30 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
# and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT # and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT
# then attempt status should be marked as error. # then attempt status should be marked as error.
last_poll_timestamp = attempt['last_poll_timestamp'] last_poll_timestamp = attempt['last_poll_timestamp']
if last_poll_timestamp is not None \
and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > SOFTWARE_SECURE_CLIENT_TIMEOUT: # if we never heard from the client, then we assume it is shut down
try: attempt['client_has_shutdown'] = last_poll_timestamp is None
update_attempt_status(
attempt['proctored_exam']['id'], if last_poll_timestamp is not None:
attempt['user']['id'], # Let's pass along information if we think the SoftwareSecure has completed
ProctoredExamStudentAttemptStatus.error # a healthy shutdown which is when our attempt is in a 'submitted' status
) if attempt['status'] == ProctoredExamStudentAttemptStatus.submitted:
attempt['status'] = ProctoredExamStudentAttemptStatus.error attempt['client_has_shutdown'] = has_client_app_shutdown(attempt)
except ProctoredExamIllegalStatusTransition: else:
# don't transition a completed state to an error state # otherwise, let's see if the shutdown happened in error
pass # e.g. a crash
time_passed_since_last_poll = (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds()
if time_passed_since_last_poll > constants.SOFTWARE_SECURE_CLIENT_TIMEOUT:
try:
update_attempt_status(
attempt['proctored_exam']['id'],
attempt['user']['id'],
ProctoredExamStudentAttemptStatus.error
)
attempt['status'] = ProctoredExamStudentAttemptStatus.error
except ProctoredExamIllegalStatusTransition:
# don't transition a completed state to an error state
pass
# add in the computed time remaining as a helper to a client app # add in the computed time remaining as a helper to a client app
time_remaining_seconds = get_time_remaining_for_attempt(attempt) time_remaining_seconds = get_time_remaining_for_attempt(attempt)
......
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