Commit 6e7b4dba by chrisndodge

Merge pull request #29 from edx/cdodge/punchlist2

Add status to attempts
parents d7d6afe8 40f36c45
......@@ -24,6 +24,7 @@ from edx_proctoring.models import (
ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus,
)
from edx_proctoring.serializers import (
ProctoredExamSerializer,
......@@ -182,6 +183,17 @@ def get_exam_attempt_by_id(attempt_id):
return serialized_attempt_obj.data if exam_attempt_obj else None
def get_exam_attempt_by_code(attempt_code):
"""
Signals the beginning of an exam attempt when we only have
an attempt code
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)
serialized_attempt_obj = ProctoredExamStudentAttemptSerializer(exam_attempt_obj)
return serialized_attempt_obj.data if exam_attempt_obj else None
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
"""
Creates an exam attempt for user_id against exam_id. There should only be
......@@ -211,7 +223,7 @@ def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
allowance_extra_mins = int(allowance.value)
allowed_time_limit_mins += allowance_extra_mins
attempt_code = unicode(uuid.uuid4())
attempt_code = unicode(uuid.uuid4()).upper()
external_id = None
if taking_as_proctored:
......@@ -263,7 +275,7 @@ def start_exam_attempt(exam_id, user_id):
raise StudentExamAttemptDoesNotExistsException(err_msg)
_start_exam_attempt(existing_attempt)
return _start_exam_attempt(existing_attempt)
def start_exam_attempt_by_code(attempt_code):
......@@ -282,7 +294,7 @@ def start_exam_attempt_by_code(attempt_code):
raise StudentExamAttemptDoesNotExistsException(err_msg)
_start_exam_attempt(existing_attempt)
return _start_exam_attempt(existing_attempt)
def _start_exam_attempt(existing_attempt):
......@@ -301,6 +313,8 @@ def _start_exam_attempt(existing_attempt):
existing_attempt.start_exam_attempt()
return existing_attempt.id
def stop_exam_attempt(exam_id, user_id):
"""
......@@ -308,9 +322,37 @@ def stop_exam_attempt(exam_id, user_id):
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that is not in progress.')
raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that does not exist.')
else:
exam_attempt_obj.completed_at = datetime.now(pytz.UTC)
exam_attempt_obj.status = ProctoredExamStudentAttemptStatus.completed
exam_attempt_obj.save()
return exam_attempt_obj.id
def mark_exam_attempt_timeout(exam_id, user_id):
"""
Marks the exam attempt as timed_out
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException('Error. Trying to time out an exam that does not exist.')
else:
exam_attempt_obj.status = ProctoredExamStudentAttemptStatus.timed_out
exam_attempt_obj.save()
return exam_attempt_obj.id
def mark_exam_attempt_as_ready(exam_id, user_id):
"""
Marks the exam attemp as ready to start
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException('Error. Trying to time out an exam that does not exist.')
else:
exam_attempt_obj.status = ProctoredExamStudentAttemptStatus.ready_to_start
exam_attempt_obj.save()
return exam_attempt_obj.id
......@@ -419,7 +461,7 @@ def get_active_exams_for_user(user_id, course_id=None):
return result
def get_student_view(user_id, course_id, content_id, context):
def get_student_view(user_id, course_id, content_id, context): # pylint: disable=too-many-branches
"""
Helper method that will return the view HTML related to the exam control
flow (i.e. entering, expired, completed, etc.) If there is no specific
......@@ -463,6 +505,10 @@ def get_student_view(user_id, course_id, content_id, context):
expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
has_time_expired = now_utc > expires_at
# make sure the attempt has been marked as timed_out, if need be
if has_time_expired and attempt['status'] != ProctoredExamStudentAttemptStatus.timed_out:
mark_exam_attempt_timeout(exam_id, user_id)
if not has_started_exam:
# determine whether to show a timed exam only entrance screen
# or a screen regarding proctoring
......@@ -483,7 +529,6 @@ def get_student_view(user_id, course_id, content_id, context):
student_view_template = 'proctoring/seq_timed_exam_completed.html'
elif has_time_expired:
student_view_template = 'proctoring/seq_timed_exam_expired.html'
if student_view_template:
template = loader.get_template(student_view_template)
django_context = Context(context)
......
......@@ -5,10 +5,9 @@ Various callback paths
from django.template import Context, loader
from django.http import HttpResponse
from edx_proctoring.exceptions import StudentExamAttemptDoesNotExistsException
from edx_proctoring.api import (
start_exam_attempt_by_code,
get_exam_attempt_by_code,
mark_exam_attempt_as_ready,
)
......@@ -23,15 +22,15 @@ def start_exam_callback(request, attempt_code): # pylint: disable=unused-argume
as a query string parameter
"""
# start the exam!
try:
start_exam_attempt_by_code(attempt_code)
except StudentExamAttemptDoesNotExistsException:
attempt = get_exam_attempt_by_code(attempt_code)
if not attempt:
return HttpResponse(
content='That exam code is not valid',
status=404
)
mark_exam_attempt_as_ready(attempt['proctored_exam']['id'], attempt['user']['id'])
template = loader.get_template('proctoring/proctoring_launch_callback.html')
return HttpResponse(template.render(Context({})))
......@@ -141,6 +141,49 @@ class ProctoredExamStudentAttemptManager(models.Manager):
return self.filter(filtered_query)
class ProctoredExamStudentAttemptStatus(object):
"""
A class to enumerate the various status that an attempt can have
IMPORTANT: Since these values are stored in a database, they are system
constants and should not be language translated, since translations
might change over time.
"""
# the student is eligible to decide if he/she wants to persue credit
eligible = 'eligible'
# the attempt record has been created, but the exam has not yet
# been started
created = 'created'
# the attempt is ready to start but requires
# user to acknowledge that he/she wants to start the exam
ready_to_start = 'ready_to_start'
# the student has started the exam and is
# in the process of completing the exam
started = 'started'
# the exam has timed out
timed_out = 'timed_out'
# the student has completed the exam
completed = 'completed'
# the student has submitted the exam for proctoring review
submitted = 'submitted'
# the exam has been verified and approved
verified = 'verified'
# the exam has been rejected
rejected = 'rejected'
# the exam is believed to be in error
error = 'error'
class ProctoredExamStudentAttempt(TimeStampedModel):
"""
Information about the Student Attempt on a
......@@ -205,7 +248,8 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
attempt_code=attempt_code,
taking_as_proctored=taking_as_proctored,
is_sample_attempt=is_sample_attempt,
external_id=external_id
external_id=external_id,
status=ProctoredExamStudentAttemptStatus.created,
)
def start_exam_attempt(self):
......@@ -213,6 +257,7 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
sets the model's state when an exam attempt has started
"""
self.started_at = datetime.now(pytz.UTC)
self.status = ProctoredExamStudentAttemptStatus.started
self.save()
def delete_exam_attempt(self):
......
......@@ -11,6 +11,7 @@
time_remaining_seconds: 0,
low_threshold_sec: 0,
critically_low_threshold_sec: 0,
course_id: null,
lastFetched: new Date()
},
getRemainingSeconds: function () {
......
var edx = edx || {};
(function (Backbone, $, _) {
(function (Backbone, $, _, gettext) {
'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {};
......@@ -11,7 +11,7 @@ var edx = edx || {};
return new Date(date).toString('MMM dd, yyyy h:mmtt');
}
else {
return 'N/A';
return '---';
}
}
......@@ -130,6 +130,11 @@ var edx = edx || {};
},
onRemoveAttempt: function (event) {
event.preventDefault();
// confirm the user's intent
if (!confirm(gettext('Are you sure you wish to remove this student\'s exam attempt?'))) {
return;
}
var $target = $(event.currentTarget);
var attemptId = $target.data("attemptId");
......@@ -148,4 +153,4 @@ var edx = edx || {};
}
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAttemptView = edx.instructor_dashboard.proctoring.ProctoredExamAttemptView;
}).call(this, Backbone, $, _);
}).call(this, Backbone, $, _, gettext);
var edx = edx || {};
(function (Backbone, $, _) {
(function (Backbone, $, _, gettext) {
'use strict';
edx.coursware = edx.coursware || {};
......@@ -37,9 +37,12 @@ var edx = edx || {};
},
modelChanged: function () {
// if we are a proctored exam, then we need to alert user that he/she
// should not leave the exam
// should not be navigating around the courseware
var taking_as_proctored = this.model.get('taking_as_proctored');
var time_left = this.model.get('time_remaining_seconds') > 0;
var in_courseware = document.location.href.indexOf('/courses/' + this.model.get('course_id') + '/courseware/') > -1;
if (this.model.get('taking_as_proctored') && this.model.get('time_remaining_seconds') > 0) {
if ( taking_as_proctored && time_left && in_courseware){
$(window).bind('beforeunload', this.unloadMessage);
} else {
// remove callback on unload event
......@@ -61,11 +64,11 @@ var edx = edx || {};
return this;
},
unloadMessage: function () {
return "As you are currently taking a proctored exam,\n" +
return gettext("As you are currently taking a proctored exam,\n" +
"you should not be navigation away from the exam.\n" +
"This may be considered as a violation of the \n" +
"proctored exam and you may be disqualified for \n" +
"credit eligibility in this course.\n";
"credit eligibility in this course.\n");
},
updateRemainingTime: function (self) {
self.$el.find('div.exam-timer').removeClass("low-time warning critical");
......@@ -80,4 +83,4 @@ var edx = edx || {};
}
});
this.edx.coursware.proctored_exam.ProctoredExamView = edx.coursware.proctored_exam.ProctoredExamView;
}).call(this, Backbone, $, _);
}).call(this, Backbone, $, _, gettext);
......@@ -2,7 +2,7 @@
<section class="content">
<div class="top-header">
<div class='search-attempts'>
<input type="text" id="search_attempt_id" placeholder="e.g johndoe or john.do@gmail.com"
<input type="text" id="search_attempt_id" placeholder="e.g johndoe or john.doe@gmail.com"
<% if (inSearchMode) { %>
value="<%= searchText %>"
<%} %>
......@@ -118,4 +118,4 @@
</tbody>
</table>
</section>
</div>
\ No newline at end of file
</div>
......@@ -42,6 +42,8 @@
<script type="text/javascript">
var _waiting_for_proctored_interval = null;
$(document).ready(function(){
var hasFlash = false;
......@@ -85,7 +87,7 @@
);
}
setInterval(
_waiting_for_proctored_interval = setInterval(
poll_exam_started,
5000
);
......@@ -94,14 +96,28 @@
function poll_exam_started() {
var url = $('.instructions').data('exam-started-poll-url')
$.ajax(url).success(function(data){
if (data.started_at !== null) {
// Let the student know exam has started and clock is running.
// this may or may not bring the browser window back to the
if (data.status === 'ready_to_start') {
if (_waiting_for_proctored_interval != null) {
clearInterval(_waiting_for_proctored_interval)
}
// Let the student know exam is ready to start.
// This alert may or may not bring the browser window back to the
// foreground (depending on browser as well as user settings)
alert('{% trans "Your proctored exam has started, please click OK to enter into your exam." %}')
// Reloading page will reflect the new state of the attempt
location.reload()
// after the user acknowledges the alert then we can start
// the exam and timer
$.ajax({
url: url,
type: 'PUT',
data: {
action: 'start'
},
success: function() {
// Reloading page will reflect the new state of the attempt
location.reload()
}
});
}
});
}
......
......@@ -27,6 +27,8 @@ from edx_proctoring.api import (
get_all_exam_attempts,
get_filtered_exam_attempts,
is_feature_enabled,
mark_exam_attempt_timeout,
mark_exam_attempt_as_ready,
)
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
......@@ -373,6 +375,36 @@ class ProctoredExamApiTests(LoggedInTestCase):
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
stop_exam_attempt(self.proctored_exam_id, self.user_id)
def test_mark_exam_attempt_timeout(self):
"""
Tests the mark exam as timed out
"""
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
mark_exam_attempt_timeout(self.proctored_exam_id, self.user_id)
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = mark_exam_attempt_timeout(
proctored_exam_student_attempt.proctored_exam, self.user_id
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
def test_mark_exam_attempt_as_ready(self):
"""
Tests the mark exam as timed out
"""
with self.assertRaises(StudentExamAttemptDoesNotExistsException):
mark_exam_attempt_as_ready(self.proctored_exam_id, self.user_id)
proctored_exam_student_attempt = self._create_unstarted_exam_attempt()
self.assertIsNone(proctored_exam_student_attempt.completed_at)
proctored_exam_attempt_id = mark_exam_attempt_as_ready(
proctored_exam_student_attempt.proctored_exam, self.user_id
)
self.assertEqual(proctored_exam_student_attempt.id, proctored_exam_attempt_id)
def test_get_active_exams_for_user(self):
"""
Test to get the all the active
......
......@@ -397,6 +397,53 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
def test_start_exam(self):
"""
Start an exam (create an exam attempt)
"""
# 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': False,
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt.collection'),
attempt_data
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertGreater(response_data['exam_attempt_id'], 0)
old_attempt_id = response_data['exam_attempt_id']
# make sure the exam has not started
attempt = get_exam_attempt_by_id(old_attempt_id)
self.assertIsNone(attempt['started_at'])
response = self.client.put(
reverse('edx_proctoring.proctored_exam.attempt', args=[old_attempt_id]),
{
'action': 'start',
}
)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response_data['exam_attempt_id'], old_attempt_id)
# make sure the exam started
attempt = get_exam_attempt_by_id(old_attempt_id)
self.assertIsNotNone(attempt['started_at'])
def test_attempt_readback(self):
"""
Confirms that an attempt can be read
......@@ -581,7 +628,9 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response = self.client.put(
reverse('edx_proctoring.proctored_exam.attempt', args=[old_attempt_id]),
{}
{
'action': 'stop',
}
)
self.assertEqual(response.status_code, 200)
......@@ -878,7 +927,7 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
self.assertEqual(response.status_code, 200)
attempt = get_exam_attempt_by_id(attempt_id)
self.assertIsNotNone(attempt['started_at'])
self.assertEqual(attempt['status'], 'ready_to_start')
def test_bad_exam_code_callback(self):
"""
......
......@@ -302,10 +302,18 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
)
raise ProctoredExamPermissionDenied(err_msg)
exam_attempt_id = stop_exam_attempt(
exam_id=attempt['proctored_exam']['id'],
user_id=request.user.id
)
action = request.DATA.get('action')
if action == 'stop':
exam_attempt_id = stop_exam_attempt(
exam_id=attempt['proctored_exam']['id'],
user_id=request.user.id
)
elif action == 'start':
exam_attempt_id = start_exam_attempt(
exam_id=attempt['proctored_exam']['id'],
user_id=request.user.id
)
return Response({"exam_attempt_id": exam_attempt_id})
except ProctoredBaseException, ex:
......@@ -473,6 +481,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
'time_remaining_seconds': time_remaining_seconds,
'low_threshold_sec': low_threshold,
'critically_low_threshold_sec': critically_low_threshold,
'course_id': exam['course_id'],
}
else:
response_dict = {
......
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