Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-proctoring
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
OpenEdx
edx-proctoring
Commits
6e7b4dba
Commit
6e7b4dba
authored
Jul 16, 2015
by
chrisndodge
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #29 from edx/cdodge/punchlist2
Add status to attempts
parents
d7d6afe8
40f36c45
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
241 additions
and
38 deletions
+241
-38
edx_proctoring/api.py
+51
-6
edx_proctoring/callbacks.py
+6
-7
edx_proctoring/models.py
+46
-1
edx_proctoring/static/proctoring/js/models/proctored_exam_model.js
+1
-0
edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js
+8
-3
edx_proctoring/static/proctoring/js/views/proctored_exam_view.js
+9
-6
edx_proctoring/static/proctoring/templates/student-proctored-exam-attempts.underscore
+2
-3
edx_proctoring/templates/proctoring/seq_proctored_exam_instructions.html
+22
-6
edx_proctoring/tests/test_api.py
+32
-0
edx_proctoring/tests/test_views.py
+51
-2
edx_proctoring/views.py
+13
-4
No files found.
edx_proctoring/api.py
View file @
6e7b4dba
...
...
@@ -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
)
...
...
edx_proctoring/callbacks.py
View file @
6e7b4dba
...
...
@@ -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
({})))
edx_proctoring/models.py
View file @
6e7b4dba
...
...
@@ -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
):
...
...
edx_proctoring/static/proctoring/js/models/proctored_exam_model.js
View file @
6e7b4dba
...
...
@@ -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
()
{
...
...
edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js
View file @
6e7b4dba
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
);
edx_proctoring/static/proctoring/js/views/proctored_exam_view.js
View file @
6e7b4dba
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
);
edx_proctoring/static/proctoring/templates/student-proctored-exam-attempts.underscore
View file @
6e7b4dba
...
...
@@ -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.do
e
@gmail.com"
<% if (inSearchMode) { %>
value="<%= searchText %>"
<%} %>
...
...
@@ -118,4 +118,4 @@
</tbody>
</table>
</section>
</div>
\ No newline at end of file
</div>
edx_proctoring/templates/proctoring/seq_proctored_exam_instructions.html
View file @
6e7b4dba
...
...
@@ -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
()
}
});
}
});
}
...
...
edx_proctoring/tests/test_api.py
View file @
6e7b4dba
...
...
@@ -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
...
...
edx_proctoring/tests/test_views.py
View file @
6e7b4dba
...
...
@@ -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
.
assert
IsNotNone
(
attempt
[
'started_at'
]
)
self
.
assert
Equal
(
attempt
[
'status'
],
'ready_to_start'
)
def
test_bad_exam_code_callback
(
self
):
"""
...
...
edx_proctoring/views.py
View file @
6e7b4dba
...
...
@@ -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
=
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment