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
9ec6b99d
Commit
9ec6b99d
authored
Oct 22, 2015
by
Chris Dodge
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add analytics event firings
parent
fc072f90
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
311 additions
and
25 deletions
+311
-25
edx_proctoring/api.py
+71
-15
edx_proctoring/backends/software_secure.py
+19
-2
edx_proctoring/models.py
+11
-2
edx_proctoring/serializers.py
+1
-1
edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js
+1
-0
edx_proctoring/templates/proctored_exam/instructions.html
+24
-1
edx_proctoring/tests/test_api.py
+37
-2
edx_proctoring/tests/test_models.py
+16
-0
edx_proctoring/tests/test_services.py
+11
-0
edx_proctoring/tests/test_views.py
+46
-1
edx_proctoring/utils.py
+67
-0
edx_proctoring/views.py
+7
-1
No files found.
edx_proctoring/api.py
View file @
9ec6b99d
...
@@ -25,6 +25,7 @@ from edx_proctoring.exceptions import (
...
@@ -25,6 +25,7 @@ from edx_proctoring.exceptions import (
StudentExamAttemptedAlreadyStarted
,
StudentExamAttemptedAlreadyStarted
,
ProctoredExamIllegalStatusTransition
,
ProctoredExamIllegalStatusTransition
,
ProctoredExamPermissionDenied
,
ProctoredExamPermissionDenied
,
ProctoredExamNotActiveException
,
)
)
from
edx_proctoring.models
import
(
from
edx_proctoring.models
import
(
ProctoredExam
,
ProctoredExam
,
...
@@ -40,7 +41,8 @@ from edx_proctoring.serializers import (
...
@@ -40,7 +41,8 @@ from edx_proctoring.serializers import (
)
)
from
edx_proctoring.utils
import
(
from
edx_proctoring.utils
import
(
humanized_time
,
humanized_time
,
has_client_app_shutdown
has_client_app_shutdown
,
emit_event
)
)
from
edx_proctoring.backends
import
get_backend_provider
from
edx_proctoring.backends
import
get_backend_provider
...
@@ -87,6 +89,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
...
@@ -87,6 +89,10 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
)
)
log
.
info
(
log_msg
)
log
.
info
(
log_msg
)
# read back exam so we can emit an event on it
exam
=
get_exam_by_id
(
proctored_exam
.
id
)
emit_event
(
exam
,
'created'
)
return
proctored_exam
.
id
return
proctored_exam
.
id
...
@@ -130,6 +136,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant
...
@@ -130,6 +136,11 @@ def update_exam(exam_id, exam_name=None, time_limit_mins=None, due_date=constant
if
is_active
is
not
None
:
if
is_active
is
not
None
:
proctored_exam
.
is_active
=
is_active
proctored_exam
.
is_active
=
is_active
proctored_exam
.
save
()
proctored_exam
.
save
()
# read back exam so we can emit an event on it
exam
=
get_exam_by_id
(
proctored_exam
.
id
)
emit_event
(
exam
,
'updated'
)
return
proctored_exam
.
id
return
proctored_exam
.
id
...
@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value):
...
@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value):
)
)
log
.
info
(
log_msg
)
log
.
info
(
log_msg
)
ProctoredExamStudentAllowance
.
add_allowance_for_user
(
exam_id
,
user_info
,
key
,
value
)
try
:
student_allowance
,
action
=
ProctoredExamStudentAllowance
.
add_allowance_for_user
(
exam_id
,
user_info
,
key
,
value
)
except
ProctoredExamNotActiveException
:
raise
ProctoredExamNotActiveException
# let this exception raised so that we get 400 in case of inactive exam
if
student_allowance
is
not
None
:
# emit an event for 'allowance.created|updated'
data
=
{
'allowance_user'
:
student_allowance
.
user
,
'allowance_proctored_exam'
:
student_allowance
.
proctored_exam
,
'allowance_key'
:
student_allowance
.
key
,
'allowance_value'
:
student_allowance
.
value
}
exam
=
get_exam_by_id
(
exam_id
)
emit_event
(
exam
,
'allowance.{action}'
.
format
(
action
=
action
),
override_data
=
data
)
def
get_allowances_for_course
(
course_id
,
timed_exams_only
=
False
):
def
get_allowances_for_course
(
course_id
,
timed_exams_only
=
False
):
...
@@ -222,6 +248,17 @@ def remove_allowance_for_user(exam_id, user_id, key):
...
@@ -222,6 +248,17 @@ def remove_allowance_for_user(exam_id, user_id, key):
if
student_allowance
is
not
None
:
if
student_allowance
is
not
None
:
student_allowance
.
delete
()
student_allowance
.
delete
()
# emit an event for 'allowance.deleted'
data
=
{
'allowance_user'
:
student_allowance
.
user
,
'allowance_proctored_exam'
:
student_allowance
.
proctored_exam
,
'allowance_key'
:
student_allowance
.
key
,
'allowance_value'
:
student_allowance
.
value
}
exam
=
get_exam_by_id
(
exam_id
)
emit_event
(
exam
,
'allowance.deleted'
,
override_data
=
data
)
def
_check_for_attempt_timeout
(
attempt
):
def
_check_for_attempt_timeout
(
attempt
):
"""
"""
...
@@ -601,6 +638,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
...
@@ -601,6 +638,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
else
:
else
:
return
return
exam
=
get_exam_by_id
(
exam_id
)
#
#
# don't allow state transitions from a completed state to an incomplete state
# don't allow state transitions from a completed state to an incomplete state
# if a re-attempt is desired then the current attempt must be deleted
# if a re-attempt is desired then the current attempt must be deleted
...
@@ -646,7 +685,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
...
@@ -646,7 +685,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# trigger credit workflow, as needed
# trigger credit workflow, as needed
credit_service
=
get_runtime_service
(
'credit'
)
credit_service
=
get_runtime_service
(
'credit'
)
exam
=
get_exam_by_id
(
exam_id
)
if
to_status
==
ProctoredExamStudentAttemptStatus
.
verified
:
if
to_status
==
ProctoredExamStudentAttemptStatus
.
verified
:
credit_requirement_status
=
'satisfied'
credit_requirement_status
=
'satisfied'
elif
to_status
==
ProctoredExamStudentAttemptStatus
.
submitted
:
elif
to_status
==
ProctoredExamStudentAttemptStatus
.
submitted
:
...
@@ -689,35 +727,35 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
...
@@ -689,35 +727,35 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# one exam all other (un-completed) proctored exams will be likewise
# one exam all other (un-completed) proctored exams will be likewise
# updated to reflect a declined status
# updated to reflect a declined status
# get all other unattempted exams and mark also as declined
# get all other unattempted exams and mark also as declined
_exams
=
ProctoredExam
.
get_all_exams_for_course
(
all_other
_exams
=
ProctoredExam
.
get_all_exams_for_course
(
exam_attempt_obj
.
proctored_exam
.
course_id
,
exam_attempt_obj
.
proctored_exam
.
course_id
,
active_only
=
True
active_only
=
True
)
)
# we just want other exams which are proctored and are not practice
# we just want other exams which are proctored and are not practice
exams
=
[
other_
exams
=
[
_exam
other
_exam
for
_exam
in
_exams
for
other_exam
in
all_other
_exams
if
(
if
(
_exam
.
content_id
!=
exam_attempt_obj
.
proctored_exam
.
content_id
and
other
_exam
.
content_id
!=
exam_attempt_obj
.
proctored_exam
.
content_id
and
_exam
.
is_proctored
and
not
_exam
.
is_practice_exam
other_exam
.
is_proctored
and
not
other
_exam
.
is_practice_exam
)
)
]
]
for
exam
in
exams
:
for
other_exam
in
other_
exams
:
# see if there was an attempt on those other exams already
# see if there was an attempt on those other exams already
attempt
=
get_exam_attempt
(
exam
.
id
,
user_id
)
attempt
=
get_exam_attempt
(
other_
exam
.
id
,
user_id
)
if
attempt
and
ProctoredExamStudentAttemptStatus
.
is_completed_status
(
attempt
[
'status'
]):
if
attempt
and
ProctoredExamStudentAttemptStatus
.
is_completed_status
(
attempt
[
'status'
]):
# don't touch any completed statuses
# don't touch any completed statuses
# we won't revoke those
# we won't revoke those
continue
continue
if
not
attempt
:
if
not
attempt
:
create_exam_attempt
(
exam
.
id
,
user_id
,
taking_as_proctored
=
False
)
create_exam_attempt
(
other_
exam
.
id
,
user_id
,
taking_as_proctored
=
False
)
# update any new or existing status to declined
# update any new or existing status to declined
update_attempt_status
(
update_attempt_status
(
exam
.
id
,
other_
exam
.
id
,
user_id
,
user_id
,
ProctoredExamStudentAttemptStatus
.
declined
,
ProctoredExamStudentAttemptStatus
.
declined
,
cascade_effects
=
False
cascade_effects
=
False
...
@@ -762,7 +800,15 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
...
@@ -762,7 +800,15 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
credit_state
.
get
(
'course_name'
,
_
(
'your course'
))
credit_state
.
get
(
'course_name'
,
_
(
'your course'
))
)
)
return
exam_attempt_obj
.
id
# emit an anlytics event based on the state transition
# we re-read this from the database in case fields got updated
# via workflow
attempt
=
get_exam_attempt
(
exam_id
,
user_id
)
# we user the 'status' field as the name of the event 'verb'
emit_event
(
exam
,
attempt
[
'status'
],
attempt
=
attempt
)
return
attempt
[
'id'
]
def
send_proctoring_attempt_status_email
(
exam_attempt_obj
,
course_name
):
def
send_proctoring_attempt_status_email
(
exam_attempt_obj
,
course_name
):
...
@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_id):
...
@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_id):
req_name
=
content_id
req_name
=
content_id
)
)
# emit an event for 'deleted'
exam
=
get_exam_by_content_id
(
course_id
,
content_id
)
serialized_attempt_obj
=
ProctoredExamStudentAttemptSerializer
(
existing_attempt
)
attempt
=
serialized_attempt_obj
.
data
emit_event
(
exam
,
'deleted'
,
attempt
=
attempt
)
def
get_all_exams_for_course
(
course_id
,
timed_exams_only
=
False
,
active_only
=
False
):
def
get_all_exams_for_course
(
course_id
,
timed_exams_only
=
False
,
active_only
=
False
):
"""
"""
...
@@ -1524,12 +1576,16 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
...
@@ -1524,12 +1576,16 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
student_view_template
=
'proctored_exam/pending-prerequisites.html'
student_view_template
=
'proctored_exam/pending-prerequisites.html'
else
:
else
:
student_view_template
=
'proctored_exam/entrance.html'
student_view_template
=
'proctored_exam/entrance.html'
# emit an event that the user was presented with the option
# to start timed exam
emit_event
(
exam
,
'option-presented'
)
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
started
:
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
started
:
# when we're taking the exam we should not override the view
# when we're taking the exam we should not override the view
return
None
return
None
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
expired
:
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
expired
:
student_view_template
=
'proctored_exam/expired.html'
student_view_template
=
'proctored_exam/expired.html'
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
created
:
elif
attempt_status
in
[
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
]:
provider
=
get_backend_provider
()
provider
=
get_backend_provider
()
student_view_template
=
'proctored_exam/instructions.html'
student_view_template
=
'proctored_exam/instructions.html'
context
.
update
({
context
.
update
({
...
...
edx_proctoring/backends/software_secure.py
View file @
9ec6b99d
...
@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import (
...
@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists
,
ProctoredExamReviewAlreadyExists
,
ProctoredExamBadReviewStatus
,
ProctoredExamBadReviewStatus
,
)
)
from
edx_proctoring.utils
import
locate_attempt_by_attempt_code
from
edx_proctoring.utils
import
locate_attempt_by_attempt_code
,
emit_event
from
edx_proctoring
.
models
import
(
from
edx_proctoring
.
models
import
(
ProctoredExamSoftwareSecureReview
,
ProctoredExamSoftwareSecureReview
,
ProctoredExamSoftwareSecureComment
,
ProctoredExamSoftwareSecureComment
,
ProctoredExamStudentAttemptStatus
,
ProctoredExamStudentAttemptStatus
,
)
)
from
edx_proctoring.serializers
import
(
ProctoredExamSerializer
,
ProctoredExamStudentAttemptSerializer
,
)
log
=
logging
.
getLogger
(
__name__
)
log
=
logging
.
getLogger
(
__name__
)
...
@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
...
@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
self
.
on_review_saved
(
review
,
allow_status_update_on_fail
=
allow_status_update_on_fail
)
self
.
on_review_saved
(
review
,
allow_status_update_on_fail
=
allow_status_update_on_fail
)
# emit an event for 'review-received'
data
=
{
'review_attempt_code'
:
review
.
attempt_code
,
'review_raw_data'
:
review
.
raw_data
,
'review_status'
:
review
.
review_status
,
'review_video_url'
:
review
.
video_url
}
serialized_attempt_obj
=
ProctoredExamStudentAttemptSerializer
(
attempt_obj
)
attempt
=
serialized_attempt_obj
.
data
serialized_exam_object
=
ProctoredExamSerializer
(
attempt_obj
.
proctored_exam
)
exam
=
serialized_exam_object
.
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_status_update_on_fail
=
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
...
...
edx_proctoring/models.py
View file @
9ec6b99d
# pylint: disable=too-many-lines
"""
"""
Data models for the proctoring subsystem
Data models for the proctoring subsystem
"""
"""
...
@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object):
...
@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object):
# been started
# been started
created
=
'created'
created
=
'created'
# the student has clicked on the external
# software download link
download_software_clicked
=
'download_software_clicked'
# the attempt is ready to start but requires
# the attempt is ready to start but requires
# user to acknowledge that he/she wants to start the exam
# user to acknowledge that he/she wants to start the exam
ready_to_start
=
'ready_to_start'
ready_to_start
=
'ready_to_start'
...
@@ -184,7 +189,8 @@ class ProctoredExamStudentAttemptStatus(object):
...
@@ -184,7 +189,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state.
Returns a boolean if the passed in status is in an "incomplete" state.
"""
"""
return
status
in
[
return
status
in
[
cls
.
eligible
,
cls
.
created
,
cls
.
ready_to_start
,
cls
.
started
,
cls
.
ready_to_submit
cls
.
eligible
,
cls
.
created
,
cls
.
download_software_clicked
,
cls
.
ready_to_start
,
cls
.
started
,
cls
.
ready_to_submit
]
]
@classmethod
@classmethod
...
@@ -742,8 +748,11 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
...
@@ -742,8 +748,11 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
student_allowance
=
cls
.
objects
.
get
(
proctored_exam_id
=
exam_id
,
user_id
=
user_id
,
key
=
key
)
student_allowance
=
cls
.
objects
.
get
(
proctored_exam_id
=
exam_id
,
user_id
=
user_id
,
key
=
key
)
student_allowance
.
value
=
value
student_allowance
.
value
=
value
student_allowance
.
save
()
student_allowance
.
save
()
action
=
"updated"
except
cls
.
DoesNotExist
:
# pylint: disable=no-member
except
cls
.
DoesNotExist
:
# pylint: disable=no-member
cls
.
objects
.
create
(
proctored_exam_id
=
exam_id
,
user_id
=
user_id
,
key
=
key
,
value
=
value
)
student_allowance
=
cls
.
objects
.
create
(
proctored_exam_id
=
exam_id
,
user_id
=
user_id
,
key
=
key
,
value
=
value
)
action
=
"created"
return
student_allowance
,
action
@classmethod
@classmethod
def
is_allowance_value_valid
(
cls
,
allowance_type
,
allowance_value
):
def
is_allowance_value_valid
(
cls
,
allowance_type
,
allowance_value
):
...
...
edx_proctoring/serializers.py
View file @
9ec6b99d
...
@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
...
@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id"
,
"created"
,
"modified"
,
"user"
,
"started_at"
,
"completed_at"
,
"id"
,
"created"
,
"modified"
,
"user"
,
"started_at"
,
"completed_at"
,
"external_id"
,
"status"
,
"proctored_exam"
,
"allowed_time_limit_mins"
,
"external_id"
,
"status"
,
"proctored_exam"
,
"allowed_time_limit_mins"
,
"attempt_code"
,
"is_sample_attempt"
,
"taking_as_proctored"
,
"last_poll_timestamp"
,
"attempt_code"
,
"is_sample_attempt"
,
"taking_as_proctored"
,
"last_poll_timestamp"
,
"last_poll_ipaddr"
,
"review_policy_id"
"last_poll_ipaddr"
,
"review_policy_id"
,
"student_name"
)
)
...
...
edx_proctoring/static/proctoring/js/views/proctored_exam_attempt_view.js
View file @
9ec6b99d
...
@@ -8,6 +8,7 @@ var edx = edx || {};
...
@@ -8,6 +8,7 @@ var edx = edx || {};
var
examStatusReadableFormat
=
{
var
examStatusReadableFormat
=
{
eligible
:
gettext
(
'Eligible'
),
eligible
:
gettext
(
'Eligible'
),
created
:
gettext
(
'Created'
),
created
:
gettext
(
'Created'
),
download_software_clicked
:
gettext
(
'Download Software Clicked'
),
ready_to_start
:
gettext
(
'Ready to start'
),
ready_to_start
:
gettext
(
'Ready to start'
),
started
:
gettext
(
'Started'
),
started
:
gettext
(
'Started'
),
ready_to_submit
:
gettext
(
'Ready to submit'
),
ready_to_submit
:
gettext
(
'Ready to submit'
),
...
...
edx_proctoring/templates/proctored_exam/instructions.html
View file @
9ec6b99d
...
@@ -26,7 +26,7 @@
...
@@ -26,7 +26,7 @@
{% endblocktrans %}
{% endblocktrans %}
</p>
</p>
<p>
<p>
<span><a
href=
"
{{software_download_url}}
"
target=
"_blank"
>
Start System Check
</a></span>
<span><a
href=
"
#"
id=
"software_download_link"
data-action=
"click_download_software
"
target=
"_blank"
>
Start System Check
</a></span>
</p>
</p>
<p>
<p>
{% blocktrans %}
{% blocktrans %}
...
@@ -108,4 +108,27 @@
...
@@ -108,4 +108,27 @@
});
});
}
}
$
(
"#software_download_link"
).
click
(
function
(
e
)
{
e
.
preventDefault
();
var
url
=
$
(
'.instructions'
).
data
(
'exam-started-poll-url'
);
var
action
=
$
(
this
).
data
(
'action'
);
// open the new tab in the click event with an empty URL but show the message.
var
newWindow
=
window
.
open
(
""
,
"_blank"
);
$
(
newWindow
.
document
.
body
).
html
(
"<p>Please wait while you are being redirected...</p>"
);
var
self
=
this
;
$
.
ajax
({
url
:
url
,
type
:
'PUT'
,
data
:
{
action
:
action
},
success
:
function
(
data
)
{
newWindow
.
location
=
"{{software_download_url}}"
;
}
}).
fail
(
function
(){
newWindow
.
close
();
});
});
</script>
</script>
edx_proctoring/tests/test_api.py
View file @
9ec6b99d
...
@@ -66,6 +66,7 @@ from .utils import (
...
@@ -66,6 +66,7 @@ from .utils import (
from
edx_proctoring.tests.test_services
import
(
from
edx_proctoring.tests.test_services
import
(
MockCreditService
,
MockCreditService
,
MockInstructorService
,
MockInstructorService
,
MockAnalyticsService
,
)
)
from
edx_proctoring.runtime
import
set_runtime_service
,
get_runtime_service
from
edx_proctoring.runtime
import
set_runtime_service
,
get_runtime_service
...
@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
set_runtime_service
(
'credit'
,
MockCreditService
())
set_runtime_service
(
'credit'
,
MockCreditService
())
set_runtime_service
(
'instructor'
,
MockInstructorService
(
is_user_course_staff
=
True
))
set_runtime_service
(
'instructor'
,
MockInstructorService
(
is_user_course_staff
=
True
))
set_runtime_service
(
'analytics'
,
MockAnalyticsService
())
self
.
prerequisites
=
[
self
.
prerequisites
=
[
{
{
...
@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt
=
self
.
_create_unstarted_exam_attempt
()
proctored_exam_student_attempt
=
self
.
_create_unstarted_exam_attempt
()
self
.
assertIsNone
(
proctored_exam_student_attempt
.
completed_at
)
self
.
assertIsNone
(
proctored_exam_student_attempt
.
completed_at
)
proctored_exam_attempt_id
=
stop_exam_attempt
(
proctored_exam_attempt_id
=
stop_exam_attempt
(
proctored_exam_student_attempt
.
proctored_exam
,
self
.
user_id
proctored_exam_student_attempt
.
proctored_exam
.
id
,
self
.
user_id
)
)
self
.
assertEqual
(
proctored_exam_student_attempt
.
id
,
proctored_exam_attempt_id
)
self
.
assertEqual
(
proctored_exam_student_attempt
.
id
,
proctored_exam_attempt_id
)
...
@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt
=
self
.
_create_unstarted_exam_attempt
()
proctored_exam_student_attempt
=
self
.
_create_unstarted_exam_attempt
()
self
.
assertIsNone
(
proctored_exam_student_attempt
.
completed_at
)
self
.
assertIsNone
(
proctored_exam_student_attempt
.
completed_at
)
proctored_exam_attempt_id
=
mark_exam_attempt_as_ready
(
proctored_exam_attempt_id
=
mark_exam_attempt_as_ready
(
proctored_exam_student_attempt
.
proctored_exam
,
self
.
user_id
proctored_exam_student_attempt
.
proctored_exam
.
id
,
self
.
user_id
)
)
self
.
assertEqual
(
proctored_exam_student_attempt
.
id
,
proctored_exam_attempt_id
)
self
.
assertEqual
(
proctored_exam_student_attempt
.
id
,
proctored_exam_attempt_id
)
...
@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
self
.
assertIn
(
self
.
chose_proctored_exam_msg
,
rendered_response
)
self
.
assertIn
(
self
.
chose_proctored_exam_msg
,
rendered_response
)
self
.
assertIn
(
self
.
proctored_exam_optout_msg
,
rendered_response
)
self
.
assertIn
(
self
.
proctored_exam_optout_msg
,
rendered_response
)
# now make sure content remains the same if
# the status transitions to 'download_software_clicked'
update_attempt_status
(
self
.
proctored_exam_id
,
self
.
user_id
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
)
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
.
chose_proctored_exam_msg
,
rendered_response
)
self
.
assertIn
(
self
.
proctored_exam_optout_msg
,
rendered_response
)
def
test_get_studentview_unstarted_practice_exam
(
self
):
def
test_get_studentview_unstarted_practice_exam
(
self
):
"""
"""
Test for get_student_view Practice exam which has not started yet.
Test for get_student_view Practice exam which has not started yet.
...
@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
(
ProctoredExamStudentAttemptStatus
.
declined
,
ProctoredExamStudentAttemptStatus
.
eligible
),
(
ProctoredExamStudentAttemptStatus
.
declined
,
ProctoredExamStudentAttemptStatus
.
eligible
),
(
ProctoredExamStudentAttemptStatus
.
timed_out
,
ProctoredExamStudentAttemptStatus
.
created
),
(
ProctoredExamStudentAttemptStatus
.
timed_out
,
ProctoredExamStudentAttemptStatus
.
created
),
(
ProctoredExamStudentAttemptStatus
.
expired
,
ProctoredExamStudentAttemptStatus
.
created
),
(
ProctoredExamStudentAttemptStatus
.
expired
,
ProctoredExamStudentAttemptStatus
.
created
),
(
ProctoredExamStudentAttemptStatus
.
timed_out
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
),
(
ProctoredExamStudentAttemptStatus
.
expired
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
),
(
ProctoredExamStudentAttemptStatus
.
submitted
,
ProctoredExamStudentAttemptStatus
.
ready_to_start
),
(
ProctoredExamStudentAttemptStatus
.
submitted
,
ProctoredExamStudentAttemptStatus
.
ready_to_start
),
(
ProctoredExamStudentAttemptStatus
.
verified
,
ProctoredExamStudentAttemptStatus
.
started
),
(
ProctoredExamStudentAttemptStatus
.
verified
,
ProctoredExamStudentAttemptStatus
.
started
),
(
ProctoredExamStudentAttemptStatus
.
rejected
,
ProctoredExamStudentAttemptStatus
.
started
),
(
ProctoredExamStudentAttemptStatus
.
rejected
,
ProctoredExamStudentAttemptStatus
.
started
),
...
@@ -1969,6 +1994,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -1969,6 +1994,14 @@ class ProctoredExamApiTests(LoggedInTestCase):
}
}
),
),
(
(
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
{
'status'
:
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
'short_description'
:
'Taking As Proctored Exam'
,
'suggested_icon'
:
'fa-pencil-square-o'
,
'in_completed_state'
:
False
}
),
(
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
{
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
{
'status'
:
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
'status'
:
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
'short_description'
:
'Taking As Proctored Exam'
,
'short_description'
:
'Taking As Proctored Exam'
,
...
@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data
(
@ddt.data
(
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
ProctoredExamStudentAttemptStatus
.
started
,
ProctoredExamStudentAttemptStatus
.
started
,
ProctoredExamStudentAttemptStatus
.
ready_to_submit
,
ProctoredExamStudentAttemptStatus
.
ready_to_submit
,
...
@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
...
@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data
(
@ddt.data
(
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
ProctoredExamStudentAttemptStatus
.
submitted
,
ProctoredExamStudentAttemptStatus
.
submitted
,
ProctoredExamStudentAttemptStatus
.
verified
,
ProctoredExamStudentAttemptStatus
.
verified
,
ProctoredExamStudentAttemptStatus
.
rejected
,
ProctoredExamStudentAttemptStatus
.
rejected
,
...
...
edx_proctoring/tests/test_models.py
View file @
9ec6b99d
...
@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
...
@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
Tests for the ProctoredExamStudentAttempt Model
Tests for the ProctoredExamStudentAttempt Model
"""
"""
def
test_exam_unicode
(
self
):
"""
Serialize the object as a display string
"""
proctored_exam
=
ProctoredExam
.
objects
.
create
(
course_id
=
'test_course'
,
content_id
=
'test_content'
,
exam_name
=
'Test Exam'
,
external_id
=
'123aXqe3'
,
time_limit_mins
=
90
)
string
=
unicode
(
proctored_exam
)
self
.
assertEqual
(
string
,
"test_course: Test Exam (inactive)"
)
def
test_delete_proctored_exam_attempt
(
self
):
# pylint: disable=invalid-name
def
test_delete_proctored_exam_attempt
(
self
):
# pylint: disable=invalid-name
"""
"""
Deleting the proctored exam attempt creates an entry in the history table.
Deleting the proctored exam attempt creates an entry in the history table.
...
@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
...
@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
external_id
=
'123aXqe3'
,
external_id
=
'123aXqe3'
,
time_limit_mins
=
90
time_limit_mins
=
90
)
)
attempt
=
ProctoredExamStudentAttempt
.
objects
.
create
(
attempt
=
ProctoredExamStudentAttempt
.
objects
.
create
(
proctored_exam_id
=
proctored_exam
.
id
,
proctored_exam_id
=
proctored_exam
.
id
,
user_id
=
1
,
user_id
=
1
,
...
...
edx_proctoring/tests/test_services.py
View file @
9ec6b99d
...
@@ -109,6 +109,17 @@ class MockInstructorService(object):
...
@@ -109,6 +109,17 @@ class MockInstructorService(object):
return
self
.
is_user_course_staff
return
self
.
is_user_course_staff
class
MockAnalyticsService
(
object
):
"""
A mock implementation of the 'analytics' service
"""
def
emit_event
(
self
,
name
,
context
,
data
):
"""
Do nothing
"""
pass
class
TestProctoringService
(
unittest
.
TestCase
):
class
TestProctoringService
(
unittest
.
TestCase
):
"""
"""
Tests for ProctoringService
Tests for ProctoringService
...
...
edx_proctoring/tests/test_views.py
View file @
9ec6b99d
...
@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
...
@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data
=
json
.
loads
(
response
.
content
)
response_data
=
json
.
loads
(
response
.
content
)
self
.
assertEqual
(
response_data
[
'exam_attempt_id'
],
old_attempt_id
)
self
.
assertEqual
(
response_data
[
'exam_attempt_id'
],
old_attempt_id
)
def
test_download_software_clicked_action
(
self
):
"""
Test if the download_software_clicked state is set
"""
# 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
,
'user_id'
:
self
.
student_taking_exam
.
id
,
'external_id'
:
proctored_exam
.
external_id
}
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'
]
response
=
self
.
client
.
put
(
reverse
(
'edx_proctoring.proctored_exam.attempt'
,
args
=
[
old_attempt_id
]),
{
'action'
:
'click_download_software'
,
}
)
self
.
assertEqual
(
response
.
status_code
,
200
)
response_data
=
json
.
loads
(
response
.
content
)
self
.
assertEqual
(
response_data
[
'exam_attempt_id'
],
old_attempt_id
)
attempt
=
get_exam_attempt_by_id
(
old_attempt_id
)
self
.
assertEqual
(
attempt
[
'status'
],
ProctoredExamStudentAttemptStatus
.
download_software_clicked
)
@ddt.data
(
@ddt.data
(
(
'submit'
,
ProctoredExamStudentAttemptStatus
.
submitted
),
(
'submit'
,
ProctoredExamStudentAttemptStatus
.
submitted
),
(
'decline'
,
ProctoredExamStudentAttemptStatus
.
declined
)
(
'decline'
,
ProctoredExamStudentAttemptStatus
.
declined
)
...
@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
...
@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
attempt_data
attempt_data
)
)
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
status_code
,
200
)
data
=
json
.
loads
(
response
.
content
)
attempt
=
get_exam_attempt_by_id
(
data
[
'exam_attempt_id'
])
self
.
assertEqual
(
attempt
[
'status'
],
ProctoredExamStudentAttemptStatus
.
submitted
)
response
=
self
.
client
.
get
(
response
=
self
.
client
.
get
(
reverse
(
'edx_proctoring.proctored_exam.attempt.collection'
)
reverse
(
'edx_proctoring.proctored_exam.attempt.collection'
)
)
)
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
status_code
,
200
)
data
=
json
.
loads
(
response
.
content
)
data
=
json
.
loads
(
response
.
content
)
self
.
assert
Equal
(
data
[
'time_remaining_seconds'
],
0
)
self
.
assert
NotIn
(
'time_remaining_seconds'
,
data
)
def
test_get_expired_exam_attempt
(
self
):
def
test_get_expired_exam_attempt
(
self
):
"""
"""
...
...
edx_proctoring/utils.py
View file @
9ec6b99d
...
@@ -16,6 +16,7 @@ from edx_proctoring.models import (
...
@@ -16,6 +16,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAttemptHistory
,
ProctoredExamStudentAttemptHistory
,
)
)
from
edx_proctoring
import
constants
from
edx_proctoring
import
constants
from
edx_proctoring.runtime
import
get_runtime_service
log
=
logging
.
getLogger
(
__name__
)
log
=
logging
.
getLogger
(
__name__
)
...
@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt):
...
@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt):
elapsed_time
=
(
datetime
.
now
(
pytz
.
UTC
)
-
attempt
[
'last_poll_timestamp'
])
.
total_seconds
()
elapsed_time
=
(
datetime
.
now
(
pytz
.
UTC
)
-
attempt
[
'last_poll_timestamp'
])
.
total_seconds
()
return
elapsed_time
>
constants
.
SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD
return
elapsed_time
>
constants
.
SOFTWARE_SECURE_SHUT_DOWN_GRACEPERIOD
def
emit_event
(
exam
,
event_short_name
,
attempt
=
None
,
override_data
=
None
):
"""
Helper method to emit an analytics event
"""
exam_type
=
(
'timed'
if
not
exam
[
'is_proctored'
]
else
(
'practice'
if
exam
[
'is_practice_exam'
]
else
'proctored'
)
)
# establish baseline schema for event 'context'
context
=
{
'course_id'
:
exam
[
'course_id'
]
}
# establish baseline schema for event 'data'
data
=
{
'exam_id'
:
exam
[
'id'
],
'exam_content_id'
:
exam
[
'content_id'
],
'exam_name'
:
exam
[
'exam_name'
],
'exam_default_time_limit_mins'
:
exam
[
'time_limit_mins'
],
'exam_is_proctored'
:
exam
[
'is_proctored'
],
'exam_is_practice_exam'
:
exam
[
'is_practice_exam'
],
'exam_is_active'
:
exam
[
'is_active'
]
}
if
attempt
:
# if an attempt is passed in then use that to add additional baseline
# schema elements
# let's compute the relative time we're firing the event
# compared to the start time, if the attempt has already started.
# This can be used to determine how far into an attempt a given
# event occured (e.g. "time to complete exam")
attempt_event_elapsed_time_secs
=
(
(
datetime
.
now
(
pytz
.
UTC
)
-
attempt
[
'started_at'
])
.
seconds
if
attempt
[
'started_at'
]
else
None
)
attempt_data
=
{
'attempt_id'
:
attempt
[
'id'
],
'attempt_user_id'
:
attempt
[
'user'
][
'id'
],
'attempt_username'
:
attempt
[
'student_name'
],
'attempt_started_at'
:
attempt
[
'started_at'
],
'attempt_completed_at'
:
attempt
[
'completed_at'
],
'attempt_code'
:
attempt
[
'attempt_code'
],
'attempt_allowed_time_limit_mins'
:
attempt
[
'allowed_time_limit_mins'
],
'attempt_status'
:
attempt
[
'status'
],
'attempt_event_elapsed_time_secs'
:
attempt_event_elapsed_time_secs
}
data
.
update
(
attempt_data
)
name
=
'.'
.
join
([
'edx'
,
'special-exam'
,
exam_type
,
'attempt'
,
event_short_name
])
else
:
name
=
'.'
.
join
([
'edx'
,
'special-exam'
,
exam_type
,
event_short_name
])
# allow caller to override event data
if
override_data
:
data
.
update
(
override_data
)
service
=
get_runtime_service
(
'analytics'
)
if
service
:
service
.
emit_event
(
name
,
context
,
data
)
else
:
log
.
warn
(
'Analytics event service not configured. If this is a production environment, please resolve.'
)
edx_proctoring/views.py
View file @
9ec6b99d
...
@@ -424,6 +424,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
...
@@ -424,6 +424,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
request
.
user
.
id
,
request
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
submitted
ProctoredExamStudentAttemptStatus
.
submitted
)
)
elif
action
==
'click_download_software'
:
exam_attempt_id
=
update_attempt_status
(
attempt
[
'proctored_exam'
][
'id'
],
request
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
)
elif
action
==
'decline'
:
elif
action
==
'decline'
:
exam_attempt_id
=
update_attempt_status
(
exam_attempt_id
=
update_attempt_status
(
attempt
[
'proctored_exam'
][
'id'
],
attempt
[
'proctored_exam'
][
'id'
],
...
@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
...
@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
else
:
else
:
response_dict
=
{
response_dict
=
{
'in_timed_exam'
:
False
,
'in_timed_exam'
:
False
,
'is_proctored'
:
False
,
'is_proctored'
:
False
}
}
return
Response
(
return
Response
(
...
...
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