Commit 17ef189f by chrisndodge

Merge pull request #13 from edx/cdodge/add-proctoring-start-template

Cdodge/add proctoring start template
parents f3f966c7 137e59f5
......@@ -12,13 +12,22 @@ from django.template import Context, loader
from django.core.urlresolvers import reverse
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists, ProctoredExamNotFoundException, StudentExamAttemptAlreadyExistsException,
StudentExamAttemptDoesNotExistsException)
ProctoredExamAlreadyExists,
ProctoredExamNotFoundException,
StudentExamAttemptAlreadyExistsException,
StudentExamAttemptDoesNotExistsException,
StudentExamAttemptedAlreadyStarted,
)
from edx_proctoring.models import (
ProctoredExam, ProctoredExamStudentAllowance, ProctoredExamStudentAttempt
ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
)
from edx_proctoring.serializers import (
ProctoredExamSerializer,
ProctoredExamStudentAttemptSerializer,
ProctoredExamStudentAllowanceSerializer,
)
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer, \
ProctoredExamStudentAllowanceSerializer
from edx_proctoring.utils import humanized_time
......@@ -139,31 +148,70 @@ def get_exam_attempt(exam_id, user_id):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_student_exam_attempt(exam_id, user_id)
exam_attempt_obj = ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id)
return exam_attempt_obj.__dict__ if exam_attempt_obj else None
def start_exam_attempt(exam_id, user_id, external_id):
def create_exam_attempt(exam_id, user_id, external_id):
"""
Creates an exam attempt for user_id against exam_id. There should only be
one exam_attempt per user per exam. Multiple attempts by user will be archived
in a separate table
"""
if ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id):
err_msg = (
'Cannot create new exam attempt for exam_id = {exam_id} and '
'user_id = {user_id} because it already exists!'
).format(exam_id=exam_id, user_id=user_id)
raise StudentExamAttemptAlreadyExistsException(err_msg)
attempt = ProctoredExamStudentAttempt.create_exam_attempt(
exam_id,
user_id,
'', # student name is TBD
external_id
)
return attempt.id
def start_exam_attempt(exam_id, user_id):
"""
Signals the beginning of an exam attempt for a given
exam_id. If one already exists, then an exception should be thrown.
Returns: exam_attempt_id (PK)
"""
exam_attempt_obj = ProctoredExamStudentAttempt.start_exam_attempt(exam_id, user_id, external_id)
if exam_attempt_obj is None:
raise StudentExamAttemptAlreadyExistsException
else:
return exam_attempt_obj.id
existing_attempt = ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id)
if not existing_attempt:
err_msg = (
'Cannot start exam attempt for exam_id = {exam_id} '
'and user_id = {user_id} because it does not exist!'
).format(exam_id=exam_id, user_id=user_id)
raise StudentExamAttemptDoesNotExistsException(err_msg)
if existing_attempt.started_at:
# cannot restart an attempt
err_msg = (
'Cannot start exam attempt for exam_id = {exam_id} '
'and user_id = {user_id} because it has already started!'
).format(exam_id=exam_id, user_id=user_id)
raise StudentExamAttemptedAlreadyStarted(err_msg)
existing_attempt.start_exam_attempt()
def stop_exam_attempt(exam_id, user_id):
"""
Marks the exam attempt as completed (sets the completed_at field and updates the record)
"""
exam_attempt_obj = ProctoredExamStudentAttempt.get_student_exam_attempt(exam_id, user_id)
exam_attempt_obj = ProctoredExamStudentAttempt.get_exam_attempt(exam_id, user_id)
if exam_attempt_obj is None:
raise StudentExamAttemptDoesNotExistsException
raise StudentExamAttemptDoesNotExistsException('Error. Trying to stop an exam that is not in progress.')
else:
exam_attempt_obj.completed_at = datetime.now(pytz.UTC)
exam_attempt_obj.save()
......@@ -191,7 +239,7 @@ def get_active_exams_for_user(user_id, course_id=None):
"""
result = []
student_active_exams = ProctoredExamStudentAttempt.get_active_student_exams(user_id, course_id)
student_active_exams = ProctoredExamStudentAttempt.get_active_student_attempts(user_id, course_id)
for active_exam in student_active_exams:
# convert the django orm objects
# into the serialized form.
......@@ -241,8 +289,8 @@ def get_student_view(user_id, course_id, content_id, context):
)
attempt = get_exam_attempt(exam_id, user_id)
has_started_exam = attempt is not None
if attempt:
has_started_exam = attempt and attempt.get('started_at')
if has_started_exam:
now_utc = datetime.now(pytz.UTC)
expires_at = attempt['started_at'] + timedelta(minutes=context['default_time_limit_mins'])
has_time_expired = now_utc > expires_at
......@@ -251,7 +299,10 @@ def get_student_view(user_id, course_id, content_id, context):
# determine whether to show a timed exam only entrance screen
# or a screen regarding proctoring
if is_proctored:
student_view_template = 'proctoring/seq_proctored_exam_entrance.html'
if not attempt:
student_view_template = 'proctoring/seq_proctored_exam_entrance.html'
else:
student_view_template = 'proctoring/seq_proctored_exam_instructions.html'
else:
student_view_template = 'proctoring/seq_timed_exam_entrance.html'
elif has_finished_exam:
......
......@@ -3,25 +3,37 @@ Specialized exceptions for the Notification subsystem
"""
class ProctoredExamAlreadyExists(Exception):
class ProctoredBaseException(Exception):
"""
A common base class for all exceptions
"""
class ProctoredExamAlreadyExists(ProctoredBaseException):
"""
Raised when trying to create an Exam that already exists.
"""
class ProctoredExamNotFoundException(Exception):
class ProctoredExamNotFoundException(ProctoredBaseException):
"""
Raised when a look up fails.
"""
class StudentExamAttemptAlreadyExistsException(Exception):
class StudentExamAttemptAlreadyExistsException(ProctoredBaseException):
"""
Raised when trying to start an exam when an Exam Attempt already exists.
"""
class StudentExamAttemptDoesNotExistsException(Exception):
class StudentExamAttemptDoesNotExistsException(ProctoredBaseException):
"""
Raised when trying to stop an exam attempt where the Exam Attempt doesn't exist.
"""
class StudentExamAttemptedAlreadyStarted(ProctoredBaseException):
"""
Raised when the same exam attempt is being started twice
"""
......@@ -89,6 +89,8 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
# in case there is an option to opt-out
taking_as_proctored = models.BooleanField()
student_name = models.CharField(max_length=255)
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamstudentattempt'
......@@ -100,23 +102,29 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
return self.started_at and not self.completed_at
@classmethod
def start_exam_attempt(cls, exam_id, user_id, external_id):
def create_exam_attempt(cls, exam_id, user_id, student_name, external_id):
"""
Create a new exam attempt entry for a given exam_id and
user_id.
"""
return cls.objects.create(
proctored_exam_id=exam_id,
user_id=user_id,
student_name=student_name,
external_id=external_id
)
def start_exam_attempt(self):
"""
create and return an exam attempt entry for a given
exam_id. If one already exists, then returns None.
sets the model's state when an exam attempt has started
"""
if cls.get_student_exam_attempt(exam_id, user_id) is None:
return cls.objects.create(
proctored_exam_id=exam_id,
user_id=user_id,
external_id=external_id,
started_at=datetime.now(pytz.UTC)
)
else:
return None
self.started_at = datetime.now(pytz.UTC)
self.save()
@classmethod
def get_student_exam_attempt(cls, exam_id, user_id):
def get_exam_attempt(cls, exam_id, user_id):
"""
Returns the Student Exam Attempt object if found
else Returns None.
......@@ -128,7 +136,7 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
return exam_attempt_obj
@classmethod
def get_active_student_exams(cls, user_id, course_id=None):
def get_active_student_attempts(cls, user_id, course_id=None):
"""
Returns the active student exams (user in-progress exams)
"""
......
......@@ -17,10 +17,14 @@
var currentTime = (new Date()).getTime();
var lastFetched = this.get('lastFetched').getTime();
var totalSeconds = this.get('time_remaining_seconds') - (currentTime - lastFetched) / 1000;
return (totalSeconds > 0) ? totalSeconds : 0;
return totalSeconds;
},
getFormattedRemainingTime: function () {
var totalSeconds = this.getRemainingSeconds();
/* since we can have a small grace period, we can end in the negative numbers */
if (totalSeconds < 0)
totalSeconds = 0;
var hours = parseInt(totalSeconds / 3600) % 24;
var minutes = parseInt(totalSeconds / 60) % 60;
var seconds = Math.floor(totalSeconds % 60);
......
......@@ -13,6 +13,8 @@ var edx = edx || {};
this.templateId = options.proctored_template;
this.template = null;
this.timerId = null;
/* give an extra 5 seconds where the timer holds at 00:00 before page refreshes */
this.grace_period_secs = 5;
var template_html = $(this.templateId).text();
if (template_html !== null) {
......@@ -47,7 +49,7 @@ var edx = edx || {};
self.$el.find('div.exam-timer').removeClass("low-time warning critical");
self.$el.find('div.exam-timer').addClass(self.model.getRemainingTimeState());
self.$el.find('span#time_remaining_id b').html(self.model.getFormattedRemainingTime());
if (self.model.getRemainingSeconds() <= 0) {
if (self.model.getRemainingSeconds() <= -self.grace_period_secs) {
clearInterval(self.timerId); // stop the timer once the time finishes.
// refresh the page when the timer expired
location.reload();
......
......@@ -15,7 +15,7 @@
</p>
<div class="gated-sequence">
<span><i class="fa fa-lock"></i></span>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-choice="proctored">
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true>
{% trans "Yes, take this exam as a proctored exam (and be eligible for credit)" %}
</a>
<p>
......@@ -24,11 +24,11 @@
of your exam. After successful installation, you will be <strong>guided through setting up your
proctored session and begin the exam immediately </strong>afterwards.</p>
{% endblocktrans %}
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-choice="proctored"></i>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=true></i>
</div>
<div class="gated-sequence">
<span><i class="fa fa-unlock"></i></span>
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-choice="unproctored">
<a class="start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false>
{% trans "No, take this exam as an open exam (and not be eligible for credit)" %}
</a>
<p>
......@@ -37,7 +37,7 @@
credit </strong>upon completing the exam or this course in general.
{% endblocktrans %}
</p>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-choice="unproctored"></i>
<i class="fa fa-arrow-circle-right start-timed-exam" data-ajax-url="{{enter_exam_endpoint}}" data-exam-id="{{exam_id}}" data-attempt-proctored=false></i>
</div>
</div>
{% include 'proctoring/seq_proctored_exam_footer.html' %}
......@@ -47,7 +47,7 @@
function(event) {
var action_url = $(this).data('ajax-url');
var exam_id = $(this).data('exam-id');
var choice = $(this).data('choice');
var attempt_proctored = $(this).data('attempt-proctored');
if (typeof action_url === "undefined" ) {
return false;
}
......@@ -55,7 +55,8 @@
action_url,
{
"exam_id": exam_id,
"choice": choice
"attempt_proctored": attempt_proctored,
"start_clock": false
},
function(data) {
// reload the page, because we've unlocked it
......
{% load i18n %}
<div class="sequence" data-exam-id="{{exam_id}}">
How to launch the proctored exam content goes here
</div>
......@@ -32,7 +32,8 @@
$.post(
action_url,
{
"exam_id": exam_id
"exam_id": exam_id,
"start_clock": true
},
function(data) {
// reload the page, because we've unlocked it
......
......@@ -13,7 +13,8 @@ from edx_proctoring.api import (
start_exam_attempt,
stop_exam_attempt,
get_active_exams_for_user,
get_exam_attempt
get_exam_attempt,
create_exam_attempt
)
from edx_proctoring.exceptions import (
ProctoredExamAlreadyExists,
......@@ -187,11 +188,11 @@ class ProctoredExamApiTests(LoggedInTestCase):
remove_allowance_for_user(student_allowance.proctored_exam.id, self.user_id, self.key)
self.assertEqual(len(ProctoredExamStudentAllowance.objects.filter()), 0)
def test_start_an_exam_attempt(self):
def test_create_an_exam_attempt(self):
"""
Start an exam attempt.
"""
attempt_id = start_exam_attempt(self.proctored_exam_id, self.user_id, self.external_id)
attempt_id = create_exam_attempt(self.proctored_exam_id, self.user_id, '')
self.assertGreater(attempt_id, 0)
def test_get_exam_attempt(self):
......@@ -211,7 +212,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
"""
proctored_exam_student_attempt = self._create_student_exam_attempt()
with self.assertRaises(StudentExamAttemptAlreadyExistsException):
start_exam_attempt(proctored_exam_student_attempt.proctored_exam, self.user_id, self.external_id)
create_exam_attempt(proctored_exam_student_attempt.proctored_exam, self.user_id, self.external_id)
def test_stop_exam_attempt(self):
"""
......@@ -244,11 +245,15 @@ class ProctoredExamApiTests(LoggedInTestCase):
exam_name='Final Test Exam',
time_limit_mins=self.default_time_limit
)
start_exam_attempt(
create_exam_attempt(
exam_id=exam_id,
user_id=self.user_id,
external_id=self.external_id
)
start_exam_attempt(
exam_id=exam_id,
user_id=self.user_id,
)
add_allowance_for_user(self.proctored_exam_id, self.user_id, self.key, self.value)
add_allowance_for_user(self.proctored_exam_id, self.user_id, 'new_key', 'new_value')
student_active_exams = get_active_exams_for_user(self.user_id, self.course_id)
......
......@@ -350,8 +350,8 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
)
attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
'external_id': proctored_exam.external_id,
'start_clock': True,
}
response = self.client.post(
reverse('edx_proctoring.proctored_exam.attempt'),
......@@ -376,7 +376,6 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
)
attempt_data = {
'exam_id': proctored_exam.id,
'user_id': self.student_taking_exam.id,
'external_id': proctored_exam.external_id
}
response = self.client.post(
......@@ -394,7 +393,10 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
)
self.assertEqual(response.status_code, 400)
response_data = json.loads(response.content)
self.assertEqual(response_data['detail'], 'Error. Trying to start an exam that has already started.')
self.assertEqual(
response_data['detail'],
'Cannot create new exam attempt for exam_id = 1 and user_id = 1 because it already exists!'
)
def test_stop_exam_attempt(self):
"""
......
......@@ -19,10 +19,13 @@ from edx_proctoring.api import (
stop_exam_attempt,
add_allowance_for_user,
remove_allowance_for_user,
get_active_exams_for_user
get_active_exams_for_user,
create_exam_attempt
)
from edx_proctoring.exceptions import (
ProctoredBaseException,
ProctoredExamNotFoundException,
)
from edx_proctoring.exceptions import ProctoredExamNotFoundException, \
StudentExamAttemptAlreadyExistsException, StudentExamAttemptDoesNotExistsException
from edx_proctoring.serializers import ProctoredExamSerializer
from .utils import AuthenticatedAPIView
......@@ -236,20 +239,26 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
def post(self, request):
"""
HTTP POST handler. To start an exam.
HTTP POST handler. To create an exam.
"""
start_immediately = request.DATA.get('start_clock', 'false').lower() == 'true'
exam_id = request.DATA.get('exam_id', None)
try:
exam_attempt_id = start_exam_attempt(
exam_id=request.DATA.get('exam_id', None),
exam_attempt_id = create_exam_attempt(
exam_id=exam_id,
user_id=request.user.id,
external_id=request.DATA.get('external_id', None)
external_id=request.DATA.get('external_id', None),
)
if start_immediately:
start_exam_attempt(exam_id, request.user.id)
return Response({'exam_attempt_id': exam_attempt_id})
except StudentExamAttemptAlreadyExistsException:
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "Error. Trying to start an exam that has already started."}
data={"detail": str(ex)}
)
def put(self, request):
......@@ -263,10 +272,10 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
)
return Response({"exam_attempt_id": exam_attempt_id})
except StudentExamAttemptDoesNotExistsException:
except ProctoredBaseException, ex:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": "Error. Trying to stop an exam that is not in progress."}
data={"detail": str(ex)}
)
......
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